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:
@ -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"""
|
||||||
|
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