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:
@ -444,6 +444,10 @@ class ActivityPubClient:
|
||||
params['max_id'] = max_id
|
||||
|
||||
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):
|
||||
|
183
src/widgets/custom_emoji_manager.py
Normal file
183
src/widgets/custom_emoji_manager.py
Normal 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)
|
Reference in New Issue
Block a user