Add comprehensive direct messaging and bookmarks system

- Add dedicated Messages timeline with conversation threading
- Implement bookmarks timeline for saved posts
- Support both standard ActivityPub conversations and Pleroma chats
- Auto-mark conversations as read when selected
- Full pagination support for messages and bookmarks
- Reorder timeline tabs for better UX (Messages now second tab)
- Remove verbose prefixes for cleaner accessibility
- Add conversation management API endpoints
- Maintain accessibility-first design with no text truncation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Storm Dragon
2025-07-21 00:37:45 -04:00
parent 352bea6ec7
commit cf240e1aa5
8 changed files with 522 additions and 12 deletions

View File

@ -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
- List management
- Advanced search

View File

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

View File

@ -290,6 +290,84 @@ class ActivityPubClient:
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):
"""Raised when authentication fails"""

View File

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

177
src/models/conversation.py Normal file
View File

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

View File

@ -231,3 +231,41 @@ class Post:
def is_boost(self) -> bool:
"""Check if this post is a boost/reblog"""
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
}

View File

@ -189,3 +189,51 @@ 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)
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
}

View File

@ -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"<p><strong>{participants}</strong>{unread_indicator}</p>"
if last_message and last_message != "No messages":
self.content += f"<p><small>{last_message}</small></p>"
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])
@ -793,6 +894,47 @@ class TimelineView(AccessibleTreeWidget):
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"""
# TODO: Implement adding real posts from API