Add comprehensive custom emoji support with instance-specific emojis

- Create CustomEmojiManager for fetching, caching, and searching custom emojis
- Add get_custom_emojis() API method to ActivityPub client for /api/v1/custom_emojis
- Implement hybrid emoji autocomplete showing both Unicode and custom emojis
- Cache custom emojis per instance in ~/.cache/bifrost/custom_emojis/ for performance
- Support proper emoji insertion: Unicode as characters, custom as :shortcode: format
- Add visual distinction with "(custom)" suffix in autocomplete suggestions
- Smart emoji limits: 5 Unicode + 5 custom emojis in autocomplete dropdown
- Automatic emoji loading when compose dialog opens for seamless experience

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Storm Dragon
2025-07-21 11:24:12 -04:00
parent 3d9ce28334
commit e96ce0c861
2 changed files with 187 additions and 0 deletions

View File

@ -445,6 +445,10 @@ class ActivityPubClient:
return self._make_request('GET', '/api/v1/mutes', params=params) return self._make_request('GET', '/api/v1/mutes', params=params)
def get_custom_emojis(self) -> List[Dict]:
"""Get list of custom emojis available on this instance"""
return self._make_request('GET', '/api/v1/custom_emojis')
class AuthenticationError(Exception): class AuthenticationError(Exception):
"""Raised when authentication fails""" """Raised when authentication fails"""

View File

@ -0,0 +1,183 @@
"""
Custom emoji manager for instance-specific emojis
"""
import json
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass
from config.settings import SettingsManager
@dataclass
class CustomEmoji:
"""Custom emoji data structure"""
shortcode: str
url: str
static_url: str
visible_in_picker: bool = True
category: Optional[str] = None
def get_display_name(self) -> str:
"""Get display name for autocomplete"""
return f":{self.shortcode}:"
def get_search_text(self) -> str:
"""Get text to search against"""
# Include shortcode and category for searching
search_parts = [self.shortcode]
if self.category:
search_parts.append(self.category)
return " ".join(search_parts).lower()
class CustomEmojiManager:
"""Manages custom emojis for fediverse instances"""
def __init__(self, settings: SettingsManager):
self.settings = settings
self.emojis_cache: Dict[str, List[CustomEmoji]] = {}
self.cache_dir = Path.home() / ".cache" / "bifrost" / "custom_emojis"
self.cache_dir.mkdir(parents=True, exist_ok=True)
def get_cache_file(self, instance_url: str) -> Path:
"""Get cache file path for an instance"""
# Sanitize instance URL for filename
safe_name = instance_url.replace("https://", "").replace("http://", "").replace("/", "_")
return self.cache_dir / f"{safe_name}.json"
def load_cached_emojis(self, instance_url: str) -> List[CustomEmoji]:
"""Load cached emojis for an instance"""
if instance_url in self.emojis_cache:
return self.emojis_cache[instance_url]
cache_file = self.get_cache_file(instance_url)
if not cache_file.exists():
return []
try:
with open(cache_file, 'r', encoding='utf-8') as f:
emoji_data = json.load(f)
emojis = []
for data in emoji_data.get('emojis', []):
emoji = CustomEmoji(
shortcode=data['shortcode'],
url=data['url'],
static_url=data['static_url'],
visible_in_picker=data.get('visible_in_picker', True),
category=data.get('category')
)
emojis.append(emoji)
self.emojis_cache[instance_url] = emojis
return emojis
except (json.JSONDecodeError, KeyError, IOError) as e:
print(f"Error loading cached emojis for {instance_url}: {e}")
return []
def save_emojis_to_cache(self, instance_url: str, emojis: List[CustomEmoji]):
"""Save emojis to cache"""
cache_file = self.get_cache_file(instance_url)
try:
emoji_data = {
'instance_url': instance_url,
'emojis': [
{
'shortcode': emoji.shortcode,
'url': emoji.url,
'static_url': emoji.static_url,
'visible_in_picker': emoji.visible_in_picker,
'category': emoji.category
}
for emoji in emojis
]
}
with open(cache_file, 'w', encoding='utf-8') as f:
json.dump(emoji_data, f, indent=2, ensure_ascii=False)
# Update memory cache
self.emojis_cache[instance_url] = emojis
except IOError as e:
print(f"Error saving emojis cache for {instance_url}: {e}")
def fetch_and_cache_emojis(self, instance_url: str, activitypub_client) -> List[CustomEmoji]:
"""Fetch emojis from instance and cache them"""
try:
emoji_data = activitypub_client.get_custom_emojis()
emojis = []
for data in emoji_data:
# Skip invisible emojis unless specifically requested
if not data.get('visible_in_picker', True):
continue
emoji = CustomEmoji(
shortcode=data['shortcode'],
url=data['url'],
static_url=data['static_url'],
visible_in_picker=data.get('visible_in_picker', True),
category=data.get('category')
)
emojis.append(emoji)
# Save to cache
self.save_emojis_to_cache(instance_url, emojis)
return emojis
except Exception as e:
print(f"Error fetching custom emojis from {instance_url}: {e}")
# Fall back to cached emojis if available
return self.load_cached_emojis(instance_url)
def get_emojis_for_instance(self, instance_url: str, activitypub_client=None) -> List[CustomEmoji]:
"""Get custom emojis for an instance, preferring cache but fetching if needed"""
# Try cache first
cached_emojis = self.load_cached_emojis(instance_url)
# If we have cached emojis, return them
if cached_emojis:
return cached_emojis
# If no cache and we have a client, try to fetch
if activitypub_client:
return self.fetch_and_cache_emojis(instance_url, activitypub_client)
return []
def search_emojis(self, instance_url: str, query: str, limit: int = 10) -> List[CustomEmoji]:
"""Search custom emojis by query"""
emojis = self.load_cached_emojis(instance_url)
if not emojis:
return []
query = query.lower().strip()
if not query:
# Return first N emojis if no query
return emojis[:limit]
# Search by shortcode and category
matches = []
for emoji in emojis:
search_text = emoji.get_search_text()
if query in search_text:
matches.append(emoji)
return matches[:limit]
def get_emoji_by_shortcode(self, instance_url: str, shortcode: str) -> Optional[CustomEmoji]:
"""Get a specific emoji by shortcode"""
emojis = self.load_cached_emojis(instance_url)
for emoji in emojis:
if emoji.shortcode == shortcode:
return emoji
return None
def refresh_emojis(self, instance_url: str, activitypub_client):
"""Force refresh emojis from the instance"""
return self.fetch_and_cache_emojis(instance_url, activitypub_client)