Add blocked/muted user management tabs with complete keyboard navigation
- Add Blocked Users and Muted Users timeline tabs with visual indicators (🚫/🔇) - Implement dedicated context menus for unblocking/unmuting from management tabs - Add complete keyboard shortcuts for all 10 timeline tabs (Ctrl+1 through Ctrl+0) - Add block (Ctrl+Shift+B) and mute (Ctrl+Shift+M) keyboard shortcuts with confirmation - Fix timeline tab indices and add missing shortcuts for all social timelines - Handle blocked/muted account display with accessibility-friendly status announcements - Prevent self-blocking/muting with appropriate error messages and sound feedback - Support immediate removal from lists after unblock/unmute actions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -77,6 +77,8 @@ class MainWindow(QMainWindow):
|
||||
self.timeline_tabs.addTab(QWidget(), "Bookmarks")
|
||||
self.timeline_tabs.addTab(QWidget(), "Followers")
|
||||
self.timeline_tabs.addTab(QWidget(), "Following")
|
||||
self.timeline_tabs.addTab(QWidget(), "Blocked")
|
||||
self.timeline_tabs.addTab(QWidget(), "Muted")
|
||||
self.timeline_tabs.currentChanged.connect(self.on_timeline_tab_changed)
|
||||
main_layout.addWidget(self.timeline_tabs)
|
||||
|
||||
@ -174,24 +176,60 @@ class MainWindow(QMainWindow):
|
||||
home_action.triggered.connect(lambda: self.switch_timeline(0))
|
||||
timeline_menu.addAction(home_action)
|
||||
|
||||
# Messages timeline action
|
||||
messages_action = QAction("&Messages", self)
|
||||
messages_action.setShortcut(QKeySequence("Ctrl+2"))
|
||||
messages_action.triggered.connect(lambda: self.switch_timeline(1))
|
||||
timeline_menu.addAction(messages_action)
|
||||
|
||||
# Mentions timeline action
|
||||
mentions_action = QAction("&Mentions", self)
|
||||
mentions_action.setShortcut(QKeySequence("Ctrl+2"))
|
||||
mentions_action.triggered.connect(lambda: self.switch_timeline(1))
|
||||
mentions_action = QAction("M&entions", self)
|
||||
mentions_action.setShortcut(QKeySequence("Ctrl+3"))
|
||||
mentions_action.triggered.connect(lambda: self.switch_timeline(2))
|
||||
timeline_menu.addAction(mentions_action)
|
||||
|
||||
# Local timeline action
|
||||
local_action = QAction("&Local", self)
|
||||
local_action.setShortcut(QKeySequence("Ctrl+3"))
|
||||
local_action.triggered.connect(lambda: self.switch_timeline(2))
|
||||
local_action.setShortcut(QKeySequence("Ctrl+4"))
|
||||
local_action.triggered.connect(lambda: self.switch_timeline(3))
|
||||
timeline_menu.addAction(local_action)
|
||||
|
||||
# Federated timeline action
|
||||
federated_action = QAction("&Federated", self)
|
||||
federated_action.setShortcut(QKeySequence("Ctrl+4"))
|
||||
federated_action.triggered.connect(lambda: self.switch_timeline(3))
|
||||
federated_action.setShortcut(QKeySequence("Ctrl+5"))
|
||||
federated_action.triggered.connect(lambda: self.switch_timeline(4))
|
||||
timeline_menu.addAction(federated_action)
|
||||
|
||||
# Bookmarks timeline action
|
||||
bookmarks_action = QAction("&Bookmarks", self)
|
||||
bookmarks_action.setShortcut(QKeySequence("Ctrl+6"))
|
||||
bookmarks_action.triggered.connect(lambda: self.switch_timeline(5))
|
||||
timeline_menu.addAction(bookmarks_action)
|
||||
|
||||
# Followers timeline action
|
||||
followers_action = QAction("Follo&wers", self)
|
||||
followers_action.setShortcut(QKeySequence("Ctrl+7"))
|
||||
followers_action.triggered.connect(lambda: self.switch_timeline(6))
|
||||
timeline_menu.addAction(followers_action)
|
||||
|
||||
# Following timeline action
|
||||
following_action = QAction("Follo&wing", self)
|
||||
following_action.setShortcut(QKeySequence("Ctrl+8"))
|
||||
following_action.triggered.connect(lambda: self.switch_timeline(7))
|
||||
timeline_menu.addAction(following_action)
|
||||
|
||||
# Blocked users timeline action
|
||||
blocked_action = QAction("Bloc&ked Users", self)
|
||||
blocked_action.setShortcut(QKeySequence("Ctrl+9"))
|
||||
blocked_action.triggered.connect(lambda: self.switch_timeline(8))
|
||||
timeline_menu.addAction(blocked_action)
|
||||
|
||||
# Muted users timeline action
|
||||
muted_action = QAction("M&uted Users", self)
|
||||
muted_action.setShortcut(QKeySequence("Ctrl+0"))
|
||||
muted_action.triggered.connect(lambda: self.switch_timeline(9))
|
||||
timeline_menu.addAction(muted_action)
|
||||
|
||||
# Post menu
|
||||
post_menu = menubar.addMenu("&Post")
|
||||
|
||||
@ -258,6 +296,20 @@ class MainWindow(QMainWindow):
|
||||
|
||||
social_menu.addSeparator()
|
||||
|
||||
# Block action
|
||||
block_action = QAction("&Block User", self)
|
||||
block_action.setShortcut(QKeySequence("Ctrl+Shift+B"))
|
||||
block_action.triggered.connect(self.block_current_user)
|
||||
social_menu.addAction(block_action)
|
||||
|
||||
# Mute action
|
||||
mute_action = QAction("&Mute User", self)
|
||||
mute_action.setShortcut(QKeySequence("Ctrl+Shift+M"))
|
||||
mute_action.triggered.connect(self.mute_current_user)
|
||||
social_menu.addAction(mute_action)
|
||||
|
||||
social_menu.addSeparator()
|
||||
|
||||
# Manual follow action
|
||||
manual_follow_action = QAction("Follow &Specific User...", self)
|
||||
manual_follow_action.setShortcut(QKeySequence("Ctrl+Shift+M"))
|
||||
@ -451,8 +503,8 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def switch_timeline(self, index, from_tab_change=False):
|
||||
"""Switch to timeline by index with loading feedback"""
|
||||
timeline_names = ["Home", "Messages", "Mentions", "Local", "Federated", "Bookmarks", "Followers", "Following"]
|
||||
timeline_types = ["home", "conversations", "notifications", "local", "federated", "bookmarks", "followers", "following"]
|
||||
timeline_names = ["Home", "Messages", "Mentions", "Local", "Federated", "Bookmarks", "Followers", "Following", "Blocked", "Muted"]
|
||||
timeline_types = ["home", "conversations", "notifications", "local", "federated", "bookmarks", "followers", "following", "blocked", "muted"]
|
||||
|
||||
if 0 <= index < len(timeline_names):
|
||||
timeline_name = timeline_names[index]
|
||||
@ -929,6 +981,91 @@ class MainWindow(QMainWindow):
|
||||
except Exception as e:
|
||||
self.status_bar.showMessage(f"Follow failed: {str(e)}", 3000)
|
||||
|
||||
def block_current_user(self):
|
||||
"""Block the user of the currently selected post"""
|
||||
post = self.get_selected_post()
|
||||
if post:
|
||||
self.block_user(post)
|
||||
else:
|
||||
self.status_bar.showMessage("No post selected", 2000)
|
||||
|
||||
def mute_current_user(self):
|
||||
"""Mute the user of the currently selected post"""
|
||||
post = self.get_selected_post()
|
||||
if post:
|
||||
self.mute_user(post)
|
||||
else:
|
||||
self.status_bar.showMessage("No post selected", 2000)
|
||||
|
||||
def block_user(self, post):
|
||||
"""Block a user with confirmation dialog"""
|
||||
active_account = self.account_manager.get_active_account()
|
||||
if not active_account or not hasattr(post, 'account'):
|
||||
self.status_bar.showMessage("Cannot block: No active account", 2000)
|
||||
return
|
||||
|
||||
# Don't allow blocking yourself
|
||||
is_own_post = (post.account.username == active_account.username and
|
||||
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
|
||||
|
||||
if is_own_post:
|
||||
self.status_bar.showMessage("Cannot block: Cannot block yourself", 2000)
|
||||
return
|
||||
|
||||
# Show confirmation dialog
|
||||
username = post.account.display_name or post.account.username
|
||||
full_username = f"@{post.account.acct}"
|
||||
|
||||
result = QMessageBox.question(
|
||||
self,
|
||||
"Block User",
|
||||
f"Are you sure you want to block {username} ({full_username})?\n\n"
|
||||
"This will prevent them from following you and seeing your posts.",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
)
|
||||
|
||||
if result == QMessageBox.Yes:
|
||||
try:
|
||||
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
|
||||
client.block_account(post.account.id)
|
||||
self.status_bar.showMessage(f"Blocked {username}", 2000)
|
||||
# Play success sound for successful block
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.play_success()
|
||||
except Exception as e:
|
||||
self.status_bar.showMessage(f"Block failed: {str(e)}", 3000)
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.play_error()
|
||||
|
||||
def mute_user(self, post):
|
||||
"""Mute a user"""
|
||||
active_account = self.account_manager.get_active_account()
|
||||
if not active_account or not hasattr(post, 'account'):
|
||||
self.status_bar.showMessage("Cannot mute: No active account", 2000)
|
||||
return
|
||||
|
||||
# Don't allow muting yourself
|
||||
is_own_post = (post.account.username == active_account.username and
|
||||
post.account.acct.split('@')[-1] == active_account.instance_url.replace('https://', '').replace('http://', ''))
|
||||
|
||||
if is_own_post:
|
||||
self.status_bar.showMessage("Cannot mute: Cannot mute yourself", 2000)
|
||||
return
|
||||
|
||||
try:
|
||||
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
|
||||
client.mute_account(post.account.id)
|
||||
username = post.account.display_name or post.account.username
|
||||
self.status_bar.showMessage(f"Muted {username}", 2000)
|
||||
# Play success sound for successful mute
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.play_success()
|
||||
except Exception as e:
|
||||
self.status_bar.showMessage(f"Mute failed: {str(e)}", 3000)
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.play_error()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle window close event"""
|
||||
# Only play shutdown sound if not already played through quit_application
|
||||
|
@ -112,7 +112,7 @@ class TimelineView(AccessibleTreeWidget):
|
||||
# Get posts per page from settings
|
||||
posts_per_page = int(self.settings.get('timeline', 'posts_per_page', 40) or 40)
|
||||
|
||||
# Fetch timeline, notifications, followers/following, conversations, or bookmarks
|
||||
# Fetch timeline, notifications, followers/following, conversations, bookmarks, blocked/muted users
|
||||
if self.timeline_type == "notifications":
|
||||
timeline_data = self.activitypub_client.get_notifications(limit=posts_per_page)
|
||||
elif self.timeline_type == "followers":
|
||||
@ -127,6 +127,10 @@ class TimelineView(AccessibleTreeWidget):
|
||||
timeline_data = self.load_conversations(posts_per_page)
|
||||
elif self.timeline_type == "bookmarks":
|
||||
timeline_data = self.activitypub_client.get_bookmarks(limit=posts_per_page)
|
||||
elif self.timeline_type == "blocked":
|
||||
timeline_data = self.activitypub_client.get_blocked_accounts(limit=posts_per_page)
|
||||
elif self.timeline_type == "muted":
|
||||
timeline_data = self.activitypub_client.get_muted_accounts(limit=posts_per_page)
|
||||
else:
|
||||
timeline_data = self.activitypub_client.get_timeline(self.timeline_type, limit=posts_per_page)
|
||||
self.load_timeline_data(timeline_data)
|
||||
@ -147,7 +151,7 @@ class TimelineView(AccessibleTreeWidget):
|
||||
"""Load real timeline data from ActivityPub API"""
|
||||
# Check for new content by comparing newest post ID (only for regular timelines)
|
||||
has_new_content = False
|
||||
if timeline_data and self.newest_post_id and self.timeline_type not in ["followers", "following"]:
|
||||
if timeline_data and self.newest_post_id and self.timeline_type not in ["followers", "following", "blocked", "muted"]:
|
||||
# Check if the first post (newest) is different from what we had
|
||||
current_newest_id = timeline_data[0]['id']
|
||||
if current_newest_id != self.newest_post_id:
|
||||
@ -236,8 +240,8 @@ class TimelineView(AccessibleTreeWidget):
|
||||
except Exception as e:
|
||||
print(f"Error parsing conversation: {e}")
|
||||
continue
|
||||
elif self.timeline_type in ["followers", "following"]:
|
||||
# Handle followers/following data structure (account list)
|
||||
elif self.timeline_type in ["followers", "following", "blocked", "muted"]:
|
||||
# Handle followers/following/blocked/muted data structure (account list)
|
||||
for account_data in timeline_data:
|
||||
try:
|
||||
# Create a pseudo-post from account data for display
|
||||
@ -246,27 +250,47 @@ class TimelineView(AccessibleTreeWidget):
|
||||
|
||||
# Create a special Post-like object for accounts
|
||||
class AccountDisplayPost:
|
||||
def __init__(self, user):
|
||||
def __init__(self, user, account_type):
|
||||
self.id = user.id
|
||||
self.account = user
|
||||
self.account_type = account_type # "followers", "following", "blocked", "muted"
|
||||
self.in_reply_to_id = None
|
||||
self.content = f"<p>@{user.username} - {user.display_name or user.username}</p>"
|
||||
|
||||
# Add status indicator based on account type
|
||||
status_indicator = ""
|
||||
if account_type == "blocked":
|
||||
status_indicator = " 🚫"
|
||||
elif account_type == "muted":
|
||||
status_indicator = " 🔇"
|
||||
|
||||
self.content = f"<p>@{user.username} - {user.display_name or user.username}{status_indicator}</p>"
|
||||
if user.note:
|
||||
self.content += f"<br><small>{user.note}</small>"
|
||||
|
||||
def get_content_text(self):
|
||||
return f"@{self.account.username} - {self.account.display_name or self.account.username}"
|
||||
status_text = ""
|
||||
if self.account_type == "blocked":
|
||||
status_text = " (Blocked)"
|
||||
elif self.account_type == "muted":
|
||||
status_text = " (Muted)"
|
||||
return f"@{self.account.username} - {self.account.display_name or self.account.username}{status_text}"
|
||||
|
||||
def get_summary_for_screen_reader(self):
|
||||
username = self.account.display_name or self.account.username
|
||||
status_text = ""
|
||||
if self.account_type == "blocked":
|
||||
status_text = " - Blocked user"
|
||||
elif self.account_type == "muted":
|
||||
status_text = " - Muted user"
|
||||
|
||||
if self.account.note:
|
||||
# Strip HTML tags from note
|
||||
import re
|
||||
note = re.sub('<[^<]+?>', '', self.account.note)
|
||||
return f"{username} (@{self.account.username}): {note}"
|
||||
return f"{username} (@{self.account.username})"
|
||||
return f"{username} (@{self.account.username}){status_text}: {note}"
|
||||
return f"{username} (@{self.account.username}){status_text}"
|
||||
|
||||
account_post = AccountDisplayPost(user)
|
||||
account_post = AccountDisplayPost(user, self.timeline_type)
|
||||
self.posts.append(account_post)
|
||||
|
||||
except Exception as e:
|
||||
@ -745,6 +769,32 @@ class TimelineView(AccessibleTreeWidget):
|
||||
|
||||
menu = QMenu(self)
|
||||
|
||||
# Check if this is a blocked/muted user management context
|
||||
is_blocked_user = (self.timeline_type == "blocked" and hasattr(post, 'account_type') and post.account_type == "blocked")
|
||||
is_muted_user = (self.timeline_type == "muted" and hasattr(post, 'account_type') and post.account_type == "muted")
|
||||
|
||||
# For blocked/muted user management, show simplified context menu
|
||||
if is_blocked_user or is_muted_user:
|
||||
# Unblock/Unmute action
|
||||
if is_blocked_user:
|
||||
unblock_action = QAction("&Unblock User", self)
|
||||
unblock_action.triggered.connect(lambda: self.unblock_user_from_list(post))
|
||||
menu.addAction(unblock_action)
|
||||
else: # is_muted_user
|
||||
unmute_action = QAction("&Unmute User", self)
|
||||
unmute_action.triggered.connect(lambda: self.unmute_user_from_list(post))
|
||||
menu.addAction(unmute_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# View profile action
|
||||
profile_action = QAction("View &Profile", self)
|
||||
profile_action.triggered.connect(lambda: self.profile_requested.emit(post))
|
||||
menu.addAction(profile_action)
|
||||
|
||||
menu.exec(self.mapToGlobal(position))
|
||||
return
|
||||
|
||||
# Get current user account for ownership checks
|
||||
active_account = self.account_manager.get_active_account()
|
||||
is_own_post = False
|
||||
@ -1035,4 +1085,50 @@ class TimelineView(AccessibleTreeWidget):
|
||||
def announce_current_item(self):
|
||||
"""Announce the current item for screen readers"""
|
||||
# This will be handled by the AccessibleTreeWidget
|
||||
pass
|
||||
pass
|
||||
|
||||
def unblock_user_from_list(self, post):
|
||||
"""Unblock a user from the blocked users list"""
|
||||
try:
|
||||
# Unblock the user via API
|
||||
self.activitypub_client.unblock_account(post.account.id)
|
||||
|
||||
# Remove from current timeline display
|
||||
current_item = self.currentItem()
|
||||
if current_item and current_item.data(0, Qt.UserRole) == post:
|
||||
# Find and remove the item
|
||||
for i in range(self.topLevelItemCount()):
|
||||
item = self.topLevelItem(i)
|
||||
if item.data(0, Qt.UserRole) == post:
|
||||
self.takeTopLevelItem(i)
|
||||
break
|
||||
|
||||
# Play success sound
|
||||
self.sound_manager.play_success()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error unblocking user: {e}")
|
||||
self.sound_manager.play_error()
|
||||
|
||||
def unmute_user_from_list(self, post):
|
||||
"""Unmute a user from the muted users list"""
|
||||
try:
|
||||
# Unmute the user via API
|
||||
self.activitypub_client.unmute_account(post.account.id)
|
||||
|
||||
# Remove from current timeline display
|
||||
current_item = self.currentItem()
|
||||
if current_item and current_item.data(0, Qt.UserRole) == post:
|
||||
# Find and remove the item
|
||||
for i in range(self.topLevelItemCount()):
|
||||
item = self.topLevelItem(i)
|
||||
if item.data(0, Qt.UserRole) == post:
|
||||
self.takeTopLevelItem(i)
|
||||
break
|
||||
|
||||
# Play success sound
|
||||
self.sound_manager.play_success()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error unmuting user: {e}")
|
||||
self.sound_manager.play_error()
|
Reference in New Issue
Block a user