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:
21
CLAUDE.md
21
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
|
||||
- List management
|
||||
- Advanced search
|
||||
|
||||
|
14
README.md
14
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
|
||||
|
||||
|
@ -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"""
|
||||
|
@ -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
177
src/models/conversation.py
Normal 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
|
||||
)
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
|
Reference in New Issue
Block a user