diff --git a/CLAUDE.md b/CLAUDE.md index 2cd6670..bdc115d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -563,6 +563,12 @@ verbose_announcements = true - **Filter Dialog**: ✅ Timeline filter configuration with real-time application - **Profile Editing**: ✅ Edit your profile information including display name, bio, profile fields, and privacy settings +### Latest Bug Fixes (August 2025) +- **Unicode Content Display**: ✅ Fixed apostrophes and special characters in posts using proper HTML entity decoding +- **Persistent Filter Settings**: ✅ Timeline filter preferences now save and restore across application restarts +- **Robust Timeline Filtering**: ✅ Fixed reblog filter bypass in "load more posts" and streaming updates - all post sources now respect filters +- **Soundpack Installation**: ✅ Enhanced soundpack discovery and validation - soundpacks properly install to XDG directories + ### Remaining High Priority Features - **User Blocking Management**: Block/unblock users with dedicated management interface - **User Muting Management**: Mute/unmute users with management interface diff --git a/README.md b/README.md index edfaeaf..e5574a2 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,13 @@ Bifrost includes a sophisticated sound system with intelligent notification hand - **Alt Text Support**: Mandatory accessibility descriptions for uploaded media - **File Validation**: MIME type and size checking with user-friendly error messages +## Recent Updates (August 2025) + +- **Enhanced Unicode Support**: Fixed display of apostrophes, quotes, and other special characters in posts using proper HTML entity decoding +- **Persistent Timeline Filters**: Timeline filter settings (show/hide replies, boosts, mentions, etc.) now persist across application restarts +- **Robust Filter System**: Fixed issue where reblog filters would stop working after timeline updates - all post loading paths now properly apply filters +- **Improved Soundpack System**: Enhanced soundpack installation validation and user experience with proper XDG directory handling + ## Technology Stack - **PySide6**: Main GUI framework for proven accessibility diff --git a/src/models/post.py b/src/models/post.py index 7f8cb0f..8df82d6 100644 --- a/src/models/post.py +++ b/src/models/post.py @@ -200,8 +200,9 @@ class Post: # Basic HTML stripping (should be improved with proper HTML parsing) import re + import html content = re.sub(r'<[^>]+>', '', self.content) - content = content.replace('<', '<').replace('>', '>').replace('&', '&') + content = html.unescape(content) return content.strip() def get_display_content(self) -> str: @@ -212,11 +213,11 @@ class Post: # Always use the rendered HTML content for display # This is what the server sends back after processing markdown/etc if self.content: - # Basic HTML entity decoding - content = self.content.replace('<', '<').replace('>', '>').replace('&', '&') # Strip HTML tags for plain text display in terminal-style interface import re - content = re.sub(r'<[^>]+>', '', content) + import html + content = re.sub(r'<[^>]+>', '', self.content) + content = html.unescape(content) return content.strip() return "" diff --git a/src/widgets/timeline_view.py b/src/widgets/timeline_view.py index 5fc3b22..7302c8c 100644 --- a/src/widgets/timeline_view.py +++ b/src/widgets/timeline_view.py @@ -57,15 +57,8 @@ class TimelineView(QTreeWidget): self.activitypub_client = None self.posts = [] # Store loaded posts - # Timeline filtering options - self.filter_settings = { - 'show_replies': True, - 'show_boosts': True, - 'show_mentions': True, - 'show_media_only': False, - 'show_text_only': False, - 'blocked_keywords': [] # List of keywords/emojis to filter out - } + # Timeline filtering options - load from settings + self.filter_settings = self._load_filter_settings() # Post actions manager for centralized operations self.post_actions_manager = PostActionsManager(self.account_manager, self.sound_manager) @@ -859,10 +852,65 @@ class TimelineView(QTreeWidget): return True + def _load_filter_settings(self): + """Load filter settings from persistent storage""" + default_settings = { + 'show_replies': True, + 'show_boosts': True, + 'show_mentions': True, + 'show_media_only': False, + 'show_text_only': False, + 'blocked_keywords': [] # List of keywords/emojis to filter out + } + + # Load from settings manager + if not self.settings.config.has_section('timeline_filters'): + # Create section with defaults if it doesn't exist + self.settings.config.add_section('timeline_filters') + for key, value in default_settings.items(): + if isinstance(value, list): + self.settings.set('timeline_filters', key, ','.join(value)) + else: + self.settings.set('timeline_filters', key, str(value)) + self.settings.save_settings() + return default_settings + + # Load existing settings + loaded_settings = {} + for key, default_value in default_settings.items(): + if isinstance(default_value, bool): + loaded_settings[key] = self.settings.get_bool('timeline_filters', key, default_value) + elif isinstance(default_value, list): + # Handle comma-separated lists + saved_value = self.settings.get('timeline_filters', key, '') + if saved_value: + loaded_settings[key] = [item.strip() for item in saved_value.split(',') if item.strip()] + else: + loaded_settings[key] = [] + else: + loaded_settings[key] = self.settings.get('timeline_filters', key, default_value) + + return loaded_settings + + def _save_filter_settings(self): + """Save current filter settings to persistent storage""" + if not self.settings.config.has_section('timeline_filters'): + self.settings.config.add_section('timeline_filters') + + for key, value in self.filter_settings.items(): + if isinstance(value, list): + self.settings.set('timeline_filters', key, ','.join(value)) + else: + self.settings.set('timeline_filters', key, str(value)) + + self.settings.save_settings() + self.logger.debug("Timeline filter settings saved") + def update_filter_setting(self, filter_name, enabled): """Update a filter setting and refresh timeline""" if filter_name in self.filter_settings: self.filter_settings[filter_name] = enabled + self._save_filter_settings() # Persist the change self.logger.info(f"Timeline filter updated: {filter_name} = {enabled}") self.refresh() # Refresh timeline to apply new filter @@ -871,6 +919,7 @@ class TimelineView(QTreeWidget): keyword = keyword.strip() if keyword and keyword not in self.filter_settings['blocked_keywords']: self.filter_settings['blocked_keywords'].append(keyword) + self._save_filter_settings() # Persist the change self.logger.info(f"Added blocked keyword: '{keyword}'") self.refresh() # Refresh timeline to apply new filter @@ -878,12 +927,14 @@ class TimelineView(QTreeWidget): """Remove a keyword/emoji from the block list""" if keyword in self.filter_settings['blocked_keywords']: self.filter_settings['blocked_keywords'].remove(keyword) + self._save_filter_settings() # Persist the change self.logger.info(f"Removed blocked keyword: '{keyword}'") self.refresh() # Refresh timeline to apply new filter def update_blocked_keywords(self, keywords_list): """Update the entire blocked keywords list""" self.filter_settings['blocked_keywords'] = [k.strip() for k in keywords_list if k.strip()] + self._save_filter_settings() # Persist the change self.logger.info(f"Updated blocked keywords list: {len(self.filter_settings['blocked_keywords'])} keywords") self.refresh() # Refresh timeline to apply new filter @@ -1061,6 +1112,12 @@ class TimelineView(QTreeWidget): try: self.logger.debug(f"Adding streaming post {new_post.id} to timeline") + # Apply filters to the new post (only for main timeline types) + if self.timeline_type in ["home", "local", "federated"]: + if not self.should_show_post(new_post): + self.logger.debug(f"Streaming post {new_post.id} filtered out, not adding to timeline") + return + # If this is a reply, try to find its parent in the current timeline if new_post.in_reply_to_id: parent_item = self.find_existing_post_item(new_post.in_reply_to_id) @@ -1374,6 +1431,8 @@ class TimelineView(QTreeWidget): def load_additional_timeline_data(self, timeline_data): """Load additional timeline data and append to existing posts""" + new_posts = [] + if self.timeline_type == "notifications": # Handle notifications data structure for notification_data in timeline_data: @@ -1389,7 +1448,7 @@ class TimelineView(QTreeWidget): post = Post.from_api_dict(notification_data["status"]) post.notification_type = notification_type post.notification_account = notification_data["account"]["acct"] - self.posts.append(post) + new_posts.append(post) elif notification_type == "follow": # Handle follow notifications without status pass # Could create a special post type for follows @@ -1401,10 +1460,19 @@ class TimelineView(QTreeWidget): 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: self.logger.error(f"Error parsing post: {e}") continue + + # Apply timeline filters to new posts before adding them (only for main timeline types) + if self.timeline_type in ["home", "local", "federated"]: + filtered_posts = self.apply_timeline_filters(new_posts) + self.posts.extend(filtered_posts) + self.logger.debug(f"Added {len(filtered_posts)} filtered posts from {len(new_posts)} new posts") + else: + # For other timeline types (notifications, conversations, etc.), add all posts + self.posts.extend(new_posts) def add_new_posts_to_tree(self, timeline_data, insert_index): """Add new posts to the tree at the specified index without rebuilding everything"""