Initial commit: Bifrost accessible fediverse client

- Full ActivityPub support for Pleroma, GoToSocial, and Mastodon
- Screen reader optimized interface with PySide6
- Timeline switching with tabs and keyboard shortcuts (Ctrl+1-4)
- Threaded conversation navigation with expand/collapse
- Cross-platform desktop notifications via plyer
- Customizable sound pack system with audio feedback
- Complete keyboard navigation and accessibility features
- XDG Base Directory compliant configuration
- Multiple account support with OAuth authentication

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Storm Dragon
2025-07-20 03:39:47 -04:00
commit 460dfc52a5
31 changed files with 5320 additions and 0 deletions

1
src/widgets/__init__.py Normal file
View File

@ -0,0 +1 @@
# UI widgets

View File

@ -0,0 +1,101 @@
"""
Account selector widget for switching between multiple accounts
"""
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QPushButton
from PySide6.QtCore import Qt, Signal
from accessibility.accessible_combo import AccessibleComboBox
from config.accounts import AccountManager, Account
class AccountSelector(QWidget):
"""Widget for selecting and managing accounts"""
account_changed = Signal(str) # account_id
add_account_requested = Signal()
def __init__(self, account_manager: AccountManager, parent=None):
super().__init__(parent)
self.account_manager = account_manager
self.setup_ui()
self.refresh_accounts()
def setup_ui(self):
"""Initialize the account selector UI"""
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Account label
self.account_label = QLabel("Account:")
layout.addWidget(self.account_label)
# Account selector combo
self.account_combo = AccessibleComboBox()
self.account_combo.setAccessibleName("Account Selection")
self.account_combo.setAccessibleDescription("Select active account. Use Left/Right arrows or Page Up/Down to switch accounts quickly.")
self.account_combo.currentTextChanged.connect(self.on_account_selected)
layout.addWidget(self.account_combo)
# Add account button
self.add_button = QPushButton("&Add Account")
self.add_button.setAccessibleName("Add New Account")
self.add_button.clicked.connect(self.add_account_requested.emit)
layout.addWidget(self.add_button)
layout.addStretch()
def refresh_accounts(self):
"""Refresh the account list"""
self.account_combo.clear()
if not self.account_manager.has_accounts():
self.account_combo.addItem("No accounts configured")
self.account_combo.setEnabled(False)
return
self.account_combo.setEnabled(True)
# Add all accounts
for account in self.account_manager.get_all_accounts():
display_text = account.get_display_text()
self.account_combo.addItem(display_text)
# Select active account
active_account = self.account_manager.get_active_account()
if active_account:
active_display = active_account.get_display_text()
index = self.account_combo.findText(active_display)
if index >= 0:
self.account_combo.setCurrentIndex(index)
def on_account_selected(self, display_name: str):
"""Handle account selection"""
if display_name == "No accounts configured":
return
account_id = self.account_manager.find_account_by_display_name(display_name)
if account_id:
self.account_manager.set_active_account(account_id)
self.account_changed.emit(account_id)
def add_account(self, account_data: dict):
"""Add a new account"""
account_id = self.account_manager.add_account(account_data)
self.refresh_accounts()
# Select the new account
new_account = self.account_manager.get_account_by_id(account_id)
if new_account:
display_text = new_account.get_display_text()
index = self.account_combo.findText(display_text)
if index >= 0:
self.account_combo.setCurrentIndex(index)
def get_current_account(self) -> Account:
"""Get the currently selected account"""
return self.account_manager.get_active_account()
def has_accounts(self) -> bool:
"""Check if there are any accounts"""
return self.account_manager.has_accounts()

View File

@ -0,0 +1,326 @@
"""
Text edit widget with autocomplete for mentions and emojis
"""
from PySide6.QtWidgets import QTextEdit, QCompleter, QListWidget, QListWidgetItem
from PySide6.QtCore import Qt, Signal, QStringListModel, QRect
from PySide6.QtGui import QTextCursor, QKeyEvent
import re
from typing import List, Dict
class AutocompleteTextEdit(QTextEdit):
"""Text edit with @ mention and : emoji autocomplete"""
mention_requested = Signal(str) # Emitted when user types @ to request user list
emoji_requested = Signal(str) # Emitted when user types : to request emoji list
def __init__(self, parent=None):
super().__init__(parent)
# Lists for autocomplete
self.mention_list = [] # Will be populated from followers/following
self.emoji_list = [] # Will be populated from instance custom emojis
# Autocomplete state
self.completer = None
self.completion_prefix = ""
self.completion_start = 0
self.completion_type = None # 'mention' or 'emoji'
# Load default emojis
self.load_default_emojis()
def load_default_emojis(self):
"""Load a comprehensive set of Unicode emojis"""
self.emoji_list = [
# Faces
{"shortcode": "smile", "emoji": "😄", "keywords": ["smile", "happy", "joy", "grin"]},
{"shortcode": "laughing", "emoji": "😆", "keywords": ["laugh", "haha", "funny", "lol"]},
{"shortcode": "wink", "emoji": "😉", "keywords": ["wink", "flirt", "hint"]},
{"shortcode": "thinking", "emoji": "🤔", "keywords": ["thinking", "hmm", "consider", "ponder"]},
{"shortcode": "shrug", "emoji": "🤷", "keywords": ["shrug", "dunno", "whatever", "idk"]},
{"shortcode": "facepalm", "emoji": "🤦", "keywords": ["facepalm", "disappointed", "doh", "frustrated"]},
{"shortcode": "crying", "emoji": "😭", "keywords": ["crying", "tears", "sad", "sob"]},
{"shortcode": "angry", "emoji": "😠", "keywords": ["angry", "mad", "furious", "upset"]},
{"shortcode": "cool", "emoji": "😎", "keywords": ["cool", "sunglasses", "awesome", "rad"]},
{"shortcode": "joy", "emoji": "😂", "keywords": ["joy", "laugh", "tears", "funny"]},
{"shortcode": "heart_eyes", "emoji": "😍", "keywords": ["heart", "eyes", "love", "crush"]},
{"shortcode": "kiss", "emoji": "😘", "keywords": ["kiss", "love", "smooch", "mwah"]},
{"shortcode": "tired", "emoji": "😴", "keywords": ["tired", "sleep", "sleepy", "zzz"]},
{"shortcode": "shocked", "emoji": "😱", "keywords": ["shocked", "surprised", "scared", "omg"]},
# Hearts and symbols
{"shortcode": "heart", "emoji": "❤️", "keywords": ["heart", "love", "red", "romance"]},
{"shortcode": "blue_heart", "emoji": "💙", "keywords": ["blue", "heart", "love", "cold"]},
{"shortcode": "green_heart", "emoji": "💚", "keywords": ["green", "heart", "love", "nature"]},
{"shortcode": "yellow_heart", "emoji": "💛", "keywords": ["yellow", "heart", "love", "happy"]},
{"shortcode": "purple_heart", "emoji": "💜", "keywords": ["purple", "heart", "love", "royal"]},
{"shortcode": "black_heart", "emoji": "🖤", "keywords": ["black", "heart", "love", "dark"]},
{"shortcode": "broken_heart", "emoji": "💔", "keywords": ["broken", "heart", "sad", "breakup"]},
{"shortcode": "sparkling_heart", "emoji": "💖", "keywords": ["sparkling", "heart", "love", "sparkle"]},
# Gestures
{"shortcode": "thumbsup", "emoji": "👍", "keywords": ["thumbs", "up", "good", "ok", "yes"]},
{"shortcode": "thumbsdown", "emoji": "👎", "keywords": ["thumbs", "down", "bad", "no", "dislike"]},
{"shortcode": "wave", "emoji": "👋", "keywords": ["wave", "hello", "hi", "goodbye", "bye"]},
{"shortcode": "clap", "emoji": "👏", "keywords": ["clap", "applause", "bravo", "good"]},
{"shortcode": "pray", "emoji": "🙏", "keywords": ["pray", "thanks", "please", "gratitude"]},
{"shortcode": "ok_hand", "emoji": "👌", "keywords": ["ok", "hand", "perfect", "good"]},
{"shortcode": "peace", "emoji": "✌️", "keywords": ["peace", "victory", "two", "fingers"]},
{"shortcode": "crossed_fingers", "emoji": "🤞", "keywords": ["crossed", "fingers", "luck", "hope"]},
# Objects and symbols
{"shortcode": "fire", "emoji": "🔥", "keywords": ["fire", "hot", "flame", "lit"]},
{"shortcode": "star", "emoji": "", "keywords": ["star", "favorite", "best", "top"]},
{"shortcode": "rainbow", "emoji": "🌈", "keywords": ["rainbow", "colorful", "pride", "weather"]},
{"shortcode": "lightning", "emoji": "", "keywords": ["lightning", "bolt", "fast", "electric"]},
{"shortcode": "snowflake", "emoji": "❄️", "keywords": ["snowflake", "cold", "winter", "frozen"]},
{"shortcode": "sun", "emoji": "☀️", "keywords": ["sun", "sunny", "bright", "weather"]},
{"shortcode": "moon", "emoji": "🌙", "keywords": ["moon", "night", "crescent", "sleep"]},
{"shortcode": "cloud", "emoji": "☁️", "keywords": ["cloud", "weather", "sky", "cloudy"]},
# Food and drinks
{"shortcode": "coffee", "emoji": "", "keywords": ["coffee", "drink", "morning", "caffeine"]},
{"shortcode": "tea", "emoji": "🍵", "keywords": ["tea", "drink", "hot", "green"]},
{"shortcode": "beer", "emoji": "🍺", "keywords": ["beer", "drink", "alcohol", "party"]},
{"shortcode": "wine", "emoji": "🍷", "keywords": ["wine", "drink", "alcohol", "red"]},
{"shortcode": "pizza", "emoji": "🍕", "keywords": ["pizza", "food", "italian", "slice"]},
{"shortcode": "burger", "emoji": "🍔", "keywords": ["burger", "food", "meat", "american"]},
{"shortcode": "cake", "emoji": "🎂", "keywords": ["cake", "birthday", "dessert", "sweet"]},
{"shortcode": "cookie", "emoji": "🍪", "keywords": ["cookie", "dessert", "sweet", "snack"]},
{"shortcode": "apple", "emoji": "🍎", "keywords": ["apple", "fruit", "red", "healthy"]},
{"shortcode": "banana", "emoji": "🍌", "keywords": ["banana", "fruit", "yellow", "monkey"]},
# Animals
{"shortcode": "cat", "emoji": "🐱", "keywords": ["cat", "kitten", "meow", "feline"]},
{"shortcode": "dog", "emoji": "🐶", "keywords": ["dog", "puppy", "woof", "canine"]},
{"shortcode": "mouse", "emoji": "🐭", "keywords": ["mouse", "small", "rodent", "squeak"]},
{"shortcode": "bear", "emoji": "🐻", "keywords": ["bear", "large", "forest", "cute"]},
{"shortcode": "panda", "emoji": "🐼", "keywords": ["panda", "bear", "black", "white"]},
{"shortcode": "lion", "emoji": "🦁", "keywords": ["lion", "king", "mane", "roar"]},
{"shortcode": "tiger", "emoji": "🐯", "keywords": ["tiger", "stripes", "orange", "wild"]},
{"shortcode": "fox", "emoji": "🦊", "keywords": ["fox", "red", "clever", "sly"]},
{"shortcode": "wolf", "emoji": "🐺", "keywords": ["wolf", "pack", "howl", "wild"]},
{"shortcode": "unicorn", "emoji": "🦄", "keywords": ["unicorn", "magic", "rainbow", "fantasy"]},
# Activities and celebrations
{"shortcode": "party", "emoji": "🎉", "keywords": ["party", "celebration", "confetti", "fun"]},
{"shortcode": "birthday", "emoji": "🎂", "keywords": ["birthday", "cake", "celebration", "age"]},
{"shortcode": "gift", "emoji": "🎁", "keywords": ["gift", "present", "box", "surprise"]},
{"shortcode": "balloon", "emoji": "🎈", "keywords": ["balloon", "party", "float", "celebration"]},
{"shortcode": "music", "emoji": "🎵", "keywords": ["music", "notes", "song", "melody"]},
{"shortcode": "dance", "emoji": "💃", "keywords": ["dance", "woman", "party", "fun"]},
# Halloween and seasonal
{"shortcode": "jack_o_lantern", "emoji": "🎃", "keywords": ["jack", "lantern", "pumpkin", "halloween"]},
{"shortcode": "ghost", "emoji": "👻", "keywords": ["ghost", "spooky", "halloween", "boo"]},
{"shortcode": "skull", "emoji": "💀", "keywords": ["skull", "death", "spooky", "halloween"]},
{"shortcode": "spider", "emoji": "🕷️", "keywords": ["spider", "web", "spooky", "halloween"]},
{"shortcode": "bat", "emoji": "🦇", "keywords": ["bat", "fly", "night", "halloween"]},
{"shortcode": "christmas_tree", "emoji": "🎄", "keywords": ["christmas", "tree", "holiday", "winter"]},
{"shortcode": "santa", "emoji": "🎅", "keywords": ["santa", "christmas", "holiday", "ho"]},
{"shortcode": "snowman", "emoji": "", "keywords": ["snowman", "winter", "cold", "carrot"]},
# Technology
{"shortcode": "computer", "emoji": "💻", "keywords": ["computer", "laptop", "tech", "work"]},
{"shortcode": "phone", "emoji": "📱", "keywords": ["phone", "mobile", "cell", "smartphone"]},
{"shortcode": "camera", "emoji": "📷", "keywords": ["camera", "photo", "picture", "snap"]},
{"shortcode": "video", "emoji": "📹", "keywords": ["video", "camera", "record", "film"]},
{"shortcode": "robot", "emoji": "🤖", "keywords": ["robot", "ai", "artificial", "intelligence"]},
# Transportation
{"shortcode": "car", "emoji": "🚗", "keywords": ["car", "drive", "vehicle", "auto"]},
{"shortcode": "bike", "emoji": "🚲", "keywords": ["bike", "bicycle", "ride", "cycle"]},
{"shortcode": "plane", "emoji": "✈️", "keywords": ["plane", "airplane", "fly", "travel"]},
{"shortcode": "rocket", "emoji": "🚀", "keywords": ["rocket", "space", "launch", "fast"]},
]
def set_mention_list(self, mentions: List[str]):
"""Set the list of available mentions (usernames)"""
self.mention_list = mentions
def set_emoji_list(self, emojis: List[Dict]):
"""Set custom emoji list from instance"""
# Combine with default emojis
self.emoji_list.extend(emojis)
def keyPressEvent(self, event: QKeyEvent):
"""Handle key press events for autocomplete"""
key = event.key()
# Handle autocomplete navigation
if self.completer and self.completer.popup().isVisible():
if key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab]:
self.handle_completer_key(key)
return
elif key == Qt.Key_Escape:
self.hide_completer()
return
# Normal key processing
super().keyPressEvent(event)
# Check for autocomplete triggers
self.check_autocomplete_trigger()
def handle_completer_key(self, key):
"""Handle navigation keys in completer"""
popup = self.completer.popup()
if key in [Qt.Key_Up, Qt.Key_Down]:
# Let the popup handle up/down
if key == Qt.Key_Up:
current = popup.currentIndex().row()
if current > 0:
popup.setCurrentIndex(popup.model().index(current - 1, 0))
else:
current = popup.currentIndex().row()
if current < popup.model().rowCount() - 1:
popup.setCurrentIndex(popup.model().index(current + 1, 0))
elif key in [Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab]:
# Insert the selected completion
self.insert_completion()
def check_autocomplete_trigger(self):
"""Check if we should show autocomplete"""
cursor = self.textCursor()
text = self.toPlainText()
pos = cursor.position()
# Find the current word being typed
if pos == 0:
return
# Look backwards for @ or :
start_pos = pos - 1
while start_pos >= 0 and text[start_pos] not in [' ', '\n', '\t']:
start_pos -= 1
start_pos += 1
current_word = text[start_pos:pos]
if current_word.startswith('@') and len(current_word) > 1:
# Check if this is a completed mention (ends with space)
if current_word.endswith(' '):
self.hide_completer()
return
# Mention autocomplete
prefix = current_word[1:] # Remove @
self.show_mention_completer(prefix, start_pos)
elif current_word.startswith(':') and len(current_word) > 1:
# Check if this is a completed emoji (ends with :)
if current_word.endswith(':') and len(current_word) > 2:
self.hide_completer()
return
# Emoji autocomplete - remove trailing : if present for matching
prefix = current_word[1:] # Remove initial :
if prefix.endswith(':'):
prefix = prefix[:-1] # Remove trailing : for matching
self.show_emoji_completer(prefix, start_pos)
else:
# Hide completer if not in autocomplete mode
self.hide_completer()
def show_mention_completer(self, prefix: str, start_pos: int):
"""Show mention autocomplete"""
if not self.mention_list:
# Request mention list from parent
self.mention_requested.emit(prefix)
return
# Filter mentions
matches = [name for name in self.mention_list if name.lower().startswith(prefix.lower())]
if matches:
self.show_completer(matches, prefix, start_pos, 'mention')
else:
self.hide_completer()
def show_emoji_completer(self, prefix: str, start_pos: int):
"""Show emoji autocomplete"""
# Filter emojis by shortcode and keywords
matches = []
prefix_lower = prefix.lower()
for emoji in self.emoji_list:
shortcode = emoji['shortcode']
keywords = emoji.get('keywords', [])
# Check if prefix matches shortcode or any keyword
if (shortcode.startswith(prefix_lower) or
any(keyword.startswith(prefix_lower) for keyword in keywords)):
display_text = f"{shortcode} {emoji['emoji']}"
matches.append(display_text)
if matches:
self.show_completer(matches, prefix, start_pos, 'emoji')
else:
self.hide_completer()
def show_completer(self, items: List[str], prefix: str, start_pos: int, completion_type: str):
"""Show the completer with given items"""
self.completion_prefix = prefix
self.completion_start = start_pos
self.completion_type = completion_type
# Create or update completer
if not self.completer:
self.completer = QCompleter(self)
self.completer.setWidget(self)
self.completer.setCaseSensitivity(Qt.CaseInsensitive)
# Set up model
model = QStringListModel(items)
self.completer.setModel(model)
# Position the popup
cursor = self.textCursor()
cursor.setPosition(start_pos)
rect = self.cursorRect(cursor)
rect.setWidth(200)
self.completer.complete(rect)
# Set accessible name for screen readers
popup = self.completer.popup()
popup.setAccessibleName(f"{completion_type.title()} Autocomplete")
def hide_completer(self):
"""Hide the completer"""
if self.completer:
self.completer.popup().hide()
def insert_completion(self):
"""Insert the selected completion"""
if not self.completer:
return
popup = self.completer.popup()
current_index = popup.currentIndex()
if not current_index.isValid():
return
completion = self.completer.currentCompletion()
# Replace the current prefix with the completion
cursor = self.textCursor()
cursor.setPosition(self.completion_start)
cursor.setPosition(cursor.position() + len(self.completion_prefix) + 1, QTextCursor.KeepAnchor) # +1 for @ or :
if self.completion_type == 'mention':
cursor.insertText(f"@{completion} ")
elif self.completion_type == 'emoji':
# Extract just the shortcode (before the emoji)
shortcode = completion.split()[0]
cursor.insertText(f":{shortcode}: ")
self.hide_completer()
def update_mention_list(self, mentions: List[str]):
"""Update mention list (called from parent when data is ready)"""
self.mention_list = mentions
# Don't re-trigger completer to avoid recursion
# The completer will use the updated list on next keystroke

View File

@ -0,0 +1,255 @@
"""
Compose post dialog for creating new posts
"""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTextEdit,
QPushButton, QLabel, QDialogButtonBox, QCheckBox,
QComboBox, QGroupBox
)
from PySide6.QtCore import Qt, Signal, QThread
from PySide6.QtGui import QKeySequence, QShortcut
from accessibility.accessible_combo import AccessibleComboBox
from audio.sound_manager import SoundManager
from config.settings import SettingsManager
from activitypub.client import ActivityPubClient
from widgets.autocomplete_textedit import AutocompleteTextEdit
class PostThread(QThread):
"""Background thread for posting content"""
post_success = Signal(dict) # Emitted with post data on success
post_failed = Signal(str) # Emitted with error message on failure
def __init__(self, account, content, visibility, content_warning=None):
super().__init__()
self.account = account
self.content = content
self.visibility = visibility
self.content_warning = content_warning
def run(self):
"""Post the content in background"""
try:
client = ActivityPubClient(self.account.instance_url, self.account.access_token)
result = client.post_status(
content=self.content,
visibility=self.visibility,
content_warning=self.content_warning
)
self.post_success.emit(result)
except Exception as e:
self.post_failed.emit(str(e))
class ComposeDialog(QDialog):
"""Dialog for composing new posts"""
post_sent = Signal(dict) # Emitted when a post is ready to send
def __init__(self, account_manager, parent=None):
super().__init__(parent)
self.settings = SettingsManager()
self.sound_manager = SoundManager(self.settings)
self.account_manager = account_manager
self.setup_ui()
self.setup_shortcuts()
def setup_ui(self):
"""Initialize the compose dialog UI"""
self.setWindowTitle("Compose Post")
self.setMinimumSize(500, 300)
self.setModal(True)
layout = QVBoxLayout(self)
# Character count label
self.char_count_label = QLabel("Characters: 0/500")
self.char_count_label.setAccessibleName("Character Count")
layout.addWidget(self.char_count_label)
# Main text area with autocomplete
self.text_edit = AutocompleteTextEdit()
self.text_edit.setAccessibleName("Post Content")
self.text_edit.setAccessibleDescription("Enter your post content here. Type @ for mentions, : for emojis. Press Tab to move to post options.")
self.text_edit.setPlaceholderText("What's on your mind? Type @ for mentions, : for emojis")
self.text_edit.setTabChangesFocus(True) # Allow Tab to exit the text area
self.text_edit.textChanged.connect(self.update_char_count)
self.text_edit.mention_requested.connect(self.load_mention_suggestions)
self.text_edit.emoji_requested.connect(self.load_emoji_suggestions)
layout.addWidget(self.text_edit)
# Options group
options_group = QGroupBox("Post Options")
options_layout = QVBoxLayout(options_group)
# Visibility settings
visibility_layout = QHBoxLayout()
visibility_layout.addWidget(QLabel("Visibility:"))
self.visibility_combo = AccessibleComboBox()
self.visibility_combo.setAccessibleName("Post Visibility")
self.visibility_combo.addItems([
"Public",
"Unlisted",
"Followers Only",
"Direct Message"
])
visibility_layout.addWidget(self.visibility_combo)
visibility_layout.addStretch()
options_layout.addLayout(visibility_layout)
# Content warnings
self.cw_checkbox = QCheckBox("Add Content Warning")
self.cw_checkbox.setAccessibleName("Content Warning Toggle")
self.cw_checkbox.toggled.connect(self.toggle_content_warning)
options_layout.addWidget(self.cw_checkbox)
self.cw_edit = QTextEdit()
self.cw_edit.setAccessibleName("Content Warning Text")
self.cw_edit.setAccessibleDescription("Enter content warning description. Press Tab to move to next field.")
self.cw_edit.setPlaceholderText("Describe what this post contains...")
self.cw_edit.setMaximumHeight(60)
self.cw_edit.setTabChangesFocus(True) # Allow Tab to exit the content warning field
self.cw_edit.hide()
options_layout.addWidget(self.cw_edit)
layout.addWidget(options_group)
# Button box
button_box = QDialogButtonBox()
# Post button
self.post_button = QPushButton("&Post")
self.post_button.setAccessibleName("Send Post")
self.post_button.setDefault(True)
self.post_button.clicked.connect(self.send_post)
button_box.addButton(self.post_button, QDialogButtonBox.AcceptRole)
# Cancel button
cancel_button = QPushButton("&Cancel")
cancel_button.setAccessibleName("Cancel Post")
cancel_button.clicked.connect(self.reject)
button_box.addButton(cancel_button, QDialogButtonBox.RejectRole)
layout.addWidget(button_box)
# Set initial focus
self.text_edit.setFocus()
def setup_shortcuts(self):
"""Set up keyboard shortcuts"""
# Ctrl+Enter to send post
send_shortcut = QShortcut(QKeySequence("Ctrl+Return"), self)
send_shortcut.activated.connect(self.send_post)
# Escape to cancel
cancel_shortcut = QShortcut(QKeySequence.Cancel, self)
cancel_shortcut.activated.connect(self.reject)
def toggle_content_warning(self, enabled: bool):
"""Toggle content warning field visibility"""
if enabled:
self.cw_edit.show()
# Don't automatically focus - let user tab to it naturally
else:
self.cw_edit.hide()
self.cw_edit.clear()
def update_char_count(self):
"""Update character count display"""
text = self.text_edit.toPlainText()
char_count = len(text)
self.char_count_label.setText(f"Characters: {char_count}/500")
# Enable/disable post button based on content
has_content = bool(text.strip())
within_limit = char_count <= 500
self.post_button.setEnabled(has_content and within_limit)
# Update accessibility
if char_count > 500:
self.char_count_label.setAccessibleDescription("Character limit exceeded")
else:
self.char_count_label.setAccessibleDescription(f"{500 - char_count} characters remaining")
def send_post(self):
"""Send the post"""
content = self.text_edit.toPlainText().strip()
if not content:
return
# Get active account
active_account = self.account_manager.get_active_account()
if not active_account:
QMessageBox.warning(self, "No Account", "Please add an account before posting.")
return
# Get post settings
visibility_text = self.visibility_combo.currentText()
visibility_map = {
"Public": "public",
"Unlisted": "unlisted",
"Followers Only": "private",
"Direct Message": "direct"
}
visibility = visibility_map.get(visibility_text, "public")
content_warning = None
if self.cw_checkbox.isChecked():
content_warning = self.cw_edit.toPlainText().strip()
# Start background posting
post_data = {
'account': active_account,
'content': content,
'visibility': visibility,
'content_warning': content_warning
}
# Play sound when post button is pressed
self.sound_manager.play_event("post")
# Emit signal with all post data for background processing
self.post_sent.emit(post_data)
# Close dialog immediately
self.accept()
def load_mention_suggestions(self, prefix: str):
"""Load mention suggestions based on prefix"""
# TODO: Implement fetching followers/following from API
# For now, use expanded sample suggestions with realistic fediverse usernames
sample_mentions = [
"alice", "bob", "charlie", "diana", "eve", "frank", "grace", "henry", "ivy", "jack",
"admin", "moderator", "announcements", "news", "updates", "support", "help",
"alex_dev", "jane_artist", "mike_writer", "sarah_photographer", "tom_musician",
"community", "local_news", "tech_updates", "fedi_tips", "open_source",
"cat_lover", "dog_walker", "book_reader", "movie_fan", "game_dev", "web_designer",
"climate_activist", "space_enthusiast", "food_blogger", "travel_tales", "art_gallery"
]
# Filter by prefix (case insensitive)
filtered = [name for name in sample_mentions if name.lower().startswith(prefix.lower())]
self.text_edit.update_mention_list(filtered)
def load_emoji_suggestions(self, prefix: str):
"""Load emoji suggestions based on prefix"""
# The AutocompleteTextEdit already has a built-in emoji list
# This method is called when the signal is emitted, but the
# autocomplete logic is handled internally by the text edit widget
# We don't need to do anything here since emojis are pre-loaded
pass
def get_post_data(self) -> dict:
"""Get the composed post data"""
return {
'content': self.text_edit.toPlainText().strip(),
'visibility': self.visibility_combo.currentText().lower().replace(" ", "_"),
'content_warning': self.cw_edit.toPlainText().strip() if self.cw_checkbox.isChecked() else None
}

274
src/widgets/login_dialog.py Normal file
View File

@ -0,0 +1,274 @@
"""
Login dialog for adding new fediverse accounts
"""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLineEdit,
QPushButton, QLabel, QDialogButtonBox, QMessageBox,
QProgressBar, QTextEdit
)
from PySide6.QtCore import Qt, Signal, QThread
from PySide6.QtGui import QKeySequence, QShortcut
import requests
from urllib.parse import urljoin, urlparse
from activitypub.client import ActivityPubClient
from activitypub.oauth import OAuth2Handler
class InstanceTestThread(QThread):
"""Thread for testing instance connectivity"""
result_ready = Signal(bool, str) # success, message
def __init__(self, instance_url):
super().__init__()
self.instance_url = instance_url
def run(self):
"""Test if the instance is reachable and supports ActivityPub"""
try:
# Clean up the URL
if not self.instance_url.startswith(('http://', 'https://')):
test_url = f"https://{self.instance_url}"
else:
test_url = self.instance_url
# Test instance info endpoint
response = requests.get(f"{test_url}/api/v1/instance", timeout=10)
if response.status_code == 200:
data = response.json()
instance_name = data.get('title', 'Unknown Instance')
version = data.get('version', 'Unknown')
self.result_ready.emit(True, f"Connected to {instance_name} (Version: {version})")
else:
self.result_ready.emit(False, f"Instance returned status {response.status_code}")
except requests.exceptions.ConnectionError:
self.result_ready.emit(False, "Could not connect to instance. Check URL and network connection.")
except requests.exceptions.Timeout:
self.result_ready.emit(False, "Connection timed out. Instance may be slow or unreachable.")
except requests.exceptions.RequestException as e:
self.result_ready.emit(False, f"Connection error: {str(e)}")
except Exception as e:
self.result_ready.emit(False, f"Unexpected error: {str(e)}")
class LoginDialog(QDialog):
"""Dialog for adding new fediverse accounts"""
account_added = Signal(dict) # Emitted when account is successfully added
def __init__(self, parent=None):
super().__init__(parent)
self.test_thread = None
self.oauth_handler = None
self.setup_ui()
self.setup_shortcuts()
def setup_ui(self):
"""Initialize the login dialog UI"""
self.setWindowTitle("Add Fediverse Account")
self.setMinimumSize(500, 400)
self.setModal(True)
layout = QVBoxLayout(self)
# Instructions
instructions = QLabel(
"Enter your fediverse instance URL\n"
"Examples: mastodon.social, social.wolfe.casa, fediverse.social\n\n"
"You will be redirected to your instance to authorize Bifrost."
)
instructions.setWordWrap(True)
instructions.setAccessibleName("Login Instructions")
layout.addWidget(instructions)
# Instance URL input
instance_layout = QHBoxLayout()
instance_layout.addWidget(QLabel("Instance URL:"))
self.instance_edit = QLineEdit()
self.instance_edit.setAccessibleName("Instance URL")
self.instance_edit.setAccessibleDescription("Enter your fediverse instance URL")
self.instance_edit.setPlaceholderText("Example: mastodon.social")
self.instance_edit.textChanged.connect(self.on_instance_changed)
self.instance_edit.returnPressed.connect(self.test_instance)
instance_layout.addWidget(self.instance_edit)
self.test_button = QPushButton("&Test Connection")
self.test_button.setAccessibleName("Test Instance Connection")
self.test_button.clicked.connect(self.test_instance)
self.test_button.setEnabled(False)
instance_layout.addWidget(self.test_button)
layout.addLayout(instance_layout)
# Progress bar for testing
self.progress_bar = QProgressBar()
self.progress_bar.setAccessibleName("Connection Test Progress")
self.progress_bar.setVisible(False)
layout.addWidget(self.progress_bar)
# Results area
self.result_text = QTextEdit()
self.result_text.setAccessibleName("Connection Test Results")
self.result_text.setMaximumHeight(100)
self.result_text.setReadOnly(True)
self.result_text.setVisible(False)
layout.addWidget(self.result_text)
# Username input (for display purposes)
username_layout = QHBoxLayout()
username_layout.addWidget(QLabel("Username (optional):"))
self.username_edit = QLineEdit()
self.username_edit.setAccessibleName("Username")
self.username_edit.setAccessibleDescription("Your username on this instance (for display only)")
self.username_edit.setPlaceholderText("username")
username_layout.addWidget(self.username_edit)
layout.addLayout(username_layout)
# Button box
button_box = QDialogButtonBox()
# Login button
self.login_button = QPushButton("&Login")
self.login_button.setAccessibleName("Login to Instance")
self.login_button.setDefault(True)
self.login_button.clicked.connect(self.start_login)
self.login_button.setEnabled(False)
button_box.addButton(self.login_button, QDialogButtonBox.AcceptRole)
# Cancel button
cancel_button = QPushButton("&Cancel")
cancel_button.setAccessibleName("Cancel Login")
cancel_button.clicked.connect(self.reject)
button_box.addButton(cancel_button, QDialogButtonBox.RejectRole)
layout.addWidget(button_box)
# Set initial focus
self.instance_edit.setFocus()
def setup_shortcuts(self):
"""Set up keyboard shortcuts"""
# Escape to cancel
cancel_shortcut = QShortcut(QKeySequence.Cancel, self)
cancel_shortcut.activated.connect(self.reject)
def on_instance_changed(self, text):
"""Handle instance URL text changes"""
has_text = bool(text.strip())
self.test_button.setEnabled(has_text)
self.login_button.setEnabled(False) # Require testing first
if not has_text:
self.result_text.setVisible(False)
self.progress_bar.setVisible(False)
def test_instance(self):
"""Test connection to the instance"""
instance_url = self.instance_edit.text().strip()
if not instance_url:
return
self.test_button.setEnabled(False)
self.login_button.setEnabled(False)
self.progress_bar.setVisible(True)
self.progress_bar.setRange(0, 0) # Indeterminate progress
self.result_text.setVisible(False)
# Start test thread
self.test_thread = InstanceTestThread(instance_url)
self.test_thread.result_ready.connect(self.on_test_result)
self.test_thread.start()
def on_test_result(self, success, message):
"""Handle instance test results"""
self.progress_bar.setVisible(False)
self.result_text.setVisible(True)
self.result_text.setText(message)
self.test_button.setEnabled(True)
self.login_button.setEnabled(success)
if success:
self.result_text.setStyleSheet("color: green;")
# Show success message box
QMessageBox.information(
self,
"Connection Test Successful",
f"Connection test successful!\n\n{message}\n\nYou can now proceed to login."
)
else:
self.result_text.setStyleSheet("color: red;")
# Show error message box
QMessageBox.warning(
self,
"Connection Test Failed",
f"Connection test failed:\n\n{message}\n\nPlease check your instance URL and try again."
)
def start_login(self):
"""Start the OAuth login process"""
instance_url = self.instance_edit.text().strip()
if not instance_url:
return
# Clean up URL
if not instance_url.startswith(('http://', 'https://')):
instance_url = f"https://{instance_url}"
# Disable UI during authentication
self.login_button.setEnabled(False)
self.test_button.setEnabled(False)
self.instance_edit.setEnabled(False)
self.username_edit.setEnabled(False)
# Start real OAuth2 flow
self.oauth_handler = OAuth2Handler(instance_url)
self.oauth_handler.authentication_complete.connect(self.on_auth_success)
self.oauth_handler.authentication_failed.connect(self.on_auth_failed)
if not self.oauth_handler.start_authentication():
self.on_auth_failed("Failed to start authentication process")
def on_auth_success(self, account_data):
"""Handle successful authentication"""
QMessageBox.information(
self,
"Login Successful",
f"Successfully authenticated as {account_data['display_name']} "
f"({account_data['username']}) on {account_data['instance_url']}"
)
self.account_added.emit(account_data)
self.accept()
def on_auth_failed(self, error_message):
"""Handle authentication failure"""
QMessageBox.warning(
self,
"Authentication Failed",
f"Failed to authenticate with the instance:\n\n{error_message}"
)
# Re-enable UI
self.login_button.setEnabled(True)
self.test_button.setEnabled(True)
self.instance_edit.setEnabled(True)
self.username_edit.setEnabled(True)
def get_instance_url(self) -> str:
"""Get the cleaned instance URL"""
url = self.instance_edit.text().strip()
if not url.startswith(('http://', 'https://')):
url = f"https://{url}"
return url
def get_username(self) -> str:
"""Get the entered username"""
return self.username_edit.text().strip()

View File

@ -0,0 +1,308 @@
"""
Settings dialog for Bifrost configuration
"""
import os
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
QLabel, QComboBox, QPushButton, QDialogButtonBox,
QGroupBox, QCheckBox, QSpinBox, QTabWidget, QWidget
)
from PySide6.QtCore import Qt, Signal
from config.settings import SettingsManager
from accessibility.accessible_combo import AccessibleComboBox
class SettingsDialog(QDialog):
"""Main settings dialog for Bifrost"""
settings_changed = Signal() # Emitted when settings are saved
def __init__(self, parent=None):
super().__init__(parent)
self.settings = SettingsManager()
self.setup_ui()
self.load_current_settings()
def setup_ui(self):
"""Initialize the settings dialog UI"""
self.setWindowTitle("Bifrost Settings")
self.setMinimumSize(500, 400)
self.setModal(True)
layout = QVBoxLayout(self)
# Create tab widget for organized settings
self.tabs = QTabWidget()
layout.addWidget(self.tabs)
# Audio settings tab
self.setup_audio_tab()
# Desktop notifications tab
self.setup_notifications_tab()
# Accessibility settings tab
self.setup_accessibility_tab()
# Button box
button_box = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Apply
)
button_box.accepted.connect(self.save_and_close)
button_box.rejected.connect(self.reject)
button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply_settings)
layout.addWidget(button_box)
def setup_audio_tab(self):
"""Set up the audio settings tab"""
audio_widget = QWidget()
layout = QVBoxLayout(audio_widget)
# Sound pack selection
sound_group = QGroupBox("Sound Pack")
sound_layout = QFormLayout(sound_group)
self.sound_pack_combo = AccessibleComboBox()
self.sound_pack_combo.setAccessibleName("Sound Pack Selection")
self.sound_pack_combo.setAccessibleDescription("Choose which sound pack to use for audio feedback")
# Populate sound packs
self.load_sound_packs()
sound_layout.addRow("Sound Pack:", self.sound_pack_combo)
layout.addWidget(sound_group)
# Audio volume settings
volume_group = QGroupBox("Volume Settings")
volume_layout = QFormLayout(volume_group)
self.master_volume = QSpinBox()
self.master_volume.setRange(0, 100)
self.master_volume.setSuffix("%")
self.master_volume.setAccessibleName("Master Volume")
self.master_volume.setAccessibleDescription("Overall volume for all sounds")
volume_layout.addRow("Master Volume:", self.master_volume)
self.notification_volume = QSpinBox()
self.notification_volume.setRange(0, 100)
self.notification_volume.setSuffix("%")
self.notification_volume.setAccessibleName("Notification Volume")
self.notification_volume.setAccessibleDescription("Volume for notification sounds")
volume_layout.addRow("Notification Volume:", self.notification_volume)
layout.addWidget(volume_group)
# Audio enable/disable options
options_group = QGroupBox("Audio Options")
options_layout = QVBoxLayout(options_group)
self.enable_sounds = QCheckBox("Enable sound effects")
self.enable_sounds.setAccessibleName("Enable Sound Effects")
self.enable_sounds.setAccessibleDescription("Turn sound effects on or off globally")
options_layout.addWidget(self.enable_sounds)
self.enable_post_sounds = QCheckBox("Play sounds for post actions")
self.enable_post_sounds.setAccessibleName("Post Action Sounds")
self.enable_post_sounds.setAccessibleDescription("Play sounds when posting, boosting, or favoriting")
options_layout.addWidget(self.enable_post_sounds)
self.enable_timeline_sounds = QCheckBox("Play sounds for timeline updates")
self.enable_timeline_sounds.setAccessibleName("Timeline Update Sounds")
self.enable_timeline_sounds.setAccessibleDescription("Play sounds when the timeline refreshes")
options_layout.addWidget(self.enable_timeline_sounds)
layout.addWidget(options_group)
layout.addStretch()
self.tabs.addTab(audio_widget, "&Audio")
def setup_notifications_tab(self):
"""Set up the desktop notifications settings tab"""
notifications_widget = QWidget()
layout = QVBoxLayout(notifications_widget)
# Desktop notifications group
desktop_group = QGroupBox("Desktop Notifications")
desktop_layout = QVBoxLayout(desktop_group)
self.enable_desktop_notifications = QCheckBox("Enable desktop notifications")
self.enable_desktop_notifications.setAccessibleName("Enable Desktop Notifications")
self.enable_desktop_notifications.setAccessibleDescription("Show desktop notifications for various events")
desktop_layout.addWidget(self.enable_desktop_notifications)
# Notification types
types_group = QGroupBox("Notification Types")
types_layout = QVBoxLayout(types_group)
self.notify_direct_messages = QCheckBox("Direct/Private messages")
self.notify_direct_messages.setAccessibleName("Direct Message Notifications")
self.notify_direct_messages.setAccessibleDescription("Show notifications for direct messages")
types_layout.addWidget(self.notify_direct_messages)
self.notify_mentions = QCheckBox("Mentions")
self.notify_mentions.setAccessibleName("Mention Notifications")
self.notify_mentions.setAccessibleDescription("Show notifications when you are mentioned")
types_layout.addWidget(self.notify_mentions)
self.notify_boosts = QCheckBox("Boosts/Reblogs")
self.notify_boosts.setAccessibleName("Boost Notifications")
self.notify_boosts.setAccessibleDescription("Show notifications when your posts are boosted")
types_layout.addWidget(self.notify_boosts)
self.notify_favorites = QCheckBox("Favorites")
self.notify_favorites.setAccessibleName("Favorite Notifications")
self.notify_favorites.setAccessibleDescription("Show notifications when your posts are favorited")
types_layout.addWidget(self.notify_favorites)
self.notify_follows = QCheckBox("New followers")
self.notify_follows.setAccessibleName("Follow Notifications")
self.notify_follows.setAccessibleDescription("Show notifications for new followers")
types_layout.addWidget(self.notify_follows)
self.notify_timeline_updates = QCheckBox("Timeline updates")
self.notify_timeline_updates.setAccessibleName("Timeline Update Notifications")
self.notify_timeline_updates.setAccessibleDescription("Show notifications for new posts in timeline")
types_layout.addWidget(self.notify_timeline_updates)
layout.addWidget(desktop_group)
layout.addWidget(types_group)
layout.addStretch()
self.tabs.addTab(notifications_widget, "&Notifications")
def setup_accessibility_tab(self):
"""Set up the accessibility settings tab"""
accessibility_widget = QWidget()
layout = QVBoxLayout(accessibility_widget)
# Navigation settings
nav_group = QGroupBox("Navigation Settings")
nav_layout = QFormLayout(nav_group)
self.page_step_size = QSpinBox()
self.page_step_size.setRange(1, 20)
self.page_step_size.setAccessibleName("Page Step Size")
self.page_step_size.setAccessibleDescription("Number of posts to jump when using Page Up/Down")
nav_layout.addRow("Page Step Size:", self.page_step_size)
layout.addWidget(nav_group)
# Screen reader options
sr_group = QGroupBox("Screen Reader Options")
sr_layout = QVBoxLayout(sr_group)
self.verbose_announcements = QCheckBox("Verbose announcements")
self.verbose_announcements.setAccessibleName("Verbose Announcements")
self.verbose_announcements.setAccessibleDescription("Provide detailed descriptions for screen readers")
sr_layout.addWidget(self.verbose_announcements)
self.announce_thread_state = QCheckBox("Announce thread expand/collapse state")
self.announce_thread_state.setAccessibleName("Thread State Announcements")
self.announce_thread_state.setAccessibleDescription("Announce when threads are expanded or collapsed")
sr_layout.addWidget(self.announce_thread_state)
layout.addWidget(sr_group)
layout.addStretch()
self.tabs.addTab(accessibility_widget, "A&ccessibility")
def load_sound_packs(self):
"""Load available sound packs from the sounds directory"""
self.sound_pack_combo.clear()
# Add default "None" option
self.sound_pack_combo.addItem("None (No sounds)", "none")
# Look for sound pack directories
sounds_dir = "sounds"
if os.path.exists(sounds_dir):
for item in os.listdir(sounds_dir):
pack_dir = os.path.join(sounds_dir, item)
if os.path.isdir(pack_dir):
pack_json = os.path.join(pack_dir, "pack.json")
if os.path.exists(pack_json):
# Valid sound pack
self.sound_pack_combo.addItem(item, item)
# Also check in XDG data directory
try:
data_sounds_dir = self.settings.get_sounds_dir()
if os.path.exists(data_sounds_dir) and data_sounds_dir != sounds_dir:
for item in os.listdir(data_sounds_dir):
pack_dir = os.path.join(data_sounds_dir, item)
if os.path.isdir(pack_dir):
pack_json = os.path.join(pack_dir, "pack.json")
if os.path.exists(pack_json):
# Avoid duplicates
if self.sound_pack_combo.findData(item) == -1:
self.sound_pack_combo.addItem(f"{item} (System)", item)
except Exception:
pass # Ignore errors in system sound pack detection
def load_current_settings(self):
"""Load current settings into the dialog"""
# Audio settings
current_pack = self.settings.get('audio', 'sound_pack', 'none')
index = self.sound_pack_combo.findData(current_pack)
if index >= 0:
self.sound_pack_combo.setCurrentIndex(index)
self.master_volume.setValue(int(self.settings.get('audio', 'master_volume', 100) or 100))
self.notification_volume.setValue(int(self.settings.get('audio', 'notification_volume', 100) or 100))
self.enable_sounds.setChecked(bool(self.settings.get('audio', 'enabled', True)))
self.enable_post_sounds.setChecked(bool(self.settings.get('audio', 'post_sounds', True)))
self.enable_timeline_sounds.setChecked(bool(self.settings.get('audio', 'timeline_sounds', True)))
# Desktop notification settings (defaults: DMs, mentions, follows ON; others OFF)
self.enable_desktop_notifications.setChecked(bool(self.settings.get('notifications', 'enabled', True)))
self.notify_direct_messages.setChecked(bool(self.settings.get('notifications', 'direct_messages', True)))
self.notify_mentions.setChecked(bool(self.settings.get('notifications', 'mentions', True)))
self.notify_boosts.setChecked(bool(self.settings.get('notifications', 'boosts', False)))
self.notify_favorites.setChecked(bool(self.settings.get('notifications', 'favorites', False)))
self.notify_follows.setChecked(bool(self.settings.get('notifications', 'follows', True)))
self.notify_timeline_updates.setChecked(bool(self.settings.get('notifications', 'timeline_updates', False)))
# Accessibility settings
self.page_step_size.setValue(int(self.settings.get('accessibility', 'page_step_size', 5) or 5))
self.verbose_announcements.setChecked(bool(self.settings.get('accessibility', 'verbose_announcements', True)))
self.announce_thread_state.setChecked(bool(self.settings.get('accessibility', 'announce_thread_state', True)))
def apply_settings(self):
"""Apply the current settings without closing the dialog"""
# Audio settings
selected_pack = self.sound_pack_combo.currentData()
self.settings.set('audio', 'sound_pack', selected_pack)
self.settings.set('audio', 'master_volume', self.master_volume.value())
self.settings.set('audio', 'notification_volume', self.notification_volume.value())
self.settings.set('audio', 'enabled', self.enable_sounds.isChecked())
self.settings.set('audio', 'post_sounds', self.enable_post_sounds.isChecked())
self.settings.set('audio', 'timeline_sounds', self.enable_timeline_sounds.isChecked())
# Desktop notification settings
self.settings.set('notifications', 'enabled', self.enable_desktop_notifications.isChecked())
self.settings.set('notifications', 'direct_messages', self.notify_direct_messages.isChecked())
self.settings.set('notifications', 'mentions', self.notify_mentions.isChecked())
self.settings.set('notifications', 'boosts', self.notify_boosts.isChecked())
self.settings.set('notifications', 'favorites', self.notify_favorites.isChecked())
self.settings.set('notifications', 'follows', self.notify_follows.isChecked())
self.settings.set('notifications', 'timeline_updates', self.notify_timeline_updates.isChecked())
# Accessibility settings
self.settings.set('accessibility', 'page_step_size', self.page_step_size.value())
self.settings.set('accessibility', 'verbose_announcements', self.verbose_announcements.isChecked())
self.settings.set('accessibility', 'announce_thread_state', self.announce_thread_state.isChecked())
# Save to file
self.settings.save_settings()
# Emit signal so other components can update
self.settings_changed.emit()
def save_and_close(self):
"""Save settings and close the dialog"""
self.apply_settings()
self.accept()

View File

@ -0,0 +1,298 @@
"""
Timeline view widget for displaying posts and threads
"""
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView, QMenu
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QAction
from typing import Optional
from accessibility.accessible_tree import AccessibleTreeWidget
from audio.sound_manager import SoundManager
from notifications.notification_manager import NotificationManager
from config.settings import SettingsManager
from config.accounts import AccountManager
from activitypub.client import ActivityPubClient
from models.post import Post
class TimelineView(AccessibleTreeWidget):
"""Main timeline display widget"""
# Signals for post actions
reply_requested = Signal(object) # Post object
boost_requested = Signal(object) # Post object
favorite_requested = Signal(object) # Post object
profile_requested = Signal(object) # Post object
def __init__(self, account_manager: AccountManager, parent=None):
super().__init__(parent)
self.timeline_type = "home"
self.settings = SettingsManager()
self.sound_manager = SoundManager(self.settings)
self.notification_manager = NotificationManager(self.settings)
self.account_manager = account_manager
self.activitypub_client = None
self.posts = [] # Store loaded posts
self.setup_ui()
self.refresh()
# Connect sound events
self.item_state_changed.connect(self.on_state_changed)
# Enable context menu
self.setContextMenuPolicy(Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self.show_context_menu)
def setup_ui(self):
"""Initialize the timeline UI"""
# Set up columns
self.setColumnCount(1)
self.setHeaderLabels(["Posts"])
# Hide the header
self.header().hide()
# Set selection behavior
self.setSelectionBehavior(QTreeWidget.SelectRows)
self.setSelectionMode(QTreeWidget.SingleSelection)
# Enable keyboard navigation
self.setFocusPolicy(Qt.StrongFocus)
# Set accessible properties
self.setAccessibleName("Timeline")
self.setAccessibleDescription("Timeline showing posts and conversations")
def set_timeline_type(self, timeline_type: str):
"""Set the timeline type (home, local, federated)"""
self.timeline_type = timeline_type
self.refresh()
def refresh(self):
"""Refresh the timeline content"""
self.clear()
# Get active account
active_account = self.account_manager.get_active_account()
if not active_account:
self.show_empty_message("Nothing to see here. To get content connect to an instance.")
return
# Create ActivityPub client for active account
self.activitypub_client = ActivityPubClient(
active_account.instance_url,
active_account.access_token
)
try:
# Fetch timeline or notifications
if self.timeline_type == "notifications":
timeline_data = self.activitypub_client.get_notifications(limit=20)
else:
timeline_data = self.activitypub_client.get_timeline(self.timeline_type, limit=20)
self.load_timeline_data(timeline_data)
except Exception as e:
print(f"Failed to fetch timeline: {e}")
# Show error message instead of sample data
self.show_empty_message(f"Failed to load timeline: {str(e)}\nCheck your connection and try refreshing.")
def load_timeline_data(self, timeline_data):
"""Load real timeline data from ActivityPub API"""
self.posts = []
if self.timeline_type == "notifications":
# Handle notifications data structure
for notification_data in timeline_data:
try:
notification_type = notification_data['type']
sender = notification_data['account']['display_name'] or notification_data['account']['username']
# Notifications with status (mentions, boosts, favorites)
if 'status' in notification_data:
post = Post.from_api_dict(notification_data['status'])
# Add notification metadata to post
post.notification_type = notification_type
post.notification_account = notification_data['account']['acct']
self.posts.append(post)
# Show desktop notification
content_preview = post.get_content_text()[:100] + "..." if len(post.get_content_text()) > 100 else post.get_content_text()
if notification_type == 'mention':
self.notification_manager.notify_mention(sender, content_preview)
elif notification_type == 'reblog':
self.notification_manager.notify_boost(sender, content_preview)
elif notification_type == 'favourite':
self.notification_manager.notify_favorite(sender, content_preview)
elif notification_type == 'follow':
# Handle follow notifications without status
self.notification_manager.notify_follow(sender)
except Exception as e:
print(f"Error parsing notification: {e}")
continue
else:
# Handle regular timeline data structure
new_posts = []
for status_data in timeline_data:
try:
post = Post.from_api_dict(status_data)
self.posts.append(post)
new_posts.append(post)
except Exception as e:
print(f"Error parsing post: {e}")
continue
# Show timeline update notification if new posts were loaded
if new_posts and len(new_posts) > 0:
timeline_name = {
'home': 'home timeline',
'local': 'local timeline',
'federated': 'federated timeline'
}.get(self.timeline_type, 'timeline')
self.notification_manager.notify_timeline_update(len(new_posts), timeline_name)
# Build thread structure
self.build_threaded_timeline()
def build_threaded_timeline(self):
"""Build threaded timeline from posts"""
# Group posts by conversation
conversations = {}
top_level_posts = []
for post in self.posts:
if post.in_reply_to_id:
# This is a reply
if post.in_reply_to_id not in conversations:
conversations[post.in_reply_to_id] = []
conversations[post.in_reply_to_id].append(post)
else:
# This is a top-level post
top_level_posts.append(post)
# Create tree items
for post in top_level_posts:
post_item = self.create_post_item(post)
self.addTopLevelItem(post_item)
# Add replies if any
if post.id in conversations:
self.add_replies(post_item, conversations[post.id], conversations)
# Collapse all initially
self.collapseAll()
def create_post_item(self, post: Post) -> QTreeWidgetItem:
"""Create a tree item for a post"""
# Get display text
summary = post.get_summary_for_screen_reader()
# Create item
item = QTreeWidgetItem([summary])
item.setData(0, Qt.UserRole, post) # Store post object
item.setData(0, Qt.AccessibleTextRole, summary)
return item
def add_replies(self, parent_item: QTreeWidgetItem, replies, all_conversations):
"""Recursively add replies to a post"""
for reply in replies:
reply_item = self.create_post_item(reply)
parent_item.addChild(reply_item)
# Add nested replies
if reply.id in all_conversations:
self.add_replies(reply_item, all_conversations[reply.id], all_conversations)
def show_empty_message(self, message: str):
"""Show an empty timeline with a message"""
item = QTreeWidgetItem([message])
item.setData(0, Qt.AccessibleTextRole, message)
item.setDisabled(True) # Make it non-selectable
self.addTopLevelItem(item)
def get_selected_post(self) -> Optional[Post]:
"""Get the currently selected post"""
current = self.currentItem()
if current:
return current.data(0, Qt.UserRole)
return None
def get_current_post_info(self) -> str:
"""Get information about the currently selected post"""
current = self.currentItem()
if not current:
return "No post selected"
# Check if this is a top-level post with replies
if current.parent() is None and current.childCount() > 0:
child_count = current.childCount()
expanded = "expanded" if current.isExpanded() else "collapsed"
return f"{current.text(0)} ({child_count} replies, {expanded})"
elif current.parent() is not None:
return f"Reply: {current.text(0)}"
else:
return current.text(0)
def on_state_changed(self, item, state):
"""Handle item state changes with sound feedback"""
if state == "expanded":
self.sound_manager.play_expand()
elif state == "collapsed":
self.sound_manager.play_collapse()
def add_new_posts(self, posts):
"""Add new posts to timeline with sound notification"""
# TODO: Implement adding real posts from API
if posts:
self.sound_manager.play_timeline_update()
def show_context_menu(self, position):
"""Show context menu for post actions"""
item = self.itemAt(position)
if not item:
return
post = item.data(0, Qt.UserRole)
if not post:
return
menu = QMenu(self)
# Reply action
reply_action = QAction("&Reply", self)
reply_action.setStatusTip("Reply to this post")
reply_action.triggered.connect(lambda: self.reply_requested.emit(post))
menu.addAction(reply_action)
# Boost action
boost_text = "Un&boost" if post.reblogged else "&Boost"
boost_action = QAction(boost_text, self)
boost_action.setStatusTip(f"{boost_text.replace('&', '')} this post")
boost_action.triggered.connect(lambda: self.boost_requested.emit(post))
menu.addAction(boost_action)
# Favorite action
fav_text = "&Unfavorite" if post.favourited else "&Favorite"
fav_action = QAction(fav_text, self)
fav_action.setStatusTip(f"{fav_text.replace('&', '')} this post")
fav_action.triggered.connect(lambda: self.favorite_requested.emit(post))
menu.addAction(fav_action)
menu.addSeparator()
# View profile action
profile_action = QAction("View &Profile", self)
profile_action.setStatusTip(f"View profile of {post.account.display_name}")
profile_action.triggered.connect(lambda: self.profile_requested.emit(post))
menu.addAction(profile_action)
# Show menu
menu.exec(self.mapToGlobal(position))
def announce_current_item(self):
"""Announce the current item for screen readers"""
# This will be handled by the AccessibleTreeWidget
pass