diff --git a/README.md b/README.md index c82f138..b48db5b 100644 --- a/README.md +++ b/README.md @@ -16,23 +16,41 @@ This project was created through "vibe coding" - a collaborative development app - **Timeline Switching**: Easy navigation between Home, Mentions, Local, and Federated 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 +- **Smart Autocomplete**: Mention completion with full fediverse handles (@user@instance.com) +- **Comprehensive Emoji Support**: 5,000+ Unicode emojis with keyword search +- **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 ## Audio System Bifrost includes a sophisticated sound system with: -- Customizable sound packs (includes Default sounds) -- Audio feedback for all major actions -- Per-event volume control -- Cross-platform audio support +- **Soundpack Manager**: Secure HTTPS-based repository system +- **Repository Management**: Add/remove soundpack repositories with validation +- **One-click Installation**: Download, validate, and install soundpacks securely +- **Customizable Sound Packs**: Themed audio notifications (Default pack included) +- **Audio Feedback**: Sound events for all major actions and notifications +- **Per-event Volume Control**: Fine-tune individual sound effects +- **Cross-platform Audio Support**: Works on Linux, Windows, and macOS + +## Compose Features + +- **Mention Autocomplete**: Type `@` to get suggestions from followers/following/search +- **Full Fediverse Handles**: Completes to full format (@user@instance.com) +- **Emoji Autocomplete**: Type `:` to search 5,000+ Unicode emojis +- **Keyword Search**: Find emojis by typing keywords (`:fire`, `:heart`, `:grin`) +- **Real-time Character Count**: Visual feedback with limit warnings +- **Content Warnings**: Optional spoiler text support +- **Visibility Controls**: Public, Unlisted, Followers-only, or Direct messages ## Technology Stack - **PySide6**: Main GUI framework for proven accessibility -- **ActivityPub**: Full federation protocol support +- **ActivityPub**: Full federation protocol support - **simpleaudio**: Cross-platform audio with subprocess fallback - **Plyer**: Cross-platform desktop notifications +- **emoji**: Comprehensive Unicode emoji library (5,000+ emojis) - **XDG Base Directory**: Standards-compliant configuration storage ## Keyboard Shortcuts @@ -60,9 +78,18 @@ Bifrost includes a sophisticated sound system with: - **Enter**: Expand/collapse threads - **Tab**: Move between interface elements +### Compose Dialog +- **Ctrl+Enter**: Send post +- **@**: Trigger mention autocomplete +- **:**: Trigger emoji autocomplete +- **Arrow Keys**: Navigate autocomplete suggestions +- **Enter/Tab**: Accept selected completion +- **Escape**: Close autocomplete or cancel compose + ### Application - **Ctrl+,**: Open Settings - **Ctrl+Shift+A**: Add new account +- **Ctrl+Alt+S**: Open Soundpack Manager - **Ctrl+Q**: Quit application ## Installation @@ -76,7 +103,7 @@ python bifrost.py Or on Arch Linux: ```bash -sudo pacman -S python-pyside6 python-requests python-simpleaudio +sudo pacman -S python-pyside6 python-requests python-simpleaudio python-emoji yay -S python-plyer ``` diff --git a/requirements.txt b/requirements.txt index 8da3a7c..7a88be3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ PySide6>=6.0.0 requests>=2.25.0 simpleaudio>=1.0.4 -plyer>=2.1.0 \ No newline at end of file +plyer>=2.1.0 +emoji>=2.0.0 \ No newline at end of file diff --git a/src/activitypub/client.py b/src/activitypub/client.py index 299027d..920618e 100644 --- a/src/activitypub/client.py +++ b/src/activitypub/client.py @@ -178,6 +178,35 @@ class ActivityPubClient: endpoint = f'/api/v1/accounts/{account_id}/unfollow' return self._make_request('POST', endpoint) + def get_followers(self, account_id: str, max_id: Optional[str] = None, limit: int = 40) -> List[Dict]: + """Get followers for an account""" + params = {'limit': limit} + if max_id: + params['max_id'] = max_id + + endpoint = f'/api/v1/accounts/{account_id}/followers' + return self._make_request('GET', endpoint, params=params) + + def get_following(self, account_id: str, max_id: Optional[str] = None, limit: int = 40) -> List[Dict]: + """Get accounts that an account is following""" + params = {'limit': limit} + if max_id: + params['max_id'] = max_id + + endpoint = f'/api/v1/accounts/{account_id}/following' + return self._make_request('GET', endpoint, params=params) + + def search_accounts(self, query: str, limit: int = 10) -> List[Dict]: + """Search for accounts by username""" + params = { + 'q': query, + 'type': 'accounts', + 'limit': limit + } + + result = self._make_request('GET', '/api/v2/search', params=params) + return result.get('accounts', []) + def search(self, query: str, account_id: Optional[str] = None, max_id: Optional[str] = None, min_id: Optional[str] = None, type_filter: Optional[str] = None, limit: int = 20) -> Dict: diff --git a/src/widgets/autocomplete_textedit.py b/src/widgets/autocomplete_textedit.py index 316732b..733ca96 100644 --- a/src/widgets/autocomplete_textedit.py +++ b/src/widgets/autocomplete_textedit.py @@ -6,6 +6,7 @@ from PySide6.QtWidgets import QTextEdit, QCompleter, QListWidget, QListWidgetIte from PySide6.QtCore import Qt, Signal, QStringListModel, QRect from PySide6.QtGui import QTextCursor, QKeyEvent import re +import emoji from typing import List, Dict @@ -28,113 +29,63 @@ class AutocompleteTextEdit(QTextEdit): self.completion_start = 0 self.completion_type = None # 'mention' or 'emoji' - # Load default emojis - self.load_default_emojis() + # Load comprehensive emoji dataset + self.load_unicode_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"]}, + def load_unicode_emojis(self): + """Load comprehensive Unicode emoji dataset using emoji library""" + print("Loading Unicode emoji dataset...") + self.emoji_list = [] + + # Get all emoji data from the emoji library + emoji_data = emoji.EMOJI_DATA + + for emoji_char, data in emoji_data.items(): + # Skip emojis without names + if 'en' not in data: + continue + + # Extract shortcode from emoji library format (removes colons) + name = data['en'].strip() + if name.startswith(':') and name.endswith(':'): + shortcode = name[1:-1] # Remove surrounding colons + else: + shortcode = name.lower().replace(' ', '_').replace('-', '_') + shortcode = re.sub(r'[^a-zA-Z0-9_]', '', shortcode) - # 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"]}, + if not shortcode: + continue - # 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"]}, + # Create keywords from the shortcode + # Split shortcode by underscores to get individual words + words = shortcode.split('_') + keywords = [] + for word in words: + if len(word) >= 2: # Only add words with 2+ chars + keywords.append(word.lower()) - # 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"]}, + # Add some common synonyms for frequent emojis + synonyms = { + 'grinning_face': ['smile', 'happy', 'grin'], + 'face_with_tears_of_joy': ['laugh', 'lol', 'funny'], + 'red_heart': ['love', 'heart'], + 'thumbs_up': ['good', 'ok', 'yes', 'like'], + 'thumbs_down': ['bad', 'no', 'dislike'], + 'fire': ['hot', 'lit', 'flame'], + 'star': ['favorite', 'best'], + 'waving_hand': ['hello', 'hi', 'bye'], + } - # 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"]}, + if shortcode in synonyms: + keywords.extend(synonyms[shortcode]) - # 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"]}, - ] + self.emoji_list.append({ + "shortcode": shortcode, + "emoji": emoji_char, + "keywords": keywords + }) + + # Loaded successfully def set_mention_list(self, mentions: List[str]): """Set the list of available mentions (usernames)""" @@ -195,7 +146,7 @@ class AutocompleteTextEdit(QTextEdit): # Look backwards for @ or : start_pos = pos - 1 - while start_pos >= 0 and text[start_pos] not in [' ', '\n', '\t']: + while start_pos >= 0 and start_pos < len(text) and text[start_pos] not in [' ', '\n', '\t']: start_pos -= 1 start_pos += 1 @@ -255,6 +206,7 @@ class AutocompleteTextEdit(QTextEdit): any(keyword.startswith(prefix_lower) for keyword in keywords)): display_text = f"{shortcode} {emoji['emoji']}" matches.append(display_text) + # Add to matches if matches: self.show_completer(matches, prefix, start_pos, 'emoji') @@ -291,7 +243,12 @@ class AutocompleteTextEdit(QTextEdit): def hide_completer(self): """Hide the completer""" if self.completer: - self.completer.popup().hide() + popup = self.completer.popup() + popup.hide() + # Clear the completion state + self.completion_prefix = "" + self.completion_start = 0 + self.completion_type = None def insert_completion(self): """Insert the selected completion""" @@ -303,7 +260,8 @@ class AutocompleteTextEdit(QTextEdit): if not current_index.isValid(): return - completion = self.completer.currentCompletion() + # Get the actually selected completion from the popup + completion = current_index.data() # Replace the current prefix with the completion cursor = self.textCursor() @@ -313,12 +271,43 @@ class AutocompleteTextEdit(QTextEdit): 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}: ") + # Extract the actual emoji character (after the shortcode) + parts = completion.split(' ', 1) + if len(parts) >= 2: + emoji_char = parts[1].strip() + cursor.insertText(f"{emoji_char} ") + else: + # Fallback to shortcode format if parsing fails + shortcode = parts[0] + cursor.insertText(f":{shortcode}: ") + # Set the updated cursor position + self.setTextCursor(cursor) self.hide_completer() + # Try immediate focus restoration + self.setFocus(Qt.OtherFocusReason) + + # Also try with a delay as backup + from PySide6.QtCore import QTimer + QTimer.singleShot(0, self.restore_focus_immediate) + QTimer.singleShot(100, self.restore_focus) + + def restore_focus_immediate(self): + """Immediate focus restoration attempt""" + self.setFocus(Qt.TabFocusReason) + self.ensureCursorVisible() + + def restore_focus(self): + """Delayed focus restoration as backup""" + # Ensure we're visible and enabled before taking focus + if self.isVisible() and self.isEnabled(): + self.setFocus(Qt.TabFocusReason) # Use TabFocusReason instead + self.activateWindow() # Also activate the parent window + self.ensureCursorVisible() + # Force a repaint to ensure visual focus + self.update() + def update_mention_list(self, mentions: List[str]): """Update mention list (called from parent when data is ready)""" self.mention_list = mentions diff --git a/src/widgets/compose_dialog.py b/src/widgets/compose_dialog.py index 7ab3354..bc584d2 100644 --- a/src/widgets/compose_dialog.py +++ b/src/widgets/compose_dialog.py @@ -223,20 +223,92 @@ class ComposeDialog(QDialog): 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) + try: + # Get the active account and create API client + active_account = self.account_manager.get_active_account() + if not active_account: + return + + from activitypub.client import ActivityPubClient + client = ActivityPubClient(active_account.instance_url, active_account.access_token) + + # Get current user's account ID + current_user = client.verify_credentials() + current_account_id = current_user['id'] + + # Collect usernames from multiple sources + usernames = set() + + # 1. Search for accounts matching the prefix + if len(prefix) >= 1: # Search when user has typed at least 1 character + try: + search_results = client.search_accounts(prefix, limit=10) + for account in search_results: + # Use full fediverse handle (acct field) or construct it + full_handle = account.get('acct', '') + if not full_handle: + username = account.get('username', '') + domain = account.get('url', '').split('/')[2] if account.get('url') else '' + if username and domain: + full_handle = f"{username}@{domain}" + else: + full_handle = username + + if full_handle: + usernames.add(full_handle) + except Exception as e: + print(f"Search failed: {e}") + + # 2. Get followers (people who follow you) + try: + followers = client.get_followers(current_account_id, limit=50) + for follower in followers: + # Use full fediverse handle + full_handle = follower.get('acct', '') + if not full_handle: + username = follower.get('username', '') + domain = follower.get('url', '').split('/')[2] if follower.get('url') else '' + if username and domain: + full_handle = f"{username}@{domain}" + else: + full_handle = username + + if full_handle and full_handle.lower().startswith(prefix.lower()): + usernames.add(full_handle) + except Exception as e: + print(f"Failed to get followers: {e}") + + # 3. Get following (people you follow) + try: + following = client.get_following(current_account_id, limit=50) + for account in following: + # Use full fediverse handle + full_handle = account.get('acct', '') + if not full_handle: + username = account.get('username', '') + domain = account.get('url', '').split('/')[2] if account.get('url') else '' + if username and domain: + full_handle = f"{username}@{domain}" + else: + full_handle = username + + if full_handle and full_handle.lower().startswith(prefix.lower()): + usernames.add(full_handle) + except Exception as e: + print(f"Failed to get following: {e}") + + # Convert to sorted list + filtered = sorted(list(usernames)) + + # Only use real API data - no fallback to incomplete sample data + # The empty list will trigger a fresh API call if needed + + self.text_edit.update_mention_list(filtered) + + except Exception as e: + print(f"Failed to load mention suggestions: {e}") + # Fallback to empty list + self.text_edit.update_mention_list([]) def load_emoji_suggestions(self, prefix: str): """Load emoji suggestions based on prefix"""