diff --git a/CLAUDE.md b/CLAUDE.md index 0249008..0d5cac8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -355,12 +355,31 @@ verbose_announcements = true - **Challenge**: Users want different audio feedback - **Solution**: Comprehensive sound pack system with easy installation +## Planned Feature Additions (TODO) + +### High Priority Missing Features +- **Direct Message Interface**: Dedicated DM tab with conversation threading (separate from private posts) +- **Bookmarks Tab**: Timeline tab for viewing saved/bookmarked posts +- **User Blocking**: Block/unblock users with management interface +- **User Muting**: Mute/unmute users functionality +- **Poll Support**: Create and vote on polls with accessible interface + +### Medium Priority Features +- **Blocked Users Management**: Tab/dialog to view and manage blocked users +- **Poll Creation**: Add poll options to compose dialog +- **Poll Voting**: Accessible poll interaction ("Poll: What's your favorite color? 3 options, press Enter to vote") + +### Implementation Notes +- Models already have bookmark, muted, blocking fields - just need API integration +- Timeline will need additional tabs: Home, Mentions, Local, Federated, DMs, Bookmarks +- Poll accessibility: Announce poll in timeline, Enter to interact, arrow keys to navigate options +- DM interface should show conversation threads rather than timeline format + ## Future Enhancements ### Advanced Features - Custom timeline filters -- Multiple column support -- Direct message interface +- Multiple column support - List management - Advanced search diff --git a/README.md b/README.md index 5e91e57..5b7e4b8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ This project was created through "vibe coding" - a collaborative development app - **Full ActivityPub Support**: Compatible with Pleroma, GoToSocial, and other fediverse servers - **Screen Reader Optimized**: Designed from the ground up for excellent accessibility - **Threaded Conversations**: Navigate complex conversation trees with keyboard shortcuts -- **Timeline Switching**: Easy navigation between Home, Mentions, Local, and Federated timelines +- **Timeline Switching**: Easy navigation between Home, Messages, Mentions, Local, Federated, Bookmarks, Followers, and Following timelines - **Desktop Notifications**: Cross-platform notifications for mentions, direct messages, and timeline updates - **Customizable Audio Feedback**: Rich sound pack system with themed audio notifications - **Soundpack Manager**: Secure repository-based soundpack discovery and installation @@ -22,6 +22,8 @@ This project was created through "vibe coding" - a collaborative development app - **Auto-refresh**: Intelligent timeline updates based on user activity - **Clean Interface**: Focused on functionality over visual design - **Keyboard Navigation**: Complete keyboard control with intuitive shortcuts +- **Direct Message Interface**: Dedicated conversation view with threading support +- **Bookmarks**: Save and view bookmarked posts in a dedicated timeline ## Audio System @@ -57,9 +59,13 @@ Bifrost includes a sophisticated sound system with: ### Timeline Navigation - **Ctrl+1**: Switch to Home timeline -- **Ctrl+2**: Switch to Mentions/Notifications timeline -- **Ctrl+3**: Switch to Local timeline -- **Ctrl+4**: Switch to Federated timeline +- **Ctrl+2**: Switch to Messages/DM timeline +- **Ctrl+3**: Switch to Mentions/Notifications timeline +- **Ctrl+4**: Switch to Local timeline +- **Ctrl+5**: Switch to Federated timeline +- **Ctrl+6**: Switch to Bookmarks timeline +- **Ctrl+7**: Switch to Followers timeline +- **Ctrl+8**: Switch to Following timeline - **Ctrl+Tab**: Switch between timeline tabs - **F5**: Refresh current timeline diff --git a/src/activitypub/client.py b/src/activitypub/client.py index 89d436e..9bfdbe4 100644 --- a/src/activitypub/client.py +++ b/src/activitypub/client.py @@ -289,6 +289,84 @@ class ActivityPubClient: params = {'id': account_id} result = self._make_request('GET', '/api/v1/accounts/relationships', params=params) return result[0] if result else {} + + def get_conversations(self, limit: int = 20, max_id: Optional[str] = None, + since_id: Optional[str] = None, min_id: Optional[str] = None) -> List[Dict]: + """Get direct message conversations""" + params = {'limit': limit} + if max_id: + params['max_id'] = max_id + if since_id: + params['since_id'] = since_id + if min_id: + params['min_id'] = min_id + + return self._make_request('GET', '/api/v1/conversations', params=params) + + def mark_conversation_read(self, conversation_id: str) -> Dict: + """Mark a conversation as read""" + endpoint = f'/api/v1/conversations/{conversation_id}/read' + return self._make_request('POST', endpoint) + + def delete_conversation(self, conversation_id: str) -> Dict: + """Remove conversation from list""" + endpoint = f'/api/v1/conversations/{conversation_id}' + return self._make_request('DELETE', endpoint) + + def get_pleroma_chats(self, limit: int = 20, max_id: Optional[str] = None) -> List[Dict]: + """Get Pleroma chat conversations (Pleroma-specific)""" + params = {'limit': limit} + if max_id: + params['max_id'] = max_id + + try: + return self._make_request('GET', '/api/v1/pleroma/chats', params=params) + except Exception: + # Pleroma chats not supported, return empty list + return [] + + def get_pleroma_chat_messages(self, chat_id: str, limit: int = 20, + max_id: Optional[str] = None) -> List[Dict]: + """Get messages from a Pleroma chat conversation""" + params = {'limit': limit} + if max_id: + params['max_id'] = max_id + + endpoint = f'/api/v1/pleroma/chats/{chat_id}/messages' + return self._make_request('GET', endpoint, params=params) + + def send_pleroma_chat_message(self, chat_id: str, content: str, + media_id: Optional[str] = None) -> Dict: + """Send a message to a Pleroma chat conversation""" + data = {'content': content} + if media_id: + data['media_id'] = media_id + + endpoint = f'/api/v1/pleroma/chats/{chat_id}/messages' + return self._make_request('POST', endpoint, data=data) + + def get_bookmarks(self, limit: int = 20, max_id: Optional[str] = None, + since_id: Optional[str] = None, min_id: Optional[str] = None) -> List[Dict]: + """Get bookmarked posts""" + params = {'limit': limit} + if max_id: + params['max_id'] = max_id + if since_id: + params['since_id'] = since_id + if min_id: + params['min_id'] = min_id + + return self._make_request('GET', '/api/v1/bookmarks', params=params) + + def bookmark_status(self, status_id: str) -> Dict: + """Bookmark a status""" + endpoint = f'/api/v1/statuses/{status_id}/bookmark' + return self._make_request('POST', endpoint) + + def unbookmark_status(self, status_id: str) -> Dict: + """Remove bookmark from a status""" + endpoint = f'/api/v1/statuses/{status_id}/unbookmark' + return self._make_request('POST', endpoint) class AuthenticationError(Exception): diff --git a/src/main_window.py b/src/main_window.py index a82f25b..77c19ca 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -69,9 +69,11 @@ class MainWindow(QMainWindow): self.timeline_tabs = QTabWidget() self.timeline_tabs.setAccessibleName("Timeline Selection") self.timeline_tabs.addTab(QWidget(), "Home") + self.timeline_tabs.addTab(QWidget(), "Messages") self.timeline_tabs.addTab(QWidget(), "Mentions") self.timeline_tabs.addTab(QWidget(), "Local") self.timeline_tabs.addTab(QWidget(), "Federated") + self.timeline_tabs.addTab(QWidget(), "Bookmarks") self.timeline_tabs.addTab(QWidget(), "Followers") self.timeline_tabs.addTab(QWidget(), "Following") self.timeline_tabs.currentChanged.connect(self.on_timeline_tab_changed) @@ -447,8 +449,8 @@ class MainWindow(QMainWindow): def switch_timeline(self, index): """Switch to timeline by index with loading feedback""" - timeline_names = ["Home", "Mentions", "Local", "Federated", "Followers", "Following"] - timeline_types = ["home", "notifications", "local", "federated", "followers", "following"] + timeline_names = ["Home", "Messages", "Mentions", "Local", "Federated", "Bookmarks", "Followers", "Following"] + timeline_types = ["home", "conversations", "notifications", "local", "federated", "bookmarks", "followers", "following"] if 0 <= index < len(timeline_names): timeline_name = timeline_names[index] diff --git a/src/models/conversation.py b/src/models/conversation.py new file mode 100644 index 0000000..2e1e93b --- /dev/null +++ b/src/models/conversation.py @@ -0,0 +1,177 @@ +""" +Data model for direct message conversations +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional, Dict, Any +from models.user import User +from models.post import Post + + +@dataclass +class Conversation: + """Represents a direct message conversation""" + + id: str + unread: bool + accounts: List[User] + last_status: Optional[Post] + + # Pleroma-specific fields + recipients: Optional[List[str]] = None + chat_id: Optional[str] = None # For Pleroma chat system + updated_at: Optional[datetime] = None + + @classmethod + def from_api_data(cls, data: Dict[str, Any]) -> 'Conversation': + """Create Conversation from API response data""" + + # Parse accounts/participants + accounts = [] + if 'accounts' in data: + for account_data in data['accounts']: + accounts.append(User.from_api_dict(account_data)) + + # Parse last status if present + last_status = None + if 'last_status' in data and data['last_status']: + last_status = Post.from_api_dict(data['last_status']) + + # Parse updated_at timestamp + updated_at = None + if 'updated_at' in data: + try: + updated_at = datetime.fromisoformat(data['updated_at'].replace('Z', '+00:00')) + except (ValueError, AttributeError): + pass + + return cls( + id=data['id'], + unread=data.get('unread', False), + accounts=accounts, + last_status=last_status, + recipients=data.get('recipients'), + chat_id=data.get('chat_id'), + updated_at=updated_at + ) + + def get_display_name(self, current_user_id: Optional[str] = None) -> str: + """Get display name for the conversation""" + if not self.accounts: + return "Unknown Conversation" + + # Filter out current user from display + other_accounts = [acc for acc in self.accounts if acc.id != current_user_id] + + if not other_accounts: + return "Conversation with yourself" + elif len(other_accounts) == 1: + return other_accounts[0].display_name or other_accounts[0].username + else: + # Group conversation + names = [acc.display_name or acc.username for acc in other_accounts[:3]] + if len(other_accounts) > 3: + return f"{', '.join(names)}, and {len(other_accounts) - 3} others" + else: + return ', '.join(names) + + def get_participants_text(self, current_user_id: Optional[str] = None) -> str: + """Get simple participant names for accessibility""" + if not self.accounts: + return "No participants" + + # Filter out current user + other_accounts = [acc for acc in self.accounts if acc.id != current_user_id] + + if not other_accounts: + return "yourself" + elif len(other_accounts) == 1: + user = other_accounts[0] + return user.display_name or user.username + else: + # Group conversation + count = len(other_accounts) + names = [acc.display_name or acc.username for acc in other_accounts[:2]] + if count == 2: + return f"{' and '.join(names)}" + else: + return f"{names[0]}, {names[1]} and {count - 2} others" + + def get_last_message_preview(self) -> str: + """Get full text of the last message (no truncation for accessibility)""" + if not self.last_status: + return "No messages" + + content = self.last_status.get_content_text() or "" + return content + + def get_accessible_description(self, current_user_id: Optional[str] = None) -> str: + """Get full accessible description for screen readers""" + participants = self.get_participants_text(current_user_id) + last_message = self.get_last_message_preview() + unread_text = " (unread)" if self.unread else "" + + if last_message == "No messages": + return f"{participants}{unread_text}" + else: + return f"{participants}{unread_text}: {last_message}" + + +@dataclass +class PleromaChatConversation: + """Represents a Pleroma-specific chat conversation""" + + id: str + account: User + unread: int + last_message: Optional[Dict[str, Any]] + updated_at: Optional[datetime] + + @classmethod + def from_api_data(cls, data: Dict[str, Any]) -> 'PleromaChatConversation': + """Create PleromaChatConversation from Pleroma API response""" + + account = User.from_api_dict(data['account']) + + # Parse updated_at timestamp + updated_at = None + if 'updated_at' in data: + try: + updated_at = datetime.fromisoformat(data['updated_at'].replace('Z', '+00:00')) + except (ValueError, AttributeError): + pass + + return cls( + id=data['id'], + account=account, + unread=data.get('unread', 0), + last_message=data.get('last_message'), + updated_at=updated_at + ) + + def to_conversation(self) -> Conversation: + """Convert to standard Conversation format""" + # Convert last_message to Post if present + last_status = None + if self.last_message: + # Pleroma chat messages have a different structure + # Convert to Post-like structure + post_data = { + 'id': self.last_message.get('id', ''), + 'content': self.last_message.get('content', ''), + 'created_at': self.last_message.get('created_at', ''), + 'account': self.account.to_api_data(), + 'visibility': 'direct', + 'media_attachments': self.last_message.get('media_attachments', []) + } + last_status = Post.from_api_dict(post_data) + + return Conversation( + id=self.id, + unread=self.unread > 0, + accounts=[self.account], + last_status=last_status, + chat_id=self.id, + updated_at=self.updated_at + ) \ No newline at end of file diff --git a/src/models/post.py b/src/models/post.py index 700e673..b950238 100644 --- a/src/models/post.py +++ b/src/models/post.py @@ -230,4 +230,42 @@ class Post: def is_boost(self) -> bool: """Check if this post is a boost/reblog""" - return self.reblog is not None \ No newline at end of file + return self.reblog is not None + + def to_api_data(self) -> Dict[str, Any]: + """Convert Post back to API response format""" + return { + 'id': self.id, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'in_reply_to_id': self.in_reply_to_id, + 'in_reply_to_account_id': self.in_reply_to_account_id, + 'sensitive': self.sensitive, + 'spoiler_text': self.spoiler_text, + 'visibility': self.visibility, + 'language': self.language, + 'uri': self.uri, + 'url': self.url, + 'replies_count': self.replies_count, + 'reblogs_count': self.reblogs_count, + 'favourites_count': self.favourites_count, + 'favourited': self.favourited, + 'reblogged': self.reblogged, + 'muted': self.muted, + 'bookmarked': self.bookmarked, + 'pinned': self.pinned, + 'content': self.content, + 'reblog': self.reblog.to_api_data() if self.reblog else None, + 'account': self.account.to_api_data() if self.account else None, + 'media_attachments': [media.to_api_data() if hasattr(media, 'to_api_data') else media for media in self.media_attachments], + 'mentions': self.mentions, + 'tags': self.tags, + 'emojis': self.emojis, + 'card': self.card, + 'poll': self.poll, + 'pleroma': self.pleroma, + 'content_type': self.content_type, + 'emoji_reactions': self.emoji_reactions, + 'expires_at': self.expires_at.isoformat() if self.expires_at else None, + 'local': self.local, + 'thread_muted': self.thread_muted + } \ No newline at end of file diff --git a/src/models/user.py b/src/models/user.py index 864e919..1b7f3ec 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -188,4 +188,52 @@ class User: def has_verified_fields(self) -> bool: """Check if user has any verified profile fields""" - return any(field.verified_at is not None for field in self.fields) \ No newline at end of file + return any(field.verified_at is not None for field in self.fields) + + def to_api_data(self) -> Dict[str, Any]: + """Convert User back to API response format""" + return { + 'id': self.id, + 'username': self.username, + 'acct': self.acct, + 'display_name': self.display_name, + 'note': self.note, + 'url': self.url, + 'avatar': self.avatar, + 'avatar_static': self.avatar_static, + 'header': self.header, + 'header_static': self.header_static, + 'locked': self.locked, + 'bot': self.bot, + 'discoverable': self.discoverable, + 'group': self.group, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'last_status_at': self.last_status_at.isoformat() if self.last_status_at else None, + 'statuses_count': self.statuses_count, + 'followers_count': self.followers_count, + 'following_count': self.following_count, + 'fields': [ + { + 'name': field.name, + 'value': field.value, + 'verified_at': field.verified_at.isoformat() if field.verified_at else None + } + for field in self.fields + ], + 'emojis': self.emojis, + 'moved': self.moved.to_api_data() if self.moved else None, + 'suspended': self.suspended, + 'limited': self.limited, + 'noindex': self.noindex, + 'following': self.following, + 'followed_by': self.followed_by, + 'blocking': self.blocking, + 'blocked_by': self.blocked_by, + 'muting': self.muting, + 'muting_notifications': self.muting_notifications, + 'requested': self.requested, + 'domain_blocking': self.domain_blocking, + 'showing_reblogs': self.showing_reblogs, + 'endorsed': self.endorsed, + 'note_plain': self.note_plain + } \ No newline at end of file diff --git a/src/widgets/timeline_view.py b/src/widgets/timeline_view.py index 7dc5b20..97910a5 100644 --- a/src/widgets/timeline_view.py +++ b/src/widgets/timeline_view.py @@ -5,7 +5,7 @@ Timeline view widget for displaying posts and threads from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView, QMenu, QDialog, QVBoxLayout, QListWidget, QDialogButtonBox, QLabel from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QAction, QClipboard -from typing import Optional, List +from typing import Optional, List, Dict import re import webbrowser @@ -16,6 +16,7 @@ from config.settings import SettingsManager from config.accounts import AccountManager from activitypub.client import ActivityPubClient from models.post import Post +from models.conversation import Conversation, PleromaChatConversation class TimelineView(AccessibleTreeWidget): @@ -49,6 +50,9 @@ class TimelineView(AccessibleTreeWidget): # Connect sound events self.item_state_changed.connect(self.on_state_changed) + # Connect selection change to mark conversations as read + self.currentItemChanged.connect(self.on_selection_changed) + # Enable context menu self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.show_context_menu) @@ -107,7 +111,7 @@ 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, notifications, or followers/following + # Fetch timeline, notifications, followers/following, conversations, or bookmarks if self.timeline_type == "notifications": timeline_data = self.activitypub_client.get_notifications(limit=posts_per_page) elif self.timeline_type == "followers": @@ -118,6 +122,10 @@ class TimelineView(AccessibleTreeWidget): # 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) + elif self.timeline_type == "conversations": + timeline_data = self.load_conversations(posts_per_page) + elif self.timeline_type == "bookmarks": + timeline_data = self.activitypub_client.get_bookmarks(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) @@ -179,6 +187,50 @@ class TimelineView(AccessibleTreeWidget): except Exception as e: print(f"Error parsing notification: {e}") continue + elif self.timeline_type == "conversations": + # Handle conversations data structure + for conv_data in timeline_data: + try: + # Create a special Post-like object for conversations + class ConversationDisplayPost: + def __init__(self, conversation_data, account_manager): + # Get current user info for display formatting + active_account = account_manager.get_active_account() + current_user_id = active_account.account_id if active_account else None + self.account_manager = account_manager + + conv = Conversation.from_api_data(conversation_data) + self.id = conv.id + self.conversation = conv + self.account = conv.accounts[0] if conv.accounts else None + self.in_reply_to_id = None + self.conversation_type = conversation_data.get('conversation_type', 'standard') + + # Generate display content + participants = conv.get_display_name(current_user_id) + last_message = conv.get_last_message_preview() + unread_indicator = " 🔵" if conv.unread else "" + + self.content = f"
{participants}{unread_indicator}
" + if last_message and last_message != "No messages": + self.content += f"{last_message}
" + + def get_content_text(self): + active_account = self.account_manager.get_active_account() + current_user_id = active_account.account_id if active_account else None + return self.conversation.get_accessible_description(current_user_id) + + def get_summary_for_screen_reader(self): + active_account = self.account_manager.get_active_account() + current_user_id = active_account.account_id if active_account else None + return self.conversation.get_accessible_description(current_user_id) + + conversation_post = ConversationDisplayPost(conv_data, self.account_manager) + self.posts.append(conversation_post) + + except Exception as e: + print(f"Error parsing conversation: {e}") + continue elif self.timeline_type in ["followers", "following"]: # Handle followers/following data structure (account list) for account_data in timeline_data: @@ -494,6 +546,16 @@ class TimelineView(AccessibleTreeWidget): limit=posts_per_page, max_id=self.oldest_post_id ) + elif self.timeline_type == "conversations": + more_data = self.load_conversations( + limit=posts_per_page, + max_id=self.oldest_post_id + ) + elif self.timeline_type == "bookmarks": + more_data = self.activitypub_client.get_bookmarks( + limit=posts_per_page, + max_id=self.oldest_post_id + ) else: more_data = self.activitypub_client.get_timeline( self.timeline_type, @@ -756,6 +818,45 @@ class TimelineView(AccessibleTreeWidget): menu.exec(self.mapToGlobal(position)) + def load_conversations(self, limit: int = 20, max_id: Optional[str] = None) -> List[Dict]: + """Load conversations from both standard API and Pleroma chats""" + conversations = [] + + try: + # Try standard conversations API first + standard_conversations = self.activitypub_client.get_conversations( + limit=limit, + max_id=max_id + ) + for conv_data in standard_conversations: + conversations.append(conv_data) + except Exception as e: + print(f"Failed to load standard conversations: {e}") + + try: + # Try Pleroma chats as fallback/supplement + pleroma_chats = self.activitypub_client.get_pleroma_chats( + limit=limit, + max_id=max_id + ) + for chat_data in pleroma_chats: + # Convert Pleroma chat to conversation format + pleroma_conv = PleromaChatConversation.from_api_data(chat_data) + conversation = pleroma_conv.to_conversation() + # Add as dict for consistency with timeline data format + conversations.append({ + 'id': conversation.id, + 'unread': conversation.unread, + 'accounts': [acc.to_api_data() for acc in conversation.accounts], + 'last_status': conversation.last_status.to_api_data() if conversation.last_status else None, + 'chat_id': conversation.chat_id, + 'conversation_type': 'pleroma_chat' + }) + except Exception as e: + print(f"Failed to load Pleroma chats: {e}") + + return conversations + def show_empty_message(self, message: str): """Show an empty timeline with a message""" item = QTreeWidgetItem([message]) @@ -792,6 +893,47 @@ class TimelineView(AccessibleTreeWidget): self.sound_manager.play_expand() elif state == "collapsed": self.sound_manager.play_collapse() + + def on_selection_changed(self, current, previous): + """Handle timeline item selection changes""" + # Only mark conversations as read in the conversations timeline + if self.timeline_type != "conversations" or not current: + return + + # Get the post object for this item + try: + post_index = self.indexOfTopLevelItem(current) + if 0 <= post_index < len(self.posts): + post = self.posts[post_index] + + # Check if this is a conversation with unread messages + if hasattr(post, 'conversation') and post.conversation.unread: + # Mark conversation as read via API + if self.activitypub_client: + try: + self.activitypub_client.mark_conversation_read(post.conversation.id) + # Update local state + post.conversation.unread = False + # Update the display text to remove (unread) indicator + self._update_conversation_display(current, post) + except Exception as e: + print(f"Failed to mark conversation as read: {e}") + except Exception as e: + print(f"Error in selection change handler: {e}") + + def _update_conversation_display(self, item, post): + """Update conversation display after marking as read""" + try: + # Get current user for display formatting + active_account = self.account_manager.get_active_account() + current_user_id = active_account.account_id if active_account else None + + # Generate new display text without unread indicator + updated_description = post.conversation.get_accessible_description(current_user_id) + item.setText(0, updated_description) + item.setData(0, Qt.AccessibleTextRole, updated_description) + except Exception as e: + print(f"Error updating conversation display: {e}") def add_new_posts(self, posts): """Add new posts to timeline with sound notification"""