Merge pull request #912 from jpavonabian/atproto-integration

Atproto integration
This commit is contained in:
2026-03-04 04:55:38 -06:00
committed by GitHub
54 changed files with 11025 additions and 5622 deletions

View File

@@ -0,0 +1,23 @@
{
"permissions": {
"allow": [
"Bash(ls:*)",
"Bash(dir:*)",
"Bash(findstr:*)",
"Bash(find:*)",
"Bash(python:*)",
"Bash(git checkout:*)",
"WebSearch",
"WebFetch(domain:bsky.app)",
"Bash(cmd /c \"dir /s /b %APPDATA%\\\\twblue\\\\*.log 2>nul\")",
"Bash(cmd /c \"dir /s /b %TEMP%\\\\twblue*.log 2>nul\")",
"Bash(cmd /c \"type C:\\\\Users\\\\Usuario\\\\Desktop\\\\repos\\\\twblue\\\\src\\\\logs\\\\debug.log | findstr /n . | findstr /r \"^[0-9]*:\" | tail -50\")",
"WebFetch(domain:atproto.blue)",
"Bash(fc:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(pip show:*)"
]
}
}

View File

@@ -71,6 +71,50 @@ Now that you have installed all these packages, you can run TW Blue from source
If necessary, change the first part of the command to reflect the location of your python executable.
### Development bootstrap (Windows / PowerShell)
If you are starting fresh in this repository, run:
```powershell
./scripts/bootstrap-dev.ps1
```
This script initializes submodules, creates `.venv` (unless you opt out), and installs dependencies.
Useful options:
```powershell
# Recreate virtual environment from scratch
./scripts/bootstrap-dev.ps1 -RecreateVenv
# Upgrade pip tooling before installing requirements
./scripts/bootstrap-dev.ps1 -UpgradePip
# Use system Python instead of .venv
./scripts/bootstrap-dev.ps1 -SystemPython
```
### Running tests (Windows / PowerShell)
After bootstrap, run tests with:
```powershell
./scripts/run-tests.ps1
```
Useful options:
```powershell
# Run only Bluesky tests
./scripts/run-tests.ps1 -PytestTargets src/test/sessions/blueski -PytestArgs "-q"
# Run only a specific test file
./scripts/run-tests.ps1 -PytestTargets src/test/sessions/blueski/test_blueski_quotes.py -PytestArgs "-q"
# Use system Python instead of .venv
./scripts/run-tests.ps1 -SystemPython
```
### Generating the documentation
To generate the documentation in html format, navigate to the doc folder inside this repo. After that, run these commands:

View File

@@ -1,5 +1,51 @@
TWBlue Changelog
## Changes in version 2024.X.X (Upcoming - ATProtoSocial Integration)
This version introduces comprehensive support for the AT Protocol (ATProto), enabling users to connect to and interact with Bluesky accounts.
* Core:
* **New Protocol Support**: Added ATProtoSocial (Bluesky) as a new session type.
* **Session Management**: Users can add Bluesky accounts using their handle and an App Password. Includes session creation, loading, and management through the Session Manager.
* **UI Adaptation**:
* Menus (e.g., "Post", "User Actions") dynamically update labels and available actions based on whether an ATProtoSocial session is active (e.g., "Tweet" becomes "Post", "Retweet" becomes "Repost", "Favorite" becomes "Like").
* New compose dialog (`src/wxUI/dialogs/composeDialog.py`) created to be more generic and configurable by session type, supporting features like character limits, media attachments (images with alt text), language selection, content warnings, and quoting specific to Bluesky.
* New user profile dialog (`src/wxUI/dialogs/atprotosocial/showUserProfile.py`) for displaying Bluesky user details and performing actions.
* New UI panels (`src/wxUI/buffers/atprotosocial/panels.py`) for displaying Home timelines, User timelines, Notifications, and User Lists (Followers/Following) for Bluesky.
* ATProtoSocial (Bluesky) Features:
* **Authentication**: Secure login using user handle and App Passwords.
* **Posting**:
* Create posts (skeets) with text.
* Attach images (up to 4, with alt text).
* Specify post language(s).
* Add content warnings (sensitive content labels).
* Quote other posts.
* Reply to posts.
* **Timelines**:
* View Home timeline (posts from followed users), with support for loading newer and older posts.
* View other users' timelines (their posts and replies).
* **Notifications**:
* Fetch and display notifications for likes, reposts, follows, mentions, replies, and quotes.
* Notifications are displayed in a dedicated buffer and trigger desktop alerts.
* **User Actions**:
* View user profiles (display name, handle, bio, counts, etc.).
* Follow / Unfollow users.
* Mute / Unmute users.
* Block / Unblock users.
* **User Interaction**:
* Like / Unlike posts.
* Repost / Unrepost posts (Unrepost might be deleting the repost record).
* **User Discovery**:
* Search for users by handle or display name.
* View lists of followers and accounts a user is following.
* **Content Display**:
* Posts are formatted for display, showing author, text, timestamp, embedded media (images, quotes, external links with placeholders), reply/repost/like counts, and CWs.
* Notifications are formatted for display in their buffer.
* Developer / Internal:
* New session module: `sessions.atprotosocial` (Session, Utils, Compose, Streaming placeholders).
* New controller module: `controller.atprotosocial` (Handler, UserList, etc.).
* Extensive use of the `atproto` Python SDK for Bluesky API interactions.
## changes in this version
* Core:

View File

@@ -28,4 +28,15 @@ The invisible interface, as its name suggests, has no graphical window and works
Global settings and session settings
++++++++++++++++++++++++++++++++++++++++++++++
TWBlue has two different configuration dialogs: the global configuration dialog, which affects how TWBlue works for all sessions, and the session configuration dialog, which only affects how the current session works. You will find specific information about the session settings dialog for Twitter and Mastodon in its corresponding chapter in this guide.
TWBlue has two different configuration dialogs: the global configuration dialog, which affects how TWBlue works for all sessions, and the session configuration dialog, which only affects how the current session works. You will find specific information about the session settings dialog for Twitter and Mastodon in its corresponding chapter in this guide.
Blueski / Bluesky Specific Terms
++++++++++++++++++++++++++++++++++++++
When using the Blueski (Bluesky) integration, you might encounter these terms:
* **Handle**: Your user-facing address on Bluesky (e.g., ``@username.bsky.social`` or a custom domain like ``@yourname.com``). This is what you use to log in with an App Password in TWBlue. Handles can be changed, but your DID remains the same.
* **App Password**: A specific password you generate within your Bluesky account settings (usually under Settings -> Advanced -> App passwords) for use with third-party applications like TWBlue. This is more secure than using your main account password, as each App Password can be revoked individually.
* **DID (Decentralized Identifier)**: A unique, permanent identifier for users and data on the AT Protocol. It typically starts with ``did:plc:``. Your DID doesn't change even if your handle does. You generally won't need to interact with DIDs directly in TWBlue, as handles are used more commonly for user interaction.
* **Skyline**: This is the term Bluesky uses for your main home timeline, showing posts from people you follow.
* **Skeet**: An informal term for a post on Bluesky (akin to a "tweet" on Twitter).

View File

@@ -0,0 +1,59 @@
.. _blueski_bluesky:
**************************************
Blueski (Bluesky) Integration
**************************************
TWBlue now supports the AT Protocol (ATProto), the decentralized social networking protocol that powers Bluesky. This allows you to interact with your Bluesky account directly within TWBlue.
Adding a Blueski Account
===============================
To connect your Bluesky account to TWBlue, you will need your user **handle** and an **App Password**.
1. **User Handle**: This is your unique Bluesky identifier, often in the format ``@username.bsky.social`` or a custom domain you've configured (e.g., ``@yourname.com``).
2. **App Password**: Bluesky uses App Passwords for third-party applications like TWBlue instead of your main account password. You need to generate an App Password from your Bluesky account settings.
* Go to Bluesky Settings (usually accessible from the Bluesky app or website).
* Navigate to the "App passwords" section (this might be under "Advanced" or "Security").
* Generate a new App Password. Give it a descriptive name (e.g., "TWBlue").
* Copy the generated App Password immediately. It will usually only be shown once.
Once you have your handle and the App Password:
1. Open TWBlue and go to the Session Manager (Application Menu -> Manage accounts).
2. Click on "New account".
3. Select "Blueski (Bluesky)" from the menu.
4. A dialog will prompt you to confirm that you want to authorize your account. Click "Yes".
5. You will then be asked for your Bluesky Handle. Enter your full handle (e.g., ``@username.bsky.social`` or ``username.bsky.social``).
6. Next, you will be asked for the App Password you generated. Enter it carefully.
7. If the credentials are correct, TWBlue will log in to your Bluesky account, and the new session will be added to your accounts list.
Key Features
============
Once your Blueski account is connected, you can use the following features in TWBlue:
* **Posting**: Create new posts (often called "skeets") with text, images, and specify language.
* **Timelines**:
* **Discover (algorithmic)**: A home feed curated by Bluesky.
* **Following (chronological)**: View posts from users you follow in order.
* **User Timelines**: View posts from specific users.
* **Mentions & Replies**: These will appear in your Notifications.
* **Notifications**: Receive notifications for likes, reposts, follows, mentions, replies, and quotes.
* **User Actions**:
* Follow and unfollow users.
* Mute and unmute users.
* Block and unblock users (blocking is done on your PDS/server).
* **Quoting Posts**: Quote other users' posts when you create a new post.
* **User Search**: Search for users by their handle or display name.
* **Content Warnings**: Create posts with content warnings (sensitive content labels).
Basic Concepts for Blueski
==================================
* **DID (Decentralized Identifier)**: A unique, permanent identifier for users and data on the AT Protocol. It doesn't change even if your handle does. You generally won't need to interact with DIDs directly in TWBlue, as handles are used more commonly.
* **Handle**: Your user-facing address on Bluesky (e.g., ``@username.bsky.social``). This is what you use to log in with an App Password in TWBlue. Handles can be changed, but your DID remains the same.
* **App Password**: A specific password you generate within your Bluesky account settings for use with third-party applications like TWBlue. This is more secure than using your main account password.
* **PDS (Personal Data Server)**: Where your account data is stored on the AT Protocol network. Most users are on the main Bluesky PDS (bsky.social), but you could potentially use a different one. TWBlue will typically connect to the default Bluesky PDS.
Further details on specific actions can be found in the relevant sections of this documentation. As Bluesky and the AT Protocol evolve, TWBlue will aim to incorporate new features and refinements.

View File

@@ -16,6 +16,7 @@ This is the user guide for the latest available version of TWBlue. The purpose o
system_requirements
installation
basic_concepts
blueski
usage
global_settings
credits

View File

@@ -55,4 +55,5 @@ win-inet-pton==1.1.0
winpaths==0.2
wxPython==4.2.5
youtube-dl==2021.12.17
zipp==3.23.0
zipp==3.23.0
atproto>=0.0.65

176
scripts/bootstrap-dev.ps1 Normal file
View File

@@ -0,0 +1,176 @@
<#
.SYNOPSIS
Bootstraps a local TWBlue development environment on Windows.
.DESCRIPTION
This script initializes git submodules, creates/uses a virtual environment,
and installs Python dependencies from requirements.txt.
It is intended to be run once when starting on the repository (or whenever
you need to rebuild your local environment).
.PARAMETER RecreateVenv
Deletes and recreates the `.venv` folder before installing dependencies.
.PARAMETER UpgradePip
Upgrades `pip`, `setuptools`, and `wheel` before installing requirements.
.PARAMETER SkipSubmodules
Skips `git submodule init` and `git submodule update --recursive`.
.PARAMETER SystemPython
Uses a detected system Python instead of creating/using `.venv`.
.EXAMPLE
./scripts/bootstrap-dev.ps1
.EXAMPLE
./scripts/bootstrap-dev.ps1 -RecreateVenv -UpgradePip
.EXAMPLE
./scripts/bootstrap-dev.ps1 -SystemPython -SkipSubmodules
#>
param(
[switch]$RecreateVenv,
[switch]$UpgradePip,
[switch]$SkipSubmodules,
[switch]$SystemPython
)
$ErrorActionPreference = "Stop"
function Write-Step {
param([string]$Message)
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Resolve-RepoRoot {
param([string]$ScriptDir)
return (Resolve-Path (Join-Path $ScriptDir "..")).Path
}
function Test-PythonCandidate {
param(
[string]$Exe,
[string[]]$BaseArgs = @()
)
try {
& $Exe @BaseArgs -c "import sys; print(sys.version)" | Out-Null
return ($LASTEXITCODE -eq 0)
}
catch {
return $false
}
}
function New-PythonSpec {
param(
[string]$Exe,
[string[]]$BaseArgs = @()
)
return [pscustomobject]@{
Exe = $Exe
BaseArgs = $BaseArgs
}
}
function Get-PythonSpec {
param([string]$RepoRoot, [bool]$UseSystemPython)
if (-not $UseSystemPython) {
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
if (Test-Path $venvPython) {
return (New-PythonSpec -Exe $venvPython)
}
}
$candidates = @()
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
if ($pythonCmd) {
$candidates += ,(New-PythonSpec -Exe "python")
}
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
if ($pyCmd) {
$candidates += ,(New-PythonSpec -Exe "py" -BaseArgs @("-3.10"))
$candidates += ,(New-PythonSpec -Exe "py" -BaseArgs @("-3"))
}
foreach ($candidate in $candidates) {
if (Test-PythonCandidate -Exe $candidate.Exe -BaseArgs $candidate.BaseArgs) {
return $candidate
}
}
throw "Could not find a usable Python. Install Python 3.10+ and try again."
}
function Invoke-Python {
param(
[pscustomobject]$PythonSpec,
[string[]]$Arguments,
[string]$WorkingDirectory
)
Push-Location $WorkingDirectory
try {
& $PythonSpec.Exe @($PythonSpec.BaseArgs + $Arguments)
if ($LASTEXITCODE -ne 0) {
throw "Python command failed with exit code $LASTEXITCODE"
}
}
finally {
Pop-Location
}
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-RepoRoot -ScriptDir $scriptDir
Write-Step "Repository: $repoRoot"
if (-not $SkipSubmodules) {
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
if ($gitCmd) {
Write-Step "Initializing/updating submodules"
Push-Location $repoRoot
try {
& git submodule init | Out-Host
& git submodule update --recursive | Out-Host
}
finally {
Pop-Location
}
}
else {
Write-Warning "Git is not available. Skipping submodule initialization."
}
}
$venvPath = Join-Path $repoRoot ".venv"
if ($RecreateVenv -and (Test-Path $venvPath)) {
Write-Step "Removing existing virtual environment"
Remove-Item -Recurse -Force $venvPath
}
if (-not $SystemPython -and -not (Test-Path (Join-Path $venvPath "Scripts\python.exe"))) {
Write-Step "Creating virtual environment in .venv"
$bootstrapPython = Get-PythonSpec -RepoRoot $repoRoot -UseSystemPython $true
Invoke-Python -PythonSpec $bootstrapPython -Arguments @("-m", "venv", ".venv") -WorkingDirectory $repoRoot
}
$python = Get-PythonSpec -RepoRoot $repoRoot -UseSystemPython $SystemPython.IsPresent
Write-Step "Using Python: $($python.Exe) $($python.BaseArgs -join ' ')"
if ($UpgradePip) {
Write-Step "Upgrading pip/setuptools/wheel"
Invoke-Python -PythonSpec $python -Arguments @("-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel") -WorkingDirectory $repoRoot
}
Write-Step "Installing dependencies"
Invoke-Python -PythonSpec $python -Arguments @("-m", "pip", "install", "-r", "requirements.txt") -WorkingDirectory $repoRoot
Write-Step "Bootstrap completed"

163
scripts/run-tests.ps1 Normal file
View File

@@ -0,0 +1,163 @@
<#
.SYNOPSIS
Runs TWBlue tests with minimal runtime setup.
.DESCRIPTION
This script only executes pytest. It does not install dependencies,
create virtual environments, or initialize submodules.
Use `./scripts/bootstrap-dev.ps1` first to prepare a development environment.
.PARAMETER PytestTargets
One or more pytest target paths/files. Defaults to `src/test`.
.PARAMETER PytestArgs
Additional pytest arguments. Defaults to `-q`.
.PARAMETER SystemPython
Uses a detected system Python instead of `.venv`.
.EXAMPLE
./scripts/run-tests.ps1
.EXAMPLE
./scripts/run-tests.ps1 -PytestTargets src/test/sessions/blueski -PytestArgs "-q"
.EXAMPLE
./scripts/run-tests.ps1 -SystemPython
#>
param(
[string[]]$PytestTargets = @("src/test"),
[string[]]$PytestArgs = @("-q"),
[switch]$SystemPython
)
$ErrorActionPreference = "Stop"
function Write-Step {
param([string]$Message)
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Resolve-RepoRoot {
param([string]$ScriptDir)
return (Resolve-Path (Join-Path $ScriptDir "..")).Path
}
function Test-PythonCandidate {
param(
[string]$Exe,
[string[]]$BaseArgs = @()
)
try {
& $Exe @BaseArgs -c "import sys; print(sys.version)" | Out-Null
return ($LASTEXITCODE -eq 0)
}
catch {
return $false
}
}
function New-PythonSpec {
param(
[string]$Exe,
[string[]]$BaseArgs = @()
)
return [pscustomobject]@{
Exe = $Exe
BaseArgs = $BaseArgs
}
}
function Get-PythonSpec {
param([string]$RepoRoot, [bool]$UseSystemPython)
if (-not $UseSystemPython) {
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
if (Test-Path $venvPython) {
return (New-PythonSpec -Exe $venvPython)
}
}
$candidates = @()
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
if ($pythonCmd) {
$candidates += ,(New-PythonSpec -Exe "python")
}
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
if ($pyCmd) {
$candidates += ,(New-PythonSpec -Exe "py" -BaseArgs @("-3.10"))
$candidates += ,(New-PythonSpec -Exe "py" -BaseArgs @("-3"))
}
foreach ($candidate in $candidates) {
if (Test-PythonCandidate -Exe $candidate.Exe -BaseArgs $candidate.BaseArgs) {
return $candidate
}
}
throw "Could not find a usable Python. Install Python 3.10+ and try again."
}
function Invoke-Python {
param(
[pscustomobject]$PythonSpec,
[string[]]$Arguments,
[string]$WorkingDirectory
)
Push-Location $WorkingDirectory
try {
& $PythonSpec.Exe @($PythonSpec.BaseArgs + $Arguments)
if ($LASTEXITCODE -ne 0) {
throw "Python command failed with exit code $LASTEXITCODE"
}
}
finally {
Pop-Location
}
}
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-RepoRoot -ScriptDir $scriptDir
Write-Step "Repository: $repoRoot"
$venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe"
if (-not $SystemPython -and -not (Test-Path $venvPython)) {
throw "No .venv Python found. Run ./scripts/bootstrap-dev.ps1 first, or pass -SystemPython."
}
$python = Get-PythonSpec -RepoRoot $repoRoot -UseSystemPython $SystemPython.IsPresent
Write-Step "Using Python: $($python.Exe) $($python.BaseArgs -join ' ')"
Write-Step "Detecting Python architecture"
$archOutput = & $python.Exe @($python.BaseArgs + @("-c", "import struct; print('x64' if struct.calcsize('P')*8 == 64 else 'x86')"))
if ($LASTEXITCODE -ne 0 -or -not $archOutput) {
throw "Could not determine Python architecture."
}
$arch = ($archOutput | Select-Object -Last 1).Trim()
if ($arch -ne "x86" -and $arch -ne "x64") {
throw "Could not determine Python architecture (result: '$arch')."
}
$vlcPath = Join-Path $repoRoot "windows-dependencies\$arch"
if (-not (Test-Path $vlcPath)) {
throw "Could not find '$vlcPath'. Run ./scripts/bootstrap-dev.ps1 first."
}
$env:PYTHON_VLC_MODULE_PATH = $vlcPath
if (-not ($env:PATH -split ';' | Where-Object { $_ -eq $vlcPath })) {
$env:PATH = "$vlcPath;$env:PATH"
}
Write-Step "PYTHON_VLC_MODULE_PATH=$($env:PYTHON_VLC_MODULE_PATH)"
Write-Step "Running tests"
$pytestCommandArgs = @("-m", "pytest") + $PytestTargets + $PytestArgs
Invoke-Python -PythonSpec $python -Arguments $pytestCommandArgs -WorkingDirectory $repoRoot

54
src/blueski.defaults Normal file
View File

@@ -0,0 +1,54 @@
[blueski]
handle = string(default="")
app_password = string(default="")
did = string(default="")
session_string = string(default="")
user_name = string(default="")
[general]
relative_times = boolean(default=True)
max_posts_per_call = integer(default=40)
reverse_timelines = boolean(default=False)
persist_size = integer(default=0)
load_cache_in_memory = boolean(default=True)
show_screen_names = boolean(default=False)
hide_emojis = boolean(default=False)
buffer_order = list(default=list('home', 'notifications'))
disable_streaming = boolean(default=True)
[sound]
volume = float(default=1.0)
input_device = string(default="Default")
output_device = string(default="Default")
session_mute = boolean(default=False)
current_soundpack = string(default="FreakyBlue")
indicate_audio = boolean(default=True)
indicate_img = boolean(default=True)
[other_buffers]
timelines = list(default=list())
followers_timelines = list(default=list())
following_timelines = list(default=list())
searches = list(default=list())
muted_buffers = list(default=list())
autoread_buffers = list(default=list(notifications))
[mysc]
spelling_language = string(default="")
save_followers_in_autocompletion_db = boolean(default=False)
save_friends_in_autocompletion_db = boolean(default=False)
ocr_language = string(default="")
[reporting]
braille_reporting = boolean(default=True)
speech_reporting = boolean(default=True)
[templates]
post = string(default="$display_name, $reply_to$safe_text $date.")
person = string(default="$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.")
notification = string(default="$display_name $text, $date")
[filters]
[user-aliases]

View File

@@ -0,0 +1,3 @@
from .handler import Handler
__all__ = ["Handler"]

View File

@@ -0,0 +1,973 @@
from __future__ import annotations
import logging
import wx
import asyncio
import output
from mysc.thread_utils import call_threaded
import widgetUtils
from extra.autocompletionUsers import completion
from wxUI.dialogs.blueski.showUserProfile import ShowUserProfileDialog
from typing import Any
import languageHandler # Ensure _() injection
logger = logging.getLogger(__name__)
class Handler:
"""Handler for Bluesky integration: creates minimal buffers."""
def __init__(self):
super().__init__()
self.menus = dict(
# Application menu
updateProfile="HIDE",
menuitem_search=_("&Search"),
lists="HIDE",
manageAliases="HIDE",
# Item menu
compose=_("&Post"),
reply=_("Re&ply"),
share=_("&Boost"),
fav=_("&Add to favorites"),
unfav="HIDE",
view=_("&Show post"),
view_conversation=_("View conversa&tion"),
ocr=_("&OCR"),
delete=_("&Delete"),
# User menu
follow=_("&Actions..."),
timeline=_("&View timeline..."),
dm=_("Direct me&ssage"),
addAlias="HIDE",
addToList="HIDE",
removeFromList="HIDE",
details=_("S&how user profile"),
favs="HIDE",
# Buffer menu
community_timeline="HIDE",
filter="HIDE",
manage_filters="HIDE",
)
self.item_menu = _("&Post")
def create_buffers(self, session, createAccounts=True, controller=None):
name = session.get_name()
if createAccounts:
from pubsub import pub
pub.sendMessage("core.create_account", name=name, session_id=session.session_id, logged=session.logged)
if not session.logged:
logger.debug(f"Session {session.session_id} is not logged in, skipping timeline buffer creation.")
return
if name not in controller.accounts:
controller.accounts.append(name)
root_position = controller.view.search(name, name)
from pubsub import pub
# Home (Following-only timeline - reverse-chronological)
pub.sendMessage(
"createBuffer",
buffer_type="following_timeline",
session_type="blueski",
buffer_title=_("Home"),
parent_tab=root_position,
start=True,
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session, sound="tweet_received.ogg")
)
# Discover timeline
pub.sendMessage(
"createBuffer",
buffer_type="home_timeline",
session_type="blueski",
buffer_title=_("Discover"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session, sound="tweet_received.ogg")
)
# Mentions (replies, mentions, quotes)
pub.sendMessage(
"createBuffer",
buffer_type="MentionsBuffer",
session_type="blueski",
buffer_title=_("Mentions"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="mentions", session=session, sound="mention_received.ogg")
)
# Chats
pub.sendMessage(
"createBuffer",
buffer_type="ConversationListBuffer",
session_type="blueski",
buffer_title=_("Chats"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="direct_messages", session=session, sound="dm_received.ogg")
)
# Notifications
pub.sendMessage(
"createBuffer",
buffer_type="notifications",
session_type="blueski",
buffer_title=_("Notifications"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="notifications", session=session, sound="new_event.ogg")
)
# Sent posts
pub.sendMessage(
"createBuffer",
buffer_type="SentBuffer",
session_type="blueski",
buffer_title=_("Sent"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="sent", session=session, sound="tweet_received.ogg")
)
# Likes
pub.sendMessage(
"createBuffer",
buffer_type="likes",
session_type="blueski",
buffer_title=_("Likes"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="likes", session=session, sound="favourite.ogg")
)
# Followers
pub.sendMessage(
"createBuffer",
buffer_type="FollowersBuffer",
session_type="blueski",
buffer_title=_("Followers"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="followers", session=session, sound="update_followers.ogg")
)
# Followings (Users you follow)
pub.sendMessage(
"createBuffer",
buffer_type="FollowingBuffer",
session_type="blueski",
buffer_title=_("Following"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="following", session=session, sound="update_followers.ogg")
)
# Blocks
pub.sendMessage(
"createBuffer",
buffer_type="BlocksBuffer",
session_type="blueski",
buffer_title=_("Blocked Users"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="blocked", session=session)
)
# Timelines container
pub.sendMessage(
"createBuffer",
buffer_type="EmptyBuffer",
session_type="base",
buffer_title=_("Timelines"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="timelines", account=name)
)
timelines_position = controller.view.search("timelines", name)
# Searches container (Bluesky supports search buffers)
pub.sendMessage(
"createBuffer",
buffer_type="EmptyBuffer",
session_type="base",
buffer_title=_("Searches"),
parent_tab=root_position,
start=False,
kwargs=dict(parent=controller.view.nb, name="searches", account=name)
)
searches_position = controller.view.search("searches", name)
# Saved searches
try:
searches = session.settings["other_buffers"].get("searches")
if searches is None:
searches = []
if isinstance(searches, str):
searches = [s for s in searches.split(",") if s]
for query in searches:
buffer_name = f"search_{query[:20]}"
title = _("Search: {query}").format(query=query)
pub.sendMessage(
"createBuffer",
buffer_type="SearchBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=searches_position,
start=False,
kwargs=dict(parent=controller.view.nb, name=buffer_name, session=session, query=query, sound="search_updated.ogg")
)
except Exception as e:
logger.error("Failed to restore Bluesky search buffers: %s", e)
# Saved user timelines
try:
timelines = session.settings["other_buffers"].get("timelines")
if timelines is None:
timelines = []
if isinstance(timelines, str):
timelines = [t for t in timelines.split(",") if t]
for actor in timelines:
handle = actor
try:
if isinstance(actor, str) and actor.startswith("did:"):
profile = session.get_profile(actor)
if profile:
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
handle = g(profile, "handle") or actor
except Exception:
handle = actor
title = _("Timeline for {user}").format(user=handle)
pub.sendMessage(
"createBuffer",
buffer_type="UserTimeline",
session_type="blueski",
buffer_title=title,
parent_tab=timelines_position,
start=False,
kwargs=dict(parent=controller.view.nb, name=f"{handle}-timeline", session=session, actor=actor, handle=handle, sound="tweet_timeline.ogg")
)
except Exception as e:
logger.error("Failed to restore Bluesky timeline buffers: %s", e)
# Saved followers/following timelines
try:
followers = session.settings["other_buffers"].get("followers_timelines")
if followers is None:
followers = []
if isinstance(followers, str):
followers = [t for t in followers.split(",") if t]
for actor in followers:
handle = actor
try:
if isinstance(actor, str) and actor.startswith("did:"):
profile = session.get_profile(actor)
if profile:
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
handle = g(profile, "handle") or actor
except Exception:
handle = actor
own_actor = session.db.get("user_id") or session.db.get("user_name")
own_handle = session.db.get("user_name")
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
continue
title = _("Followers for {user}").format(user=handle)
pub.sendMessage(
"createBuffer",
buffer_type="FollowersBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=timelines_position,
start=False,
kwargs=dict(parent=controller.view.nb, name=f"{handle}-followers", session=session, actor=actor, handle=handle, sound="new_event.ogg")
)
except Exception as e:
logger.error("Failed to restore Bluesky followers buffers: %s", e)
try:
following = session.settings["other_buffers"].get("following_timelines")
if following is None:
following = []
if isinstance(following, str):
following = [t for t in following.split(",") if t]
for actor in following:
handle = actor
try:
if isinstance(actor, str) and actor.startswith("did:"):
profile = session.get_profile(actor)
if profile:
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
handle = g(profile, "handle") or actor
except Exception:
handle = actor
own_actor = session.db.get("user_id") or session.db.get("user_name")
own_handle = session.db.get("user_name")
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
continue
title = _("Following for {user}").format(user=handle)
pub.sendMessage(
"createBuffer",
buffer_type="FollowingBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=timelines_position,
start=False,
kwargs=dict(parent=controller.view.nb, name=f"{handle}-following", session=session, actor=actor, handle=handle, sound="new_event.ogg")
)
except Exception as e:
logger.error("Failed to restore Bluesky following buffers: %s", e)
# Start the background poller for real-time-like updates
try:
session.start_streaming()
except Exception as e:
logger.error("Failed to start Bluesky streaming for session %s: %s", name, e)
def start_buffer(self, controller, buffer):
"""Start a newly created Bluesky buffer."""
try:
if hasattr(buffer, "start_stream"):
buffer.start_stream(mandatory=True, play_sound=False)
# Enable periodic auto-refresh to simulate real-time updates
if hasattr(buffer, "enable_auto_refresh"):
buffer.enable_auto_refresh()
finally:
# Ensure we won't try to start it again
try:
buffer.needs_init = False
except Exception:
pass
def account_settings(self, buffer, controller):
"""Open a minimal account settings dialog for Bluesky."""
try:
current_mode = None
try:
current_mode = buffer.session.settings["general"].get("boost_mode")
except Exception:
current_mode = None
ask_default = True if current_mode in (None, "ask") else False
from wxUI.dialogs.blueski.configuration import AccountSettingsDialog
from .templateEditor import EditTemplate
dlg = AccountSettingsDialog(controller.view, ask_before_boost=ask_default)
try:
if buffer.session.settings.get("templates") is None:
buffer.session.settings["templates"] = {}
templates_cfg = buffer.session.settings.get("templates", {})
template_state = {
"post": templates_cfg.get("post", "$display_name, $reply_to$safe_text $date."),
"person": templates_cfg.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at."),
"notification": templates_cfg.get("notification", "$display_name $text, $date"),
}
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
def edit_post_template(*args, **kwargs):
control = EditTemplate(template=template_state["post"], type="post")
result = control.run_dialog()
if result:
buffer.session.settings["templates"]["post"] = result
buffer.session.settings.write()
template_state["post"] = result
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
def edit_person_template(*args, **kwargs):
control = EditTemplate(template=template_state["person"], type="person")
result = control.run_dialog()
if result:
buffer.session.settings["templates"]["person"] = result
buffer.session.settings.write()
template_state["person"] = result
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
def edit_notification_template(*args, **kwargs):
control = EditTemplate(template=template_state["notification"], type="notification")
result = control.run_dialog()
if result:
buffer.session.settings["templates"]["notification"] = result
buffer.session.settings.write()
template_state["notification"] = result
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
widgetUtils.connect_event(dlg.template_post, widgetUtils.BUTTON_PRESSED, edit_post_template)
widgetUtils.connect_event(dlg.template_person, widgetUtils.BUTTON_PRESSED, edit_person_template)
widgetUtils.connect_event(dlg.template_notification, widgetUtils.BUTTON_PRESSED, edit_notification_template)
except Exception as e:
logger.error("Failed to init Bluesky templates editor: %s", e)
resp = dlg.ShowModal()
if resp == wx.ID_OK:
vals = dlg.get_values()
boost_mode = "ask" if vals.get("ask_before_boost") else "direct"
try:
buffer.session.settings["general"]["boost_mode"] = boost_mode
buffer.session.settings.write()
except Exception as e:
logger.error("Failed to persist Bluesky boost_mode setting: %s", e)
dlg.Destroy()
except Exception as e:
logger.error("Error opening Bluesky account settings dialog: %s", e)
def user_details(self, buffer):
"""Show user profile dialog for the selected user/post."""
session = getattr(buffer, "session", None)
if not session:
output.speak(_("No active session to view user details."), True)
return
item = buffer.get_item() if hasattr(buffer, "get_item") else None
if not item:
output.speak(_("No user selected or identified to view details."), True)
return
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
user_ident = None
# If we're in a user list, the item itself is the user profile dict/model.
if g(item, "did") or g(item, "handle"):
user_ident = g(item, "did") or g(item, "handle")
else:
author = g(item, "author")
if not author:
post = g(item, "post") or g(item, "record")
author = g(post, "author") if post else None
if author:
user_ident = g(author, "did") or g(author, "handle")
if not user_ident:
output.speak(_("No user selected or identified to view details."), True)
return
parent = getattr(buffer, "buffer", None) or wx.GetApp().GetTopWindow()
dialog = ShowUserProfileDialog(parent, session, user_ident)
dialog.ShowModal()
dialog.Destroy()
async def handle_action(self, action_name: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
logger.debug("handle_action stub: %s %s %s", action_name, user_id, payload)
return None
async def handle_message_command(self, command: str, user_id: str, message_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
logger.debug("handle_message_command stub: %s %s %s %s", command, user_id, message_id, payload)
return None
async def handle_user_command(self, command: str, user_id: str, target_user_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
logger.debug("handle_user_command stub: %s %s %s %s", command, user_id, target_user_id, payload)
return None
def add_to_favourites(self, buffer):
"""Standard action for Alt+Win+F"""
if hasattr(buffer, "add_to_favorites"):
buffer.add_to_favorites()
elif hasattr(buffer, "on_like"):
# Fallback
buffer.on_like(None)
def remove_from_favourites(self, buffer):
"""Standard action for Alt+Shift+Win+F"""
if hasattr(buffer, "remove_from_favorites"):
buffer.remove_from_favorites()
elif hasattr(buffer, "on_like"):
buffer.on_like(None)
def follow(self, buffer):
"""Standard action for Ctrl+Win+S - Opens user actions dialog"""
if not hasattr(buffer, "get_item"):
return
session = getattr(buffer, "session", None)
if not session:
output.speak(_("No active session."), True)
return
item = buffer.get_item()
if not item:
return
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
users = []
buffer_type = getattr(buffer, "type", "")
if buffer_type in ("user", "post_user_list"):
# User buffer - item is a user object
handle = g(item, "handle")
if handle:
users = [handle]
elif buffer_type == "notifications":
# Notification buffer
author = g(item, "author")
if author:
handle = g(author, "handle")
if handle:
users.append(handle)
# Also check for post author in the notification subject
record = g(item, "record")
if record:
subject = g(record, "subject")
if subject:
subject_author = g(subject, "author")
if subject_author:
subject_handle = g(subject_author, "handle")
if subject_handle and subject_handle not in users:
users.append(subject_handle)
else:
# Post buffer - extract author and mentioned users
# Get the actual post (could be nested in "post" key)
actual_post = g(item, "post", item)
record = g(actual_post, "record") or {}
# Extract mentions from facets
facets = g(record, "facets") or []
for facet in facets:
features = g(facet, "features") or []
for feature in features:
ftype = g(feature, "$type") or g(feature, "py_type") or ""
if "mention" in ftype.lower():
mention_did = g(feature, "did")
# We'd need to resolve DID to handle, but for simplicity just skip
# The main author will be added below
# Get the post author
author = g(actual_post, "author") or g(item, "author")
if author:
handle = g(author, "handle")
if handle and handle not in users:
users.insert(0, handle)
# Ensure we have at least the author if no users found
if not users:
author = g(item, "author") or g(g(item, "post"), "author")
if author:
handle = g(author, "handle")
if handle:
users = [handle]
from controller.blueski import userActions as user_actions_controller
user_actions_controller.userActions(session, users)
def open_conversation(self, controller, buffer):
"""Standard action for Control+Win+C"""
# If this is a chat conversation list, open the selected chat
if buffer.type == "chat" and hasattr(buffer, "view_chat"):
buffer.view_chat()
return
item = buffer.get_item()
if not item:
return
uri = None
if hasattr(buffer, "get_selected_item_id"):
uri = buffer.get_selected_item_id()
if not uri:
uri = getattr(item, "uri", None) or (item.get("post", {}).get("uri") if isinstance(item, dict) else None)
if not uri: return
# Buffer Title
handle = None
display_name = None
if hasattr(buffer, "get_selected_item_author_details"):
details = buffer.get_selected_item_author_details()
if details:
handle = details.get("handle")
if not handle:
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
author = g(item, "author") or g(g(item, "post"), "author")
if author:
handle = g(author, "handle")
display_name = g(author, "displayName") or g(author, "display_name")
label = handle or display_name or _("Unknown")
title = _("Conversation with {0}").format(label)
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="conversation",
session_type="blueski",
buffer_title=title,
parent_tab=controller.view.search(buffer.session.get_name(), buffer.session.get_name()) if hasattr(buffer.session, "get_name") else None,
start=True,
kwargs=dict(parent=controller.view.nb, name=title, session=buffer.session, uri=uri, sound="search_updated.ogg")
)
def open_timeline(self, controller, buffer, default="posts"):
if not hasattr(buffer, "get_item"):
return
item = buffer.get_item()
if not item:
output.speak(_("No user selected."), True)
return
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
users = []
handle = None
if hasattr(buffer, "get_selected_item_author_details"):
details = buffer.get_selected_item_author_details()
if details:
handle = details.get("handle") or details.get("did")
if not handle:
if g(item, "handle") or g(item, "did"):
handle = g(item, "handle") or g(item, "did")
else:
author = g(item, "author") or g(g(item, "post"), "author")
if author:
handle = g(author, "handle") or g(author, "did")
if not handle:
output.speak(_("No user selected."), True)
return
users.append(handle)
# Add mentioned users if available (facets)
record = g(g(item, "post"), "record") or g(item, "record")
facets = g(record, "facets", []) if record else []
handle_cache = {}
def resolve_handle(did):
if did in handle_cache:
return handle_cache[did]
try:
profile = buffer.session.get_profile(did)
if profile:
h = g(profile, "handle")
if h:
handle_cache[did] = h
return h
except Exception:
pass
return None
self_did = buffer.session.db.get("user_id")
for facet in facets or []:
features = g(facet, "features", []) or []
for feat in features:
ftype = g(feat, "$type") or g(feat, "py_type") or ""
if "facet#mention" in ftype:
did = g(feat, "did")
if not did or did == self_did:
continue
h = resolve_handle(did)
if h and h not in users:
users.append(h)
from wxUI.dialogs.mastodon import userTimeline as userTimelineDialog
dlg = userTimelineDialog.UserTimeline(users=users, default=default)
try:
widgetUtils.connect_event(
dlg.autocompletion,
widgetUtils.BUTTON_PRESSED,
lambda *args, **kwargs: completion.autocompletionUsers(dlg, buffer.session.session_id).show_menu("free"),
)
except Exception:
pass
try:
if hasattr(dlg, "autocompletion"):
dlg.autocompletion.Enable(True)
except Exception:
pass
if dlg.ShowModal() != wx.ID_OK:
dlg.Destroy()
return
action = dlg.get_action()
user = dlg.get_user().strip() or handle
dlg.Destroy()
if user.startswith("@"):
user = user[1:]
try:
profile = buffer.session.get_profile(user)
if profile is None:
output.speak(_("User not found."), True)
return
except Exception:
pass
user_payload = {"handle": user}
if action == "posts":
result = self.open_user_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
elif action == "followers":
result = self.open_followers_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
elif action == "following":
result = self.open_following_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
else:
return
if asyncio.iscoroutine(result):
call_threaded(asyncio.run, result)
def open_followers_timeline(self, main_controller, session, user_payload=None):
actor, handle = self._resolve_actor(session, user_payload)
if not actor:
output.speak(_("No user selected."), True)
return
self._open_user_list(main_controller, session, actor, handle, list_type="followers")
def open_following_timeline(self, main_controller, session, user_payload=None):
actor, handle = self._resolve_actor(session, user_payload)
if not actor:
output.speak(_("No user selected."), True)
return
self._open_user_list(main_controller, session, actor, handle, list_type="following")
def open_user_timeline(self, main_controller, session, user_payload=None):
"""Open posts timeline for a user (Alt+Win+I)."""
actor, handle = self._resolve_actor(session, user_payload)
if not actor:
output.speak(_("No user selected."), True)
return
actor, handle = self._resolve_actor(session, {"did": actor, "handle": handle})
if not handle:
handle = actor
account_name = session.get_name()
list_name = f"{handle}-timeline"
if main_controller.search_buffer(list_name, account_name):
index = main_controller.view.search(list_name, account_name)
if index is not None:
main_controller.view.change_buffer(index)
return
title = _("Timeline for {user}").format(user=handle)
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="UserTimeline",
session_type="blueski",
buffer_title=title,
parent_tab=main_controller.view.search("timelines", account_name),
start=True,
kwargs=dict(parent=main_controller.view.nb, name=list_name, session=session, actor=actor, handle=handle, sound="tweet_timeline.ogg")
)
try:
timelines = session.settings["other_buffers"].get("timelines")
if timelines is None:
timelines = []
if isinstance(timelines, str):
timelines = [t for t in timelines.split(",") if t]
key = actor or handle
if key in timelines:
from wxUI import commonMessageDialogs
commonMessageDialogs.timeline_exist()
return
if key:
timelines.append(key)
session.settings["other_buffers"]["timelines"] = timelines
session.settings.write()
except Exception as e:
logger.error("Failed to persist Bluesky timeline buffer: %s", e)
def _resolve_actor(self, session, user_payload):
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
actor = None
handle = None
if user_payload:
actor = g(user_payload, "did") or g(user_payload, "handle")
handle = g(user_payload, "handle") or g(user_payload, "did")
if isinstance(actor, str):
actor = actor.strip()
if actor.startswith("@"):
actor = actor[1:]
if isinstance(handle, str):
handle = handle.strip()
if handle.startswith("@"):
handle = handle[1:]
# Resolve handle -> DID when possible, and keep handle for titles
try:
if isinstance(actor, str) and not actor.startswith("did:"):
profile = session.get_profile(actor)
if profile:
did = g(profile, "did")
if did:
actor = did
if not handle:
handle = g(profile, "handle")
except Exception:
pass
if not actor:
actor = session.db.get("user_id") or session.db.get("user_name")
handle = session.db.get("user_name") or actor
if not handle and isinstance(actor, str):
try:
if actor.startswith("did:"):
profile = session.get_profile(actor)
if profile:
handle = g(profile, "handle")
except Exception:
pass
return actor, handle
def _open_user_list(self, main_controller, session, actor, handle, list_type):
account_name = session.get_name()
if not handle:
handle = actor
own_actor = session.db.get("user_id") or session.db.get("user_name")
own_handle = session.db.get("user_name")
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
name = "followers" if list_type == "followers" else "following"
try:
stored = session.settings["other_buffers"].get("followers_timelines" if list_type == "followers" else "following_timelines") or []
if isinstance(stored, str):
stored = [t for t in stored.split(",") if t]
if actor in stored:
stored.remove(actor)
session.settings["other_buffers"]["followers_timelines" if list_type == "followers" else "following_timelines"] = stored
session.settings.write()
except Exception:
pass
index = main_controller.view.search(name, account_name)
if index is not None:
main_controller.view.change_buffer(index)
return
list_name = f"{handle}-{list_type}"
if main_controller.search_buffer(list_name, account_name):
index = main_controller.view.search(list_name, account_name)
if index is not None:
main_controller.view.change_buffer(index)
return
settings_key = "followers_timelines" if list_type == "followers" else "following_timelines"
try:
stored = session.settings["other_buffers"].get(settings_key)
if stored is None:
stored = []
if isinstance(stored, str):
stored = [t for t in stored.split(",") if t]
key = actor or handle
if key in stored:
from wxUI import commonMessageDialogs
commonMessageDialogs.timeline_exist()
return
except Exception:
stored = None
title = _("Followers for {user}").format(user=handle) if list_type == "followers" else _("Following for {user}").format(user=handle)
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="FollowersBuffer" if list_type == "followers" else "FollowingBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=main_controller.view.search("timelines", account_name),
start=True,
kwargs=dict(parent=main_controller.view.nb, name=list_name, session=session, actor=actor, handle=handle, sound="new_event.ogg")
)
try:
if stored is None:
stored = session.settings["other_buffers"].get(settings_key) or []
if isinstance(stored, str):
stored = [t for t in stored.split(",") if t]
key = actor or handle
if key:
stored.append(key)
session.settings["other_buffers"][settings_key] = stored
session.settings.write()
except Exception as e:
logger.error("Failed to persist Bluesky %s buffer: %s", list_type, e)
def delete(self, buffer, controller):
"""Standard action for delete key / menu item"""
item = buffer.get_item()
if not item: return
uri = getattr(item, "uri", None) or (item.get("post", {}).get("uri") if isinstance(item, dict) else None)
if not uri: return
import wx
if wx.MessageBox(_("Are you sure you want to delete this post?"), _("Delete post"), wx.YES_NO | wx.ICON_QUESTION) == wx.YES:
if buffer.session.delete_post(uri):
import output
output.speak(_("Post deleted."))
# Refresh buffer
if hasattr(buffer, "start_stream"):
buffer.start_stream(mandatory=True, play_sound=False)
else:
import output
output.speak(_("Failed to delete post."))
def search(self, controller, session, value=""):
"""Open search dialog and create search buffer for results."""
dlg = wx.TextEntryDialog(
controller.view,
_("Enter search term:"),
_("Search Bluesky"),
value
)
if dlg.ShowModal() != wx.ID_OK:
dlg.Destroy()
return
query = dlg.GetValue().strip()
dlg.Destroy()
if not query:
return
# Create unique buffer name for this search
buffer_name = f"search_{query[:20]}"
account_name = session.get_name()
# Check if buffer already exists
existing = controller.search_buffer(buffer_name, account_name)
if existing:
# Navigate to existing buffer
index = controller.view.search(buffer_name, account_name)
if index is not None:
controller.view.change_buffer(index)
# Refresh search
existing.search_query = query
existing.start_stream(mandatory=True, play_sound=False)
return
# Create new search buffer
title = _("Search: {query}").format(query=query)
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="SearchBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=controller.view.search("searches", account_name),
start=True,
kwargs=dict(
parent=controller.view.nb,
name=buffer_name,
session=session,
query=query,
sound="search_updated.ogg"
)
)
# Save search to settings for persistence
try:
searches = session.settings["other_buffers"].get("searches")
if searches is None:
searches = []
if isinstance(searches, str):
searches = [s for s in searches.split(",") if s]
if query not in searches:
searches.append(query)
session.settings["other_buffers"]["searches"] = searches
session.settings.write()
except Exception as e:
logger.error("Failed to save search to settings: %s", e)

View File

@@ -0,0 +1,433 @@
from __future__ import annotations
import logging
from typing import Any
import arrow
import languageHandler
import output
import widgetUtils
from controller import messages as base_messages
from sessions.blueski import utils as bluesky_utils
from wxUI.dialogs.blueski import postDialogs
from extra.autocompletionUsers import completion
# Translation function is provided globally by TWBlue's language handler (_)
logger = logging.getLogger(__name__)
# This file would typically contain functions to generate complex message bodies or
# interactive components for Blueski, similar to how it might be done for Mastodon.
# Since Blueski's interactive features (beyond basic posts) are still evolving
# or client-dependent (like polls), this might be less complex initially.
# Example: If Blueski develops a standard for "cards" or interactive messages,
# functions to create those would go here. For now, we can imagine placeholders.
def format_welcome_message(session: Any) -> dict[str, Any]:
"""
Generates a welcome message for a new Blueski session.
This is just a placeholder and example.
"""
# user_profile = session.util.get_own_profile_info() # Assuming this method exists and is async or cached
# handle = user_profile.get("handle", _("your Blueski account")) if user_profile else _("your Blueski account")
# Expect session to expose username via db/settings
handle = (getattr(session, "db", {}).get("user_name")
or getattr(getattr(session, "settings", {}), "get", lambda *_: {})("blueski").get("handle")
or _("your Bluesky account"))
return {
"text": _("Welcome to Approve for Blueski! Your account {handle} is connected.").format(handle=handle),
# "blocks": [ # If Blueski supports a block kit like Slack or Discord
# {
# "type": "section",
# "text": {
# "type": "mrkdwn", # Or Blueski's equivalent
# "text": _("Welcome to Approve for Blueski! Your account *{handle}* is connected.").format(handle=handle)
# }
# },
# {
# "type": "actions",
# "elements": [
# {
# "type": "button",
# "text": {"type": "plain_text", "text": _("Post your first Skeet")},
# "action_id": "blueski_compose_new_post" # Example action ID
# }
# ]
# }
# ]
}
def format_error_message(error_description: str, details: str | None = None) -> dict[str, Any]:
"""
Generates a standardized error message.
"""
message = {"text": f":warning: Error: {error_description}"} # Basic text message
# if details:
# message["blocks"] = [
# {
# "type": "section",
# "text": {"type": "mrkdwn", "text": f":warning: *Error:* {error_description}\n{details}"}
# }
# ]
return message
# More functions could be added here as Blueski's capabilities become clearer
# or as specific formatting needs for Approve arise. For example:
# - Formatting a post for display with all its embeds and cards.
# - Generating help messages specific to Blueski features.
# - Creating interactive messages for polls (if supported via some convention).
# Example of adapting a function that might exist in mastodon_messages:
# def build_post_summary_message(session: BlueskiSession, post_uri: str, post_content: dict) -> dict[str, Any]:
# """
# Builds a summary message for an Blueski post.
# """
# author_handle = post_content.get("author", {}).get("handle", "Unknown user")
# text_preview = post_content.get("text", "")[:100] # First 100 chars of text
# # url = session.get_message_url(post_uri) # Assuming this method exists
# url = f"https://bsky.app/profile/{author_handle}/post/{post_uri.split('/')[-1]}" # Construct a URL
# return {
# "text": _("Post by {author_handle}: {text_preview}... ({url})").format(
# author_handle=author_handle, text_preview=text_preview, url=url
# ),
# # Potentially with "blocks" for richer formatting if the platform supports it
# }
logger.info("Blueski messages module loaded (placeholders).")
class post(base_messages.basicMessage):
# Bluesky character limit
MAX_CHARS = 300
def __init__(self, session: Any, title: str, caption: str, text: str = "", *args, **kwargs):
self.session = session
self.title = title
langs = session.supported_languages
display_langs = [l.name for l in langs]
self.message = postDialogs.Post(caption=caption, text=text, languages=display_langs, *args, **kwargs)
try:
self.message.SetTitle(title)
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
except Exception:
pass
# Set default language
self.set_language(session.default_language)
# Connect events for text processing and buttons
widgetUtils.connect_event(self.message.text, widgetUtils.ENTERED_TEXT, self.text_processor)
widgetUtils.connect_event(self.message.spoiler, widgetUtils.ENTERED_TEXT, self.text_processor)
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
widgetUtils.connect_event(self.message.translate, widgetUtils.BUTTON_PRESSED, self.translate)
widgetUtils.connect_event(self.message.autocomplete_users, widgetUtils.BUTTON_PRESSED, self.autocomplete_users)
# Initial text processing to show character count
self.text_processor()
def set_language(self, language_code=None):
"""Set the language selection based on language code."""
if language_code is None:
language_code = languageHandler.curLang[:2]
for idx, lang in enumerate(self.session.supported_languages):
if lang.code == language_code:
self.message.language.SetSelection(idx)
return
# If not found, select first item (Not set)
self.message.language.SetSelection(0)
def get_language(self):
"""Get the selected language code."""
langs = self.session.supported_languages
idx = self.message.language.GetSelection()
if idx >= 0 and idx < len(langs):
return langs[idx].code
return None
def get_data(self):
text, files, cw_text, lang_index = self.message.get_payload()
langs = self.session.supported_languages
lang_code = None
if lang_index >= 0 and lang_index < len(langs):
lang_code = langs[lang_index].code
return text, files, cw_text, ([lang_code] if lang_code else [])
def text_processor(self, *args, **kwargs):
text = self.message.text.GetValue()
cw = self.message.spoiler.GetValue() if self.message.spoiler.IsEnabled() else ""
char_count = len(text) + len(cw)
self.message.SetTitle(_("%s - %s of %d characters") % (self.title, char_count, self.MAX_CHARS))
if char_count > self.MAX_CHARS:
self.session.sound.play("max_length.ogg")
def autocomplete_users(self, *args, **kwargs):
c = completion.autocompletionUsers(self.message, self.session.session_id)
c.show_menu()
def _g(obj: Any, key: str, default: Any = None) -> Any:
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
def has_post_data(item: Any) -> bool:
post = _g(item, "post")
record = _g(post, "record") if post is not None else None
if record is None:
record = _g(item, "record")
return record is not None or post is not None
def _extract_labels(obj: Any) -> list[dict[str, Any]]:
labels = _g(obj, "labels", None)
if labels is None:
return []
if isinstance(labels, dict):
labels = labels.get("values", [])
if isinstance(labels, list):
return labels
return []
def _extract_cw_text(post: Any, record: Any) -> str:
labels = _extract_labels(post) + _extract_labels(record)
for label in labels:
val = _g(label, "val", "")
if val == "warn":
return _("Sensitive Content")
if isinstance(val, str) and val.startswith("warn:"):
return val.split("warn:", 1)[-1].strip()
return ""
def _extract_image_descriptions(post: Any, record: Any) -> str:
def _collect_images(embed: Any) -> list[Any]:
if not embed:
return []
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
if "recordWithMedia" in etype:
media = _g(embed, "media")
mtype = _g(media, "$type") or _g(media, "py_type") or ""
if "images" in mtype:
return list(_g(media, "images", []) or [])
return []
if "images" in etype:
return list(_g(embed, "images", []) or [])
return []
images = []
images.extend(_collect_images(_g(post, "embed")))
if not images:
images.extend(_collect_images(_g(record, "embed")))
descriptions = []
for idx, img in enumerate(images, start=1):
alt = _g(img, "alt", "") or ""
if alt:
descriptions.append(_("Image {index}: {alt}").format(index=idx, alt=alt))
return "\n".join(descriptions)
def _format_date(raw_date: str | None, offset_hours: int = 0) -> str:
if not raw_date:
return ""
try:
ts = arrow.get(raw_date)
if offset_hours:
ts = ts.shift(hours=offset_hours)
return ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception:
return str(raw_date)[:16].replace("T", " ")
def _extract_post_view_data(session: Any, item: Any) -> dict[str, Any] | None:
post = _g(item, "post", item)
record = _g(post, "record") or _g(item, "record")
if record is None:
return None
author = _g(post, "author") or _g(item, "author") or {}
handle = _g(author, "handle", "")
display_name = _g(author, "displayName") or _g(author, "display_name") or handle or _("Unknown")
if handle and display_name != handle:
author_label = f"{display_name} (@{handle})"
elif handle:
author_label = f"@{handle}"
else:
author_label = display_name
text = _g(record, "text", "") or ""
reply_to_handle = bluesky_utils.extract_reply_to_handle(item)
if reply_to_handle:
if text:
text = _("Replying to @{handle}: {text}").format(handle=reply_to_handle, text=text)
else:
text = _("Replying to @{handle}").format(handle=reply_to_handle)
quote_info = bluesky_utils.extract_quoted_post_info(item)
if quote_info:
if quote_info["kind"] == "not_found":
text += f" [{_('Quoted post not found')}]"
elif quote_info["kind"] == "blocked":
text += f" [{_('Quoted post blocked')}]"
elif quote_info["kind"] == "feed":
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
else:
q_handle = quote_info.get("handle", "unknown")
q_text = quote_info.get("text", "")
if q_text:
text += " " + _("Quoting @{handle}: {text}").format(handle=q_handle, text=q_text)
else:
text += " " + _("Quoting @{handle}").format(handle=q_handle)
cw_text = _extract_cw_text(post, record)
if cw_text:
text = f"CW: {cw_text}\n\n{text}" if text else f"CW: {cw_text}"
created_at = _g(record, "createdAt") or _g(record, "created_at")
indexed_at = _g(post, "indexedAt") or _g(post, "indexed_at")
date = _format_date(created_at or indexed_at, offset_hours=_g(session.db, "utc_offset", 0))
reply_count = _g(post, "replyCount", 0) or 0
repost_count = _g(post, "repostCount", 0) or 0
like_count = _g(post, "likeCount", 0) or 0
uri = _g(post, "uri") or _g(item, "uri")
item_url = ""
if uri and handle:
rkey = uri.split("/")[-1]
item_url = f"https://bsky.app/profile/{handle}/post/{rkey}"
image_description = _extract_image_descriptions(post, record)
return {
"author": author_label,
"text": text,
"date": date,
"replies": reply_count,
"reposts": repost_count,
"likes": like_count,
"source": _("Bluesky"),
"privacy": _("Public"),
"image_description": image_description,
"item_url": item_url,
}
class viewPost(base_messages.basicMessage):
def __init__(self, session: Any, item: Any, controller: Any = None):
self.session = session
self.controller = controller
data = _extract_post_view_data(session, item)
if not data:
output.speak(_("No post available to view."), True)
return
self.post_uri = _g(_g(item, "post", item), "uri") or _g(item, "uri")
title = _("Post from {}").format(data["author"])
self.message = postDialogs.viewPost(
text=data["text"],
reposts_count=data["reposts"],
likes_count=data["likes"],
source=data["source"],
date=data["date"],
privacy=data["privacy"],
)
self.message.SetTitle(title)
if data["image_description"]:
self.message.image_description.Enable(True)
self.message.image_description.ChangeValue(data["image_description"])
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
if data["item_url"]:
self.message.enable_button("share")
self.item_url = data["item_url"]
widgetUtils.connect_event(self.message.share, widgetUtils.BUTTON_PRESSED, self.share)
if self.post_uri:
try:
self.message.reposts_button.Enable(True)
self.message.likes_button.Enable(True)
widgetUtils.connect_event(self.message.reposts_button, widgetUtils.BUTTON_PRESSED, self.on_reposts)
widgetUtils.connect_event(self.message.likes_button, widgetUtils.BUTTON_PRESSED, self.on_likes)
except Exception:
pass
self.message.ShowModal()
def text_processor(self):
pass
def share(self, *args, **kwargs):
if hasattr(self, "item_url"):
output.copy(self.item_url)
output.speak(_("Link copied to clipboard."))
def on_reposts(self, *args, **kwargs):
if not self.post_uri or not self.controller:
return
try:
controller = self.controller
account_name = self.session.get_name()
list_name = f"{self.post_uri}-reposts"
existing = controller.search_buffer(list_name, account_name)
if existing:
index = controller.view.search(list_name, account_name)
if index is not None:
controller.view.change_buffer(index)
return
title = _("people who reposted this post")
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="PostUserListBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=controller.view.search("timelines", account_name),
start=True,
kwargs=dict(parent=controller.view.nb, name=list_name, session=self.session,
post_uri=self.post_uri, api_method="get_post_reposts")
)
except Exception:
pass
def on_likes(self, *args, **kwargs):
if not self.post_uri or not self.controller:
return
try:
controller = self.controller
account_name = self.session.get_name()
list_name = f"{self.post_uri}-likes"
existing = controller.search_buffer(list_name, account_name)
if existing:
index = controller.view.search(list_name, account_name)
if index is not None:
controller.view.change_buffer(index)
return
title = _("people who liked this post")
from pubsub import pub
pub.sendMessage(
"createBuffer",
buffer_type="PostUserListBuffer",
session_type="blueski",
buffer_title=title,
parent_tab=controller.view.search("timelines", account_name),
start=True,
kwargs=dict(parent=controller.view.nb, name=list_name, session=self.session,
post_uri=self.post_uri, api_method="get_post_likes")
)
except Exception:
pass
class text(base_messages.basicMessage):
"""Simple text viewer dialog for OCR results and similar."""
def __init__(self, title, text="", *args, **kwargs):
self.title = title
self.message = postDialogs.viewText(title=title, text=text, *args, **kwargs)
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
def text_processor(self):
pass

View File

@@ -0,0 +1,128 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
fromapprove.forms import Form, SubmitField, TextAreaField, TextField
fromapprove.translation import translate as _
if TYPE_CHECKING:
fromapprove.config import ConfigSectionProxy
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
logger = logging.getLogger(__name__)
# This file is for defining forms and handling for Blueski-specific settings
# that might be more complex than simple key-value pairs handled by Session.get_settings_inputs.
# For Blueski, initial settings might be simple (handle, app password),
# but this structure allows for expansion.
class BlueskiSettingsForm(Form):
"""
A settings form for Blueski sessions.
This would mirror the kind of settings found in Session.get_settings_inputs
but using the WTForms-like Form structure for more complex validation or layout.
"""
# Example fields - these should align with what BlueskiSession.get_settings_inputs defines
# and what BlueskiSession.get_configurable_values expects for its config.
# instance_url = TextField(
# _("Instance URL"),
# default="https://bsky.social", # Default PDS for Bluesky
# description=_("The base URL of your Blueski PDS instance (e.g., https://bsky.social)."),
# validators=[], # Add validators if needed, e.g., URL validator
# )
handle = TextField(
_("Bluesky Handle"),
description=_("Your Bluesky user handle (e.g., @username.bsky.social or username.bsky.social)."),
validators=[], # e.g., DataRequired()
)
app_password = TextField( # Consider PasswordField if sensitive and your Form class supports it
_("App Password"),
description=_("Your Bluesky App Password. Generate this in your Bluesky account settings."),
validators=[], # e.g., DataRequired()
)
# Add more fields as needed for Blueski configuration.
# For example, if there were specific notification settings, content filters, etc.
submit = SubmitField(_("Save Blueski Settings"))
async def get_settings_form(
user_id: str,
session: BlueskiSession | None = None,
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
) -> BlueskiSettingsForm:
"""
Creates and pre-populates the Blueski settings form.
"""
form_data = {}
if session: # If a session exists, use its current config
# form_data["instance_url"] = session.config_get("api_base_url", "https://bsky.social")
form_data["handle"] = session.config_get("handle", "")
# App password should not be pre-filled for security.
form_data["app_password"] = ""
elif config: # Fallback to persisted config if no active session
# form_data["instance_url"] = config.api_base_url.get("https://bsky.social")
form_data["handle"] = config.handle.get("")
form_data["app_password"] = ""
form = BlueskiSettingsForm(formdata=None, **form_data) # formdata=None for initial display
return form
async def process_settings_form(
form: BlueskiSettingsForm,
user_id: str,
session: BlueskiSession | None = None, # Pass if update should affect live session
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
) -> bool:
"""
Processes the submitted Blueski settings form and updates configuration.
Returns True if successful, False otherwise.
"""
if not form.validate(): # Assuming form has a validate method
logger.warning(f"Blueski settings form validation failed for user {user_id}: {form.errors}")
return False
if not config and session: # Try to get config via session if not directly provided
# This depends on how ConfigSectionProxy is obtained.
# config = approve.config.config.sessions.blueski[user_id] # Example path
pass # Needs actual way to get config proxy
if not config:
logger.error(f"Cannot process Blueski settings for user {user_id}: no config proxy available.")
return False
try:
# Update the configuration values
# await config.api_base_url.set(form.instance_url.data)
await config.handle.set(form.handle.data)
await config.app_password.set(form.app_password.data) # Ensure this is stored securely
logger.info(f"Blueski settings updated for user {user_id}.")
# If there's an active session, it might need to be reconfigured or restarted
if session:
logger.info(f"Requesting Blueski session re-initialization for user {user_id} due to settings change.")
# await session.stop() # Stop it
# # Update session instance with new values directly or rely on it re-reading config
# session.api_base_url = form.instance_url.data
# session.handle = form.handle.data
# # App password should be handled carefully, session might need to re-login
# await session.start() # Restart with new settings
# Or, more simply, the session might have a reconfigure method:
# await session.reconfigure(new_settings_dict)
pass # Placeholder for session reconfiguration logic
return True
except Exception as e:
logger.error(f"Error saving Blueski settings for user {user_id}: {e}", exc_info=True)
return False
# Any additional Blueski-specific settings views or handlers would go here.
# For instance, if Blueski had features like "Relays" or "Feed Generators"
# that needed UI configuration within Approve, those forms and handlers could be defined here.
logger.info("Blueski settings module loaded (placeholders).")

View File

@@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
import re
import wx
from typing import List
from sessions.blueski.templates import post_variables, person_variables, notification_variables
from wxUI.dialogs import templateDialogs
class EditTemplate(object):
def __init__(self, template: str, type: str) -> None:
super(EditTemplate, self).__init__()
self.default_template = template
if type == "post":
self.variables = post_variables
elif type == "notification":
self.variables = notification_variables
else:
self.variables = person_variables
self.template: str = template
def validate_template(self, template: str) -> bool:
used_variables: List[str] = re.findall(r"\$\w+", template)
validated: bool = True
for var in used_variables:
if var[1:] not in self.variables:
validated = False
return validated
def run_dialog(self) -> str:
dialog = templateDialogs.EditTemplateDialog(
template=self.template,
variables=self.variables,
default_template=self.default_template,
)
response = dialog.ShowModal()
if response == wx.ID_SAVE:
validated: bool = self.validate_template(dialog.template.GetValue())
if validated == False:
templateDialogs.invalid_template()
self.template = dialog.template.GetValue()
return self.run_dialog()
else:
return dialog.template.GetValue()
else:
return ""

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
import logging
import widgetUtils
import output
from wxUI.dialogs.blueski import userActions as userActionsDialog
from extra.autocompletionUsers import completion
import languageHandler
log = logging.getLogger("controller.blueski.userActions")
class BasicUserSelector(object):
def __init__(self, session, users=None):
super(BasicUserSelector, self).__init__()
self.session = session
self.create_dialog(users=users or [])
def create_dialog(self, users):
pass
def resolve_profile(self, actor):
try:
return self.session.get_profile(actor)
except Exception:
log.exception("Error resolving Bluesky profile for %s.", actor)
return None
def autocomplete_users(self, *args, **kwargs):
c = completion.autocompletionUsers(self.dialog, self.session.session_id)
c.show_menu("free")
class userActions(BasicUserSelector):
def __init__(self, *args, **kwargs):
super(userActions, self).__init__(*args, **kwargs)
if self.dialog.get_response() == widgetUtils.OK:
self.process_action()
def create_dialog(self, users):
self.dialog = userActionsDialog.UserActionsDialog(users)
widgetUtils.connect_event(self.dialog.autocompletion, widgetUtils.BUTTON_PRESSED, self.autocomplete_users)
def process_action(self):
action = self.dialog.get_action()
actor = self.dialog.get_user().strip()
if not actor:
output.speak(_("No user specified."), True)
return
profile = self.resolve_profile(actor)
if not profile:
output.speak(_("User not found."), True)
return
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
did = g(profile, "did")
viewer = g(profile, "viewer") or {}
if not did:
output.speak(_("User identifier not available."), True)
return
if action == "follow":
if self.session.follow_user(did):
output.speak(_("Followed."))
else:
output.speak(_("Failed to follow user."), True)
elif action == "unfollow":
follow_uri = g(viewer, "following")
if not follow_uri:
output.speak(_("Follow information not available."), True)
return
if self.session.unfollow_user(follow_uri):
output.speak(_("Unfollowed."))
else:
output.speak(_("Failed to unfollow user."), True)
elif action == "mute":
if self.session.mute_user(did):
output.speak(_("Muted."))
else:
output.speak(_("Failed to mute user."), True)
elif action == "unmute":
if self.session.unmute_user(did):
output.speak(_("Unmuted."))
else:
output.speak(_("Failed to unmute user."), True)
elif action == "block":
if self.session.block_user(did):
output.speak(_("Blocked."))
else:
output.speak(_("Failed to block user."), True)
elif action == "unblock":
block_uri = g(viewer, "blocking")
if not block_uri:
output.speak(_("Block information not available."), True)
return
if self.session.unblock_user(block_uri):
output.speak(_("Unblocked."))
else:
output.speak(_("Failed to unblock user."), True)

View File

@@ -0,0 +1,309 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, AsyncGenerator
import widgetUtils
from wxUI.dialogs.blueski.showUserProfile import ShowUserProfileDialog
from controller.userList import UserListController
from controller.blueski import userActions as user_actions_controller
fromapprove.translation import translate as _
# fromapprove.controller.mastodon import userList as mastodon_user_list # If adapting
if TYPE_CHECKING:
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
# Define a type for what a user entry in a list might look like for Blueski
BlueskiUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."}
logger = logging.getLogger(__name__)
# This file is responsible for fetching and managing lists of users from Blueski.
# Examples include:
# - Followers of a user
# - Users a user is following
# - Users who liked or reposted a post
# - Users in a specific list or feed (if Blueski supports user lists like Twitter/Mastodon)
# - Search results for users
# The structure will likely involve:
# - A base class or functions for paginating through user lists from the Blueski API.
# - Specific functions for each type of user list.
# - Formatting Blueski user data into a consistent structure for UI display.
async def fetch_followers(
session: BlueskiSession,
user_id: str, # DID of the user whose followers to fetch
limit: int = 20,
cursor: str | None = None
) -> AsyncGenerator[BlueskiUserListItem, None]:
"""
Asynchronously fetches a list of followers for a given Blueski user.
user_id is the DID of the target user.
Yields user data dictionaries.
"""
# client = await session.util._get_client() # Get authenticated client
# if not client:
# logger.warning(f"Blueski client not available for fetching followers of {user_id}.")
# return
# current_cursor = cursor
# try:
# while True:
# # response = await client.app.bsky.graph.get_followers(
# # models.AppBskyGraphGetFollowers.Params(
# # actor=user_id,
# # limit=min(limit, 100), # ATProto API might have its own max limit per request (e.g. 100)
# # cursor=current_cursor
# # )
# # )
# # if not response or not response.followers:
# # break
# # for user_profile_view in response.followers:
# # yield session.util._format_profile_data(user_profile_view) # Use a utility to standardize format
# # current_cursor = response.cursor
# # if not current_cursor or len(response.followers) < limit : # Or however the API indicates end of list
# # break
# # This is a placeholder loop for demonstration
# if current_cursor == "simulated_end_cursor": break # Stop after one simulated page
# for i in range(limit):
# if current_cursor and int(current_cursor) + i >= 25: # Simulate total 25 followers
# current_cursor = "simulated_end_cursor"
# break
# yield {
# "did": f"did:plc:follower{i + (int(current_cursor) if current_cursor else 0)}",
# "handle": f"follower{i + (int(current_cursor) if current_cursor else 0)}.bsky.social",
# "displayName": f"Follower {i + (int(current_cursor) if current_cursor else 0)}",
# "avatar": None # Placeholder
# }
# if not current_cursor: current_cursor = str(limit) # Simulate next cursor
# elif current_cursor != "simulated_end_cursor": current_cursor = str(int(current_cursor) + limit)
"""
if not session.is_ready():
logger.warning(f"Cannot fetch followers for {user_id}: Blueski session not ready.")
# yield {} # Stop iteration if not ready
return
try:
followers_data = await session.util.get_followers(user_did=user_id, limit=limit, cursor=cursor)
if followers_data:
users, _ = followers_data # We'll return the cursor separately via the calling HTTP handler
for user_profile_view in users:
yield session.util._format_profile_data(user_profile_view)
else:
logger.info(f"No followers data returned for user {user_id}.")
except Exception as e:
logger.error(f"Error in fetch_followers for Blueski user {user_id}: {e}", exc_info=True)
# Depending on desired error handling, could raise or yield an error marker
async def fetch_following(
session: BlueskiSession,
user_id: str, # DID of the user whose followed accounts to fetch
limit: int = 20,
cursor: str | None = None
) -> AsyncGenerator[BlueskiUserListItem, None]:
"""
Asynchronously fetches a list of users followed by a given Blueski user.
Yields user data dictionaries.
"""
if not session.is_ready():
logger.warning(f"Cannot fetch following for {user_id}: Blueski session not ready.")
return
try:
following_data = await session.util.get_following(user_did=user_id, limit=limit, cursor=cursor)
if following_data:
users, _ = following_data
for user_profile_view in users:
yield session.util._format_profile_data(user_profile_view)
else:
logger.info(f"No following data returned for user {user_id}.")
except Exception as e:
logger.error(f"Error in fetch_following for Blueski user {user_id}: {e}", exc_info=True)
async def search_users(
session: BlueskiSession,
query: str,
limit: int = 20,
cursor: str | None = None
) -> AsyncGenerator[BlueskiUserListItem, None]:
"""
Searches for users on Blueski based on a query string.
Yields user data dictionaries.
"""
if not session.is_ready():
logger.warning(f"Cannot search users for '{query}': Blueski session not ready.")
return
try:
search_data = await session.util.search_users(term=query, limit=limit, cursor=cursor)
if search_data:
users, _ = search_data
for user_profile_view in users:
yield session.util._format_profile_data(user_profile_view)
else:
logger.info(f"No users found for search term '{query}'.")
except Exception as e:
logger.error(f"Error in search_users for Blueski query '{query}': {e}", exc_info=True)
# This function is designed to be called by an API endpoint that returns JSON
async def get_user_list_paginated(
session: BlueskiSession,
list_type: str, # "followers", "following", "search"
identifier: str, # User DID for followers/following, or search query for search
limit: int = 20,
cursor: str | None = None
) -> tuple[list[BlueskiUserListItem], str | None]:
"""
Fetches a paginated list of users (followers, following, or search results)
and returns the list and the next cursor.
"""
users_list: list[BlueskiUserListItem] = []
next_cursor: str | None = None
if not session.is_ready():
logger.warning(f"Cannot fetch user list '{list_type}': Blueski session not ready.")
return [], None
try:
if list_type == "followers":
data = await session.util.get_followers(user_did=identifier, limit=limit, cursor=cursor)
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
elif list_type == "following":
data = await session.util.get_following(user_did=identifier, limit=limit, cursor=cursor)
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
elif list_type == "search_users":
data = await session.util.search_users(term=identifier, limit=limit, cursor=cursor)
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
else:
logger.error(f"Unknown list_type: {list_type}")
return [], None
except Exception as e:
logger.error(f"Error fetching paginated user list '{list_type}' for '{identifier}': {e}", exc_info=True)
# Optionally re-raise or return empty with no cursor to indicate error
return [], None
return users_list, next_cursor
async def get_user_profile_details(session: BlueskiSession, user_ident: str) -> BlueskiUserListItem | None:
"""
Fetches detailed profile information for a user by DID or handle.
Returns a dictionary of formatted profile data, or None if not found/error.
"""
if not session.is_ready():
logger.warning(f"Cannot get profile for {user_ident}: Blueski session not ready.")
return None
try:
profile_view_detailed = await session.util.get_user_profile(user_ident=user_ident)
if profile_view_detailed:
return session.util._format_profile_data(profile_view_detailed)
else:
logger.info(f"No profile data found for user {user_ident}.")
return None
except Exception as e:
logger.error(f"Error in get_user_profile_details for {user_ident}: {e}", exc_info=True)
return None
# Other list types could include:
# - fetch_likers(session, post_uri, limit, cursor) # Needs app.bsky.feed.getLikes
# - fetch_reposters(session, post_uri, limit, cursor)
# - fetch_muted_users(session, limit, cursor)
# - fetch_blocked_users(session, limit, cursor)
# The UI part of Approve that displays user lists would call these functions.
# Each function needs to handle pagination as provided by the ATProto API (usually cursor-based).
logger.info("Blueski userList module loaded (placeholders).")
class BlueskyUserList(UserListController):
def __init__(self, users, session, title, fetch_fn=None, cursor=None):
self.session = session
self.users = self.process_users(users)
self._fetch_fn = fetch_fn
self._cursor = cursor
from wxUI.dialogs import userList
self.dialog = userList.UserListDialog(title=title, users=[user.get("display_name") for user in self.users])
widgetUtils.connect_event(self.dialog.actions_button, widgetUtils.BUTTON_PRESSED, self.on_actions)
widgetUtils.connect_event(self.dialog.details_button, widgetUtils.BUTTON_PRESSED, self.on_details)
self._enable_pagination()
self.dialog.ShowModal()
def process_users(self, users):
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
processed = []
for item in users or []:
actor = g(item, "actor") or g(item, "user") or item
did = g(actor, "did")
handle = g(actor, "handle")
display_name = g(actor, "displayName") or g(actor, "display_name") or handle or "Unknown"
label = f"{display_name} (@{handle})" if handle and display_name != handle else (f"@{handle}" if handle else display_name)
processed.append(dict(did=did, handle=handle, display_name=label))
return processed
def on_actions(self, *args, **kwargs):
idx = self.dialog.user_list.GetSelection()
if idx < 0 or idx >= len(self.users):
return
handle = self.users[idx].get("handle")
if not handle:
return
user_actions_controller.userActions(self.session, [handle])
def on_details(self, *args, **kwargs):
idx = self.dialog.user_list.GetSelection()
if idx < 0 or idx >= len(self.users):
return
user_ident = self.users[idx].get("did") or self.users[idx].get("handle")
if not user_ident:
return
dlg = ShowUserProfileDialog(self.dialog, self.session, user_ident)
dlg.ShowModal()
dlg.Destroy()
def _enable_pagination(self):
if not self._fetch_fn:
return
if not self._cursor:
return
self.dialog.load_more_button.Show()
widgetUtils.connect_event(self.dialog.load_more_button, widgetUtils.BUTTON_PRESSED, self.load_more)
self.dialog.Layout()
def load_more(self, *args, **kwargs):
if not self._fetch_fn:
return
if not self._cursor:
self.dialog.load_more_button.Disable()
return
try:
res = self._fetch_fn(cursor=self._cursor)
items = res.get("items", []) if isinstance(res, dict) else []
self._cursor = res.get("cursor") if isinstance(res, dict) else None
new_users = self.process_users(items)
if not new_users:
self.dialog.load_more_button.Disable()
return
self.users.extend(new_users)
self.dialog.add_users([u.get("display_name") for u in new_users])
if not self._cursor:
self.dialog.load_more_button.Disable()
except Exception:
self.dialog.load_more_button.Disable()

View File

@@ -10,7 +10,7 @@ from . import base
log = logging.getLogger("controller.buffers.base.account")
class AccountBuffer(base.Buffer):
def __init__(self, parent, name, account, account_id):
def __init__(self, parent, name, account, account_id, session=None):
super(AccountBuffer, self).__init__(parent, None, name)
log.debug("Initializing buffer %s, account %s" % (name, account,))
self.buffer = buffers.accountPanel(parent, name)
@@ -53,4 +53,4 @@ class AccountBuffer(base.Buffer):
else:
self.buffer.change_autostart(False)
config.app["sessions"]["ignored_sessions"].append(self.account_id)
config.app.write()
config.app.write()

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from .timeline import (
HomeTimeline,
FollowingTimeline,
NotificationBuffer,
Conversation,
LikesBuffer,
MentionsBuffer,
SentBuffer,
UserTimeline,
SearchBuffer,
)
from .user import FollowersBuffer, FollowingBuffer, BlocksBuffer, PostUserListBuffer
from .chat import ConversationListBuffer, ChatBuffer as ChatMessageBuffer

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,559 @@
# -*- coding: utf-8 -*-
import logging
import wx
import widgetUtils
import output
from .base import BaseBuffer
from controller.blueski import messages as blueski_messages
from wxUI.buffers.blueski import panels as BlueskiPanels
from sessions.blueski import compose
from mysc.thread_utils import call_threaded
log = logging.getLogger("controller.buffers.blueski.chat")
class ConversationListBuffer(BaseBuffer):
"""Buffer for listing conversations, similar to Mastodon's ConversationListBuffer."""
def __init__(self, *args, **kwargs):
kwargs["compose_func"] = "compose_convo"
super(ConversationListBuffer, self).__init__(*args, **kwargs)
self.type = "chat"
self.sound = "dm_received.ogg"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.ChatPanel(parent, name)
self.buffer.session = self.session
def bind_events(self):
"""Bind events like Mastodon's ConversationListBuffer."""
self.buffer.set_focus_function(self.onFocus)
widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event)
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_ITEM_RIGHT_CLICK, self.show_menu)
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_KEY_DOWN, self.show_menu_by_key)
# Buttons
if hasattr(self.buffer, "post"):
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.on_post, self.buffer.post)
if hasattr(self.buffer, "reply"):
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.reply, self.buffer.reply)
if hasattr(self.buffer, "new_chat"):
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.on_new_chat, self.buffer.new_chat)
def get_item(self):
"""Get the last message from the selected conversation (like Mastodon)."""
index = self.buffer.list.get_selected()
if index > -1 and self.session.db.get(self.name) is not None and len(self.session.db[self.name]) > index:
convo = self.session.db[self.name][index]
# Return lastMessage for compatibility with item-based operations
last_msg = getattr(convo, "lastMessage", None) or (convo.get("lastMessage") if isinstance(convo, dict) else None)
return last_msg
return None
def get_conversation(self):
"""Get the full conversation object."""
index = self.buffer.list.get_selected()
if index > -1 and self.session.db.get(self.name) is not None and len(self.session.db[self.name]) > index:
return self.session.db[self.name][index]
return None
def get_convo_id(self, conversation):
"""Extract conversation ID from a conversation object, handling different field names."""
if not conversation:
return None
# Try different possible field names for the conversation ID
for attr in ("id", "convo_id", "convoId"):
val = getattr(conversation, attr, None)
if val:
return val
if isinstance(conversation, dict) and attr in conversation:
return conversation[attr]
return None
def _get_members_key(self, conversation):
"""Fallback key when convo id is missing: stable member DID list."""
members = getattr(conversation, "members", None) or (conversation.get("members") if isinstance(conversation, dict) else None) or []
dids = []
for m in members:
did = getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)
if did:
dids.append(did)
if not dids:
return None
dids.sort()
return tuple(dids)
def _get_last_message_key(self, conversation):
"""Key for detecting conversation updates based on last message."""
last_msg = getattr(conversation, "lastMessage", None) or (conversation.get("lastMessage") if isinstance(conversation, dict) else None)
if not last_msg:
return None
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
for attr in ("id", "messageId", "message_id", "msgId", "msg_id", "cid", "rev"):
val = g(last_msg, attr)
if val:
return str(val)
nested = g(last_msg, "message") or g(last_msg, "record")
if nested:
for attr in ("id", "messageId", "message_id", "msgId", "msg_id", "cid", "rev"):
val = g(nested, attr)
if val:
return str(val)
sent_at = g(last_msg, "sentAt") or g(last_msg, "sent_at") or g(last_msg, "createdAt") or g(last_msg, "created_at")
sender = g(last_msg, "sender") or (g(nested, "sender") if nested else {}) or {}
sender_did = g(sender, "did")
text = g(last_msg, "text") or (g(nested, "text") if nested else None)
if sent_at or sender_did or text:
return (str(sent_at) if sent_at else None, str(sender_did) if sender_did else None, str(text) if text else None)
return None
def get_formatted_message(self):
"""Return last message text for current conversation."""
conversation = self.get_conversation()
if not conversation:
return None
return self.compose_function(
conversation,
self.session.db,
self.session.settings,
self.session.settings["general"].get("relative_times", False),
self.session.settings["general"].get("show_screen_names", False),
)[1]
def get_message(self):
"""Return a readable summary for the selected conversation."""
conversation = self.get_conversation()
if not conversation:
return None
composed = self.compose_function(
conversation,
self.session.db,
self.session.settings,
self.session.settings["general"].get("relative_times", False),
self.session.settings["general"].get("show_screen_names", False),
)
return " ".join(composed)
def on_new_chat(self, *args, **kwargs):
"""Start a new conversation by entering a handle."""
dlg = wx.TextEntryDialog(None, _("Enter the handle of the user (e.g., user.bsky.social):"), _("New Chat"))
if dlg.ShowModal() == wx.ID_OK:
handle = dlg.GetValue().strip()
if handle:
if handle.startswith("@"):
handle = handle[1:]
def do_create():
try:
# Resolve handle to DID
profile = self.session.get_profile(handle)
if not profile:
wx.CallAfter(output.speak, _("User not found."), True)
return
did = getattr(profile, "did", None) or (profile.get("did") if isinstance(profile, dict) else None)
if not did:
wx.CallAfter(output.speak, _("Could not get user ID."), True)
return
# Get or create conversation
convo = self.session.get_or_create_convo([did])
if not convo:
wx.CallAfter(output.speak, _("Could not create conversation."), True)
return
convo_id = self.get_convo_id(convo)
user_handle = getattr(profile, "handle", None) or (profile.get("handle") if isinstance(profile, dict) else None) or handle
title = _("Chat: {0}").format(user_handle)
# Create the buffer under direct_messages node
wx.CallAfter(self._create_chat_buffer, self.controller, title, convo_id)
# Refresh conversation list
wx.CallAfter(self.start_stream, True, False)
except Exception:
log.exception("Error creating new conversation")
wx.CallAfter(output.speak, _("Error creating conversation."), True)
call_threaded(do_create)
dlg.Destroy()
def start_stream(self, mandatory=False, play_sound=True, avoid_autoreading=False):
count = self.get_max_items()
try:
res = self.session.list_convos(limit=count)
items = res.get("items", [])
self._build_member_maps(items)
return self._merge_conversations(items, play_sound, avoid_autoreading=avoid_autoreading)
except Exception:
log.exception("Error fetching conversations")
output.speak(_("Error loading conversations."), True)
return 0
def _build_member_maps(self, convos):
"""Build DID→name maps from conversation members and store in db for chat buffers."""
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
for convo in convos:
convo_id = self.get_convo_id(convo)
if not convo_id:
continue
members = g(convo, "members", []) or []
member_map = {}
for m in members:
did = g(m, "did", None)
if did:
name = g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
member_map[did] = name
if member_map:
self.session.db["convo_" + str(convo_id) + "_members"] = member_map
def _merge_conversations(self, items, play_sound=True, avoid_autoreading=False):
"""Merge conversation list, updating items without duplicating or re-alerting."""
if self.session.db.get(self.name) is None:
self.session.db[self.name] = []
# Track current selection to restore after refresh
selected_key = None
current_convo = self.get_conversation()
if current_convo:
selected_key = self.get_convo_id(current_convo) or self._get_members_key(current_convo)
existing = {}
existing_last = {}
for convo in self.session.db[self.name]:
key = self.get_convo_id(convo) or self._get_members_key(convo)
if key is None:
continue
existing[key] = convo
existing_last[key] = self._get_last_message_key(convo)
new_db = []
new_count = 0
first_load = len(self.session.db[self.name]) == 0
for convo in items:
key = self.get_convo_id(convo) or self._get_members_key(convo)
new_db.append(convo)
if key is None:
if first_load:
new_count += 1
continue
if key not in existing:
new_count += 1
continue
if self._get_last_message_key(convo) != existing_last.get(key):
new_count += 1
# Replace DB with latest ordered list from API
self.session.db[self.name] = new_db
# Rebuild list UI to keep ordering consistent with API
self.buffer.list.clear()
safe = True
relative_times = self.session.settings["general"].get("relative_times", False)
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
for convo in new_db:
row = self.compose_function(convo, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe)
self.buffer.list.insert_item(False, *row)
# Restore selection if possible
if selected_key is not None:
for idx, convo in enumerate(new_db):
key = self.get_convo_id(convo) or self._get_members_key(convo)
if key == selected_key:
self.buffer.list.select_item(idx)
break
# Sound and auto-read only when something actually changed
if new_count > 0:
if play_sound and self.sound and not self.session.settings["sound"].get("session_mute", False):
self.session.sound.play(self.sound)
if not avoid_autoreading:
self.auto_read(new_count)
return new_count
def fav(self):
pass
def unfav(self):
pass
def can_share(self):
return False
def url(self, *args, **kwargs):
"""Enter key opens the chat conversation buffer."""
self.view_chat()
def send_message(self, *args, **kwargs):
"""Global DM shortcut - reply to conversation."""
return self.reply()
def reply(self, *args, **kwargs):
"""Reply to the selected conversation (like Mastodon)."""
conversation = self.get_conversation()
if not conversation:
output.speak(_("No conversation selected."), True)
return
convo_id = self.get_convo_id(conversation)
if not convo_id:
log.error("Could not get conversation ID from conversation object")
output.speak(_("Could not identify conversation."), True)
return
# Get participants for title
members = getattr(conversation, "members", []) or (conversation.get("members", []) if isinstance(conversation, dict) else [])
user_did = self.session.db.get("user_id")
others = [m for m in members if (getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)) != user_did]
if not others:
others = members
if others:
first_user = others[0]
username = getattr(first_user, "handle", None) or (first_user.get("handle") if isinstance(first_user, dict) else None) or "unknown"
else:
username = "unknown"
title = _("Conversation with {0}").format(username)
caption = _("Write your message here")
initial_text = "@{} ".format(username)
post = blueski_messages.post(session=self.session, title=title, caption=caption, text=initial_text)
if post.message.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = post.get_data()
if text:
def do_send():
try:
self.session.send_chat_message(convo_id, text)
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
wx.CallAfter(output.speak, _("Message sent."))
wx.CallAfter(self.start_stream, True, False)
except Exception:
log.exception("Error sending message")
wx.CallAfter(output.speak, _("Failed to send message."), True)
call_threaded(do_send)
if hasattr(post.message, "Destroy"):
post.message.Destroy()
def view_chat(self):
"""Open the conversation in a separate buffer (nested under Chats node)."""
conversation = self.get_conversation()
if not conversation:
output.speak(_("No conversation selected."), True)
return
convo_id = self.get_convo_id(conversation)
if not convo_id:
log.error("Could not get conversation ID from conversation object: %r", conversation)
output.speak(_("Could not identify conversation."), True)
return
# Determine participants names for title
members = getattr(conversation, "members", []) or (conversation.get("members", []) if isinstance(conversation, dict) else [])
user_did = self.session.db.get("user_id")
others = [m for m in members if (getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)) != user_did]
if not others:
others = members
names = ", ".join([getattr(m, "handle", None) or (m.get("handle") if isinstance(m, dict) else None) or "unknown" for m in others])
title = _("Chat: {0}").format(names)
self._create_chat_buffer(self.controller, title, convo_id)
def _create_chat_buffer(self, controller, title, convo_id):
"""Create a chat buffer under the direct_messages node, avoiding duplicates."""
account_name = self.session.get_name()
# Avoid duplicates: if buffer already exists, navigate to it
existing = controller.search_buffer(title, account_name)
if existing:
index = controller.view.search(title, account_name)
if index is not None:
controller.view.change_buffer(index)
return
# Insert under direct_messages node (like Mastodon's ConversationBuffer)
chats_position = controller.view.search("direct_messages", account_name)
if chats_position is None:
chats_position = controller.view.search(account_name, account_name)
controller.create_buffer(
buffer_type="chat_messages",
session_type="blueski",
buffer_title=title,
parent_tab=chats_position,
kwargs={"session": self.session, "convo_id": convo_id, "name": title},
start=True
)
# Navigate to the newly created buffer and announce it
new_index = controller.view.search(title, account_name)
if new_index is not None:
controller.view.change_buffer(new_index)
buffer_obj = controller.search_buffer(title, account_name)
if buffer_obj and hasattr(buffer_obj.buffer, "list"):
try:
count = buffer_obj.buffer.list.get_count()
if count > 0:
msg = _("{0}, {1} of {2}").format(title, buffer_obj.buffer.list.get_selected()+1, count)
else:
msg = _("{0}. Empty").format(title)
except Exception:
msg = _("{0}. Empty").format(title)
output.speak(msg, True)
def destroy_status(self):
pass
class ChatBuffer(BaseBuffer):
"""Buffer for displaying messages in a conversation, similar to Mastodon's ConversationBuffer."""
def __init__(self, *args, **kwargs):
kwargs["compose_func"] = "compose_chat_message"
super(ChatBuffer, self).__init__(*args, **kwargs)
self.type = "chat_messages"
self.convo_id = kwargs.get("convo_id")
self.sound = "dm_received.ogg"
self._member_map_loaded = False
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.ChatMessagePanel(parent, name)
self.buffer.session = self.session
def _update_member_map(self):
"""Fetch conversation members to build a DID-to-name map for sender resolution."""
try:
convo = self.session.get_convo(self.convo_id)
if not convo:
return
member_map = {}
for m in getattr(convo, "members", []) or []:
did = getattr(m, "did", None)
if did:
name = getattr(m, "display_name", None) or getattr(m, "handle", None) or "unknown"
member_map[did] = name
self.session.db[self.name + "_members"] = member_map
except Exception:
log.exception("Error fetching conversation members for DID resolution")
def start_stream(self, mandatory=False, play_sound=True):
if not self.convo_id:
return 0
if not self._member_map_loaded:
self._update_member_map()
self._member_map_loaded = True
count = self.get_max_items()
try:
res = self.session.get_convo_messages(self.convo_id, limit=count)
items = res.get("items", [])
items = list(reversed(items))
return self.process_items(items, play_sound)
except Exception:
log.exception("Error fetching chat messages")
return 0
def get_more_items(self):
output.speak(_("This action is not supported for this buffer"), True)
def fav(self):
pass
def unfav(self):
pass
def can_share(self):
return False
def destroy_status(self):
pass
def url(self, *args, **kwargs):
"""Enter key opens reply dialog in chat."""
self.on_reply(None)
def on_reply(self, evt):
"""Open dialog to send a message in this conversation."""
if not self.convo_id:
output.speak(_("Cannot send message: no conversation selected."), True)
return
# Get conversation title from buffer name or use generic
title = _("Send Message")
if self.name and self.name.startswith(_("Chat: ")):
title = self.name
caption = _("Write your message here")
post = blueski_messages.post(session=self.session, title=title, caption=caption, text="")
if post.message.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = post.get_data()
if text:
def do_send():
try:
self.session.send_chat_message(self.convo_id, text)
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
wx.CallAfter(output.speak, _("Message sent."))
wx.CallAfter(self.start_stream, True, False)
except Exception:
log.exception("Error sending chat message")
wx.CallAfter(output.speak, _("Failed to send message."), True)
call_threaded(do_send)
if hasattr(post.message, "Destroy"):
post.message.Destroy()
def reply(self, *args, **kwargs):
"""Handle reply action (from menu or keyboard shortcut)."""
self.on_reply(None)
def send_message(self, *args, **kwargs):
"""Global shortcut for DM."""
self.on_reply(None)
def get_message(self):
"""Return a readable summary for the selected chat message."""
item = self.get_item()
if item is None:
return None
composed = self.compose_function(
item, self.session.db, self.session.settings,
self.session.settings["general"].get("relative_times", False),
self.session.settings["general"].get("show_screen_names", False),
)
return " ".join(composed)
def get_formatted_message(self):
"""Return the text content of the selected chat message."""
item = self.get_item()
if item is None:
return None
composed = self.compose_function(
item, self.session.db, self.session.settings,
self.session.settings["general"].get("relative_times", False),
self.session.settings["general"].get("show_screen_names", False),
)
return composed[1]
def view_item(self, item=None):
"""View the selected chat message in a dialog."""
msg = self.get_formatted_message()
if not msg:
output.speak(_("No message selected."), True)
return
viewer = blueski_messages.text(title=_("Chat message"), text=msg)
viewer.message.ShowModal()
viewer.message.Destroy()
def remove_buffer(self, force=False):
"""Allow removing this buffer."""
from wxUI import commonMessageDialogs
if force == False:
dlg = commonMessageDialogs.remove_buffer()
else:
dlg = widgetUtils.YES
if dlg == widgetUtils.YES:
if self.name in self.session.db:
self.session.db.pop(self.name)
return True
elif dlg == widgetUtils.NO:
return False

View File

@@ -0,0 +1,602 @@
# -*- coding: utf-8 -*-
import logging
import output
from .base import BaseBuffer
from wxUI.buffers.blueski import panels as BlueskiPanels
log = logging.getLogger("controller.buffers.blueski.timeline")
class HomeTimeline(BaseBuffer):
"""Discover feed buffer."""
def __init__(self, *args, **kwargs):
super(HomeTimeline, self).__init__(*args, **kwargs)
self.type = "home_timeline"
self.feed_uri = None
self.next_cursor = None
self.sound = "tweet_received.ogg"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.HomePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = self.get_max_items()
api = self.session._ensure_client()
if not self.feed_uri:
self.feed_uri = self._resolve_discover_feed(api)
try:
if self.feed_uri:
res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count})
else:
res = api.app.bsky.feed.get_timeline({"limit": count})
items = list(getattr(res, "feed", []))
self.next_cursor = getattr(res, "cursor", None)
except Exception as e:
log.error("Error fetching home timeline: %s", e)
return 0
return self.process_items(items, play_sound)
def get_more_items(self):
if not self.next_cursor:
return
count = self.get_max_items()
api = self.session._ensure_client()
try:
if self.feed_uri:
res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count, "cursor": self.next_cursor})
else:
res = api.app.bsky.feed.get_timeline({"limit": count, "cursor": self.next_cursor})
items = list(getattr(res, "feed", []))
self.next_cursor = getattr(res, "cursor", None)
added = self.process_items(items, play_sound=False)
if added:
output.speak(_(u"%s items retrieved") % added, True)
except Exception as e:
log.error("Error fetching more home timeline: %s", e)
def _resolve_discover_feed(self, api):
cached = self.session.db.get("discover_feed_uri")
if cached:
return cached
try:
res = api.app.bsky.feed.get_suggested_feeds({"limit": 50})
for feed in getattr(res, "feeds", []):
dn = getattr(feed, "displayName", "") or getattr(feed, "display_name", "")
if "discover" in dn.lower():
uri = getattr(feed, "uri", "")
self.session.db["discover_feed_uri"] = uri
return uri
except Exception:
pass
return None
class FollowingTimeline(BaseBuffer):
"""Following-only timeline (reverse-chronological)."""
def __init__(self, *args, **kwargs):
super(FollowingTimeline, self).__init__(*args, **kwargs)
self.type = "following_timeline"
self.next_cursor = None
self.sound = "tweet_received.ogg"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.HomePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = self.get_max_items()
api = self.session._ensure_client()
try:
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"})
items = list(getattr(res, "feed", []))
self.next_cursor = getattr(res, "cursor", None)
except Exception as e:
log.error("Error fetching following timeline: %s", e)
return 0
return self.process_items(items, play_sound)
def get_more_items(self):
if not self.next_cursor:
return
count = self.get_max_items()
api = self.session._ensure_client()
try:
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological", "cursor": self.next_cursor})
items = list(getattr(res, "feed", []))
self.next_cursor = getattr(res, "cursor", None)
added = self.process_items(items, play_sound=False)
if added:
output.speak(_(u"%s items retrieved") % added, True)
except Exception as e:
log.error("Error fetching more following timeline: %s", e)
class NotificationBuffer(BaseBuffer):
"""Notifications buffer."""
def __init__(self, *args, **kwargs):
kwargs["compose_func"] = "compose_notification"
super(NotificationBuffer, self).__init__(*args, **kwargs)
self.type = "notifications"
self.sound = "notification_received.ogg"
self.next_cursor = None
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
self.buffer.session = self.session
def _hydrate_notifications(self, notifications):
"""Fetch subject post text for like/repost notifications."""
if not notifications:
return notifications
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
# Collect URIs for likes/reposts that need subject post text
uris_to_fetch = []
for notif in notifications:
reason = g(notif, "reason", "")
if reason in ("like", "repost"):
reason_subject = g(notif, "reasonSubject") or g(notif, "reason_subject")
if reason_subject and isinstance(reason_subject, str):
uris_to_fetch.append(reason_subject)
if not uris_to_fetch:
return notifications
# Fetch posts in batch
posts_map = {}
try:
api = self.session._ensure_client()
if api and uris_to_fetch:
# getPosts accepts up to 25 URIs at a time
for i in range(0, len(uris_to_fetch), 25):
batch = uris_to_fetch[i:i+25]
res = api.app.bsky.feed.get_posts({"uris": batch})
for post in getattr(res, "posts", []):
uri = g(post, "uri")
if uri:
record = g(post, "record", {})
text = g(record, "text", "")
posts_map[uri] = text
except Exception as e:
log.error("Error fetching subject posts for notifications: %s", e)
# Attach subject post text to notifications
enriched = []
for notif in notifications:
reason = g(notif, "reason", "")
if reason in ("like", "repost"):
reason_subject = g(notif, "reasonSubject") or g(notif, "reason_subject")
if reason_subject and reason_subject in posts_map:
# Create a modified notification with subject post text
if isinstance(notif, dict):
notif = dict(notif)
notif["_subject_text"] = posts_map[reason_subject]
else:
# For ATProto model objects, add as attribute
try:
notif._subject_text = posts_map[reason_subject]
except AttributeError:
pass
enriched.append(notif)
return enriched
def start_stream(self, mandatory=False, play_sound=True):
count = self.get_max_items()
api = self.session._ensure_client()
if not api:
return 0
try:
res = api.app.bsky.notification.list_notifications({"limit": count})
notifications = list(getattr(res, "notifications", []))
self.next_cursor = getattr(res, "cursor", None)
if not notifications:
return 0
notifications = self._hydrate_notifications(notifications)
return self.process_items(notifications, play_sound)
except Exception as e:
log.error("Error fetching notifications: %s", e)
return 0
def get_more_items(self):
if not self.next_cursor:
return
count = self.get_max_items()
api = self.session._ensure_client()
if not api:
return
try:
res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor})
notifications = list(getattr(res, "notifications", []))
self.next_cursor = getattr(res, "cursor", None)
notifications = self._hydrate_notifications(notifications)
added = self.process_items(notifications, play_sound=False)
if added:
output.speak(_(u"%s items retrieved") % added, True)
except Exception as e:
log.error("Error fetching more notifications: %s", e)
def add_new_item(self, notification):
notifications = self._hydrate_notifications([notification])
return self.process_items(notifications, play_sound=True)
class Conversation(BaseBuffer):
"""Thread/conversation view."""
def __init__(self, *args, **kwargs):
super(Conversation, self).__init__(*args, **kwargs)
self.type = "conversation"
self.root_uri = kwargs.get("uri")
self.sound = "search_updated.ogg"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.HomePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
if not self.root_uri:
return 0
api = self.session._ensure_client()
try:
res = api.app.bsky.feed.get_post_thread({"uri": self.root_uri, "depth": 100, "parentHeight": 100})
thread = getattr(res, "thread", None)
if not thread:
return 0
def g(obj, key, default=None):
return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default)
final_items = []
# Add ancestors
ancestors = []
parent = g(thread, "parent")
while parent:
ppost = g(parent, "post")
if ppost:
ancestors.insert(0, ppost)
parent = g(parent, "parent")
final_items.extend(ancestors)
# Traverse thread
def traverse(node):
if not node:
return
post = g(node, "post")
if post:
final_items.append(post)
for r in (g(node, "replies") or []):
traverse(r)
traverse(thread)
self.session.db[self.name] = []
self.buffer.list.clear()
# Don't use process_items() because it applies reverse logic.
# Conversations should always be chronological (oldest first).
return self._add_items_chronological(final_items, play_sound)
except Exception as e:
log.error("Error fetching thread: %s", e)
return 0
def _add_items_chronological(self, items, play_sound=True):
"""Add items in chronological order (oldest first) without reverse logic."""
if not items:
return 0
safe = True
relative_times = self.session.settings["general"].get("relative_times", False)
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
for item in items:
self.session.db[self.name].append(item)
post = self.compose_function(item, self.session.db, self.session.settings,
relative_times=relative_times,
show_screen_names=show_screen_names,
safe=safe)
self.buffer.list.insert_item(False, *post)
# Select the root post (first item after ancestors, or just the first)
total = self.buffer.list.get_count()
if total > 0:
self.buffer.list.select_item(0)
if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]:
self.session.sound.play(self.sound)
return len(items)
class LikesBuffer(BaseBuffer):
"""User's liked posts."""
def __init__(self, *args, **kwargs):
super(LikesBuffer, self).__init__(*args, **kwargs)
self.type = "likes"
self.next_cursor = None
self.sound = "favourite.ogg"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.HomePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = self.get_max_items()
api = self.session._ensure_client()
try:
res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count})
items = list(getattr(res, "feed", None) or getattr(res, "items", None) or [])
self.next_cursor = getattr(res, "cursor", None)
except Exception as e:
log.error("Error fetching likes: %s", e)
return 0
return self.process_items(items, play_sound)
def get_more_items(self):
if not self.next_cursor:
return
count = self.get_max_items()
api = self.session._ensure_client()
if not api:
return
try:
res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count, "cursor": self.next_cursor})
items = list(getattr(res, "feed", None) or getattr(res, "items", None) or [])
self.next_cursor = getattr(res, "cursor", None)
added = self.process_items(items, play_sound=False)
if added:
output.speak(_(u"%s items retrieved") % added, True)
except Exception as e:
log.error("Error fetching more likes: %s", e)
class MentionsBuffer(BaseBuffer):
"""Mentions, replies and quotes."""
def __init__(self, *args, **kwargs):
kwargs["compose_func"] = "compose_notification"
super(MentionsBuffer, self).__init__(*args, **kwargs)
self.type = "mentions"
self.sound = "mention_received.ogg"
self.next_cursor = None
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = self.get_max_items()
api = self.session._ensure_client()
if not api:
return 0
try:
res = api.app.bsky.notification.list_notifications({"limit": count})
notifications = getattr(res, "notifications", [])
self.next_cursor = getattr(res, "cursor", None)
mentions = [n for n in notifications if getattr(n, "reason", "") in ("mention", "reply", "quote")]
if not mentions:
return 0
return self.process_items(mentions, play_sound)
except Exception as e:
log.error("Error fetching mentions: %s", e)
return 0
def get_more_items(self):
if not self.next_cursor:
return
count = self.get_max_items()
api = self.session._ensure_client()
if not api:
return
try:
res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor})
notifications = getattr(res, "notifications", [])
self.next_cursor = getattr(res, "cursor", None)
mentions = [n for n in notifications if getattr(n, "reason", "") in ("mention", "reply", "quote")]
if mentions:
added = self.process_items(mentions, play_sound=False)
if added:
output.speak(_(u"%s items retrieved") % added, True)
except Exception as e:
log.error("Error fetching more mentions: %s", e)
def add_new_item(self, notification):
if getattr(notification, "reason", "") in ("mention", "reply", "quote"):
return self.process_items([notification], play_sound=True)
return 0
class SentBuffer(BaseBuffer):
"""User's sent posts."""
def __init__(self, *args, **kwargs):
super(SentBuffer, self).__init__(*args, **kwargs)
self.type = "sent"
self.next_cursor = None
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.HomePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
count = self.get_max_items()
api = self.session._ensure_client()
if not api or not api.me:
return 0
try:
res = api.app.bsky.feed.get_author_feed({"actor": api.me.did, "limit": count, "filter": "posts_no_replies"})
items = list(getattr(res, "feed", []))
self.next_cursor = getattr(res, "cursor", None)
if not items:
return 0
return self.process_items(items, play_sound)
except Exception as e:
log.error("Error fetching sent posts: %s", e)
return 0
def get_more_items(self):
if not self.next_cursor:
return
count = self.get_max_items()
api = self.session._ensure_client()
if not api or not api.me:
return
try:
res = api.app.bsky.feed.get_author_feed({"actor": api.me.did, "limit": count, "filter": "posts_no_replies", "cursor": self.next_cursor})
items = list(getattr(res, "feed", []))
self.next_cursor = getattr(res, "cursor", None)
added = self.process_items(items, play_sound=False)
if added:
output.speak(_(u"%s items retrieved") % added, True)
except Exception as e:
log.error("Error fetching more sent posts: %s", e)
class UserTimeline(BaseBuffer):
"""Timeline for a specific user."""
def __init__(self, *args, **kwargs):
self.actor = kwargs.get("actor")
self.handle = kwargs.get("handle")
super(UserTimeline, self).__init__(*args, **kwargs)
self.type = "user_timeline"
self.next_cursor = None
self._resolved_actor = None
self.sound = "tweet_timeline.ogg"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.HomePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
if not self.actor:
return 0
count = self.get_max_items()
actor = self.actor.strip().lstrip("@") if isinstance(self.actor, str) else self.actor
api = self.session._ensure_client()
if not api:
return 0
try:
if isinstance(actor, str) and not actor.startswith("did:"):
profile = self.session.get_profile(actor)
if profile:
did = profile.get("did") if isinstance(profile, dict) else getattr(profile, "did", None)
if did:
actor = did
self._resolved_actor = actor
res = api.app.bsky.feed.get_author_feed({"actor": actor, "limit": count})
items = list(getattr(res, "feed", []) or [])
self.next_cursor = getattr(res, "cursor", None)
except Exception as e:
log.error("Error fetching user timeline: %s", e)
return 0
return self.process_items(items, play_sound)
def get_more_items(self):
if not self.next_cursor or not self._resolved_actor:
return
count = self.get_max_items()
api = self.session._ensure_client()
if not api:
return
try:
res = api.app.bsky.feed.get_author_feed({"actor": self._resolved_actor, "limit": count, "cursor": self.next_cursor})
items = list(getattr(res, "feed", []) or [])
self.next_cursor = getattr(res, "cursor", None)
added = self.process_items(items, play_sound=False)
if added:
output.speak(_(u"%s items retrieved") % added, True)
except Exception as e:
log.error("Error fetching more user timeline: %s", e)
def remove_buffer(self, force=False):
if not force:
from wxUI import commonMessageDialogs
import widgetUtils
if commonMessageDialogs.remove_buffer() != widgetUtils.YES:
return False
self.session.db.pop(self.name, None)
timelines = self.session.settings["other_buffers"].get("timelines") or []
if isinstance(timelines, str):
timelines = [t for t in timelines.split(",") if t]
for key in (self.actor or "", self.handle or ""):
if key in timelines:
timelines.remove(key)
self.session.settings["other_buffers"]["timelines"] = timelines
self.session.settings.write()
return True
class SearchBuffer(BaseBuffer):
"""Search results buffer."""
def __init__(self, *args, **kwargs):
self.search_query = kwargs.pop("query", "")
super(SearchBuffer, self).__init__(*args, **kwargs)
self.type = "search"
self.next_cursor = None
self.sound = "search_updated.ogg"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.HomePanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
if not self.search_query:
return 0
count = self.get_max_items()
api = self.session._ensure_client()
if not api:
return 0
try:
res = api.app.bsky.feed.search_posts({"q": self.search_query, "limit": count})
posts = list(getattr(res, "posts", []))
self.next_cursor = getattr(res, "cursor", None)
if not posts:
return 0
self.session.db[self.name] = []
self.buffer.list.clear()
return self.process_items(posts, play_sound)
except Exception as e:
log.error("Error searching posts: %s", e)
return 0
def get_more_items(self):
if not self.next_cursor or not self.search_query:
return
count = self.get_max_items()
api = self.session._ensure_client()
if not api:
return
try:
res = api.app.bsky.feed.search_posts({"q": self.search_query, "limit": count, "cursor": self.next_cursor})
posts = list(getattr(res, "posts", []))
self.next_cursor = getattr(res, "cursor", None)
added = self.process_items(posts, play_sound=False)
if added:
output.speak(_(u"%s items retrieved") % added, True)
except Exception as e:
log.error("Error fetching more search results: %s", e)
def remove_buffer(self, force=False):
if not force:
from wxUI import commonMessageDialogs
import widgetUtils
if commonMessageDialogs.remove_buffer() != widgetUtils.YES:
return False
self.session.db.pop(self.name, None)
searches = self.session.settings["other_buffers"].get("searches") or []
if isinstance(searches, str):
searches = [s for s in searches.split(",") if s]
if self.search_query in searches:
searches.remove(self.search_query)
self.session.settings["other_buffers"]["searches"] = searches
self.session.settings.write()
return True

View File

@@ -0,0 +1,289 @@
# -*- coding: utf-8 -*-
import logging
import output
from .base import BaseBuffer
from wxUI.buffers.blueski import panels as BlueskiPanels
from sessions.blueski import compose
log = logging.getLogger("controller.buffers.blueski.user")
class UserBuffer(BaseBuffer):
def __init__(self, *args, **kwargs):
# We need compose_user for this buffer
kwargs["compose_func"] = "compose_user"
super(UserBuffer, self).__init__(*args, **kwargs)
self.type = "user"
self.next_cursor = None
self.sound = "new_event.ogg"
def create_buffer(self, parent, name):
self.buffer = BlueskiPanels.UserPanel(parent, name)
self.buffer.session = self.session
def start_stream(self, mandatory=False, play_sound=True):
api_method = self.kwargs.get("api_method")
if not api_method: return 0
count = self.get_max_items()
actor = (
self.kwargs.get("actor")
or self.kwargs.get("did")
or self.kwargs.get("handle")
or self.kwargs.get("id")
)
try:
if api_method in ("get_followers", "get_follows"):
res = getattr(self.session, api_method)(actor=actor, limit=count)
else:
res = getattr(self.session, api_method)(limit=count)
items = self._hydrate_profiles(res.get("items", []) or [])
self.next_cursor = res.get("cursor")
return self.process_items(items, play_sound)
except Exception as e:
log.error("Error fetching user list for %s: %s", self.name, e)
return 0
def get_more_items(self):
api_method = self.kwargs.get("api_method")
if not api_method or not self.next_cursor:
return
count = self.get_max_items()
actor = (
self.kwargs.get("actor")
or self.kwargs.get("did")
or self.kwargs.get("handle")
or self.kwargs.get("id")
)
try:
if api_method in ("get_followers", "get_follows"):
res = getattr(self.session, api_method)(actor=actor, limit=count, cursor=self.next_cursor)
else:
res = getattr(self.session, api_method)(limit=count, cursor=self.next_cursor)
items = self._hydrate_profiles(res.get("items", []) or [])
self.next_cursor = res.get("cursor")
added = self.process_items(items, play_sound=False)
if added:
output.speak(_(u"%s items retrieved") % (str(added)), True)
except Exception as e:
log.error("Error fetching more user list items for %s: %s", self.name, e)
def _hydrate_profiles(self, items):
if not items:
return []
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
def resolve_profile(obj):
if g(obj, "handle") or g(obj, "did"):
return obj
for key in ("subject", "actor", "profile", "user"):
nested = g(obj, key)
if nested and (g(nested, "handle") or g(nested, "did")):
return nested
return obj
actors = []
for item in items:
profile = resolve_profile(item)
did = g(profile, "did")
handle = g(profile, "handle")
if did:
actors.append(did)
elif handle:
actors.append(handle)
if not actors:
return items
profiles = []
if actors and hasattr(self.session, "get_profiles"):
try:
res = self.session.get_profiles(actors)
profiles = res.get("items", []) or []
except Exception:
profiles = []
# If batch profiles lack counts, hydrate with detailed profiles.
if hasattr(self.session, "get_profile"):
def counts_missing(profile_obj):
p1 = g(profile_obj, "followersCount") or g(profile_obj, "followers_count")
p2 = g(profile_obj, "followsCount") or g(profile_obj, "follows_count")
p3 = g(profile_obj, "postsCount") or g(profile_obj, "posts_count")
if p1 is None and p2 is None and p3 is None:
return True
return (p1 or 0) == 0 and (p2 or 0) == 0 and (p3 or 0) == 0
if not profiles:
for actor in actors:
try:
p = self.session.get_profile(actor)
if p:
profiles.append(p)
except Exception:
pass
else:
for idx, p in enumerate(profiles):
if counts_missing(p):
did = g(p, "did") or g(p, "handle")
if not did:
continue
try:
detailed = self.session.get_profile(did)
if detailed:
profiles[idx] = detailed
except Exception:
pass
profile_map = {}
for p in profiles:
did = g(p, "did")
handle = g(p, "handle")
if did:
profile_map[did] = p
if handle and handle not in profile_map:
profile_map[handle] = p
def needs_replace(item, profile):
if profile is None:
return False
base = resolve_profile(item)
f1 = g(base, "followersCount") or g(base, "followers_count")
f2 = g(base, "followsCount") or g(base, "follows_count")
f3 = g(base, "postsCount") or g(base, "posts_count")
p1 = g(profile, "followersCount") or g(profile, "followers_count")
p2 = g(profile, "followsCount") or g(profile, "follows_count")
p3 = g(profile, "postsCount") or g(profile, "posts_count")
if f1 is None and f2 is None and f3 is None:
return True
if (f1 or 0) == 0 and (f2 or 0) == 0 and (f3 or 0) == 0:
return (p1 or 0) != 0 or (p2 or 0) != 0 or (p3 or 0) != 0
return False
enriched = []
for item in items:
base = resolve_profile(item)
did = g(base, "did")
handle = g(base, "handle")
profile = profile_map.get(did) or profile_map.get(handle)
if needs_replace(item, profile):
enriched.append(profile)
else:
enriched.append(item)
return enriched
class FollowersBuffer(UserBuffer):
def __init__(self, *args, **kwargs):
kwargs["api_method"] = "get_followers"
super(FollowersBuffer, self).__init__(*args, **kwargs)
self.sound = "update_followers.ogg"
def remove_buffer(self, force=False):
if not force:
from wxUI import commonMessageDialogs
import widgetUtils
dlg = commonMessageDialogs.remove_buffer()
if dlg != widgetUtils.YES:
return False
try:
self.session.db.pop(self.name, None)
except Exception:
pass
try:
key = self.kwargs.get("actor") or self.kwargs.get("handle") or self.kwargs.get("id")
timelines = self.session.settings["other_buffers"].get("followers_timelines") or []
if isinstance(timelines, str):
timelines = [t for t in timelines.split(",") if t]
if key in timelines:
timelines.remove(key)
self.session.settings["other_buffers"]["followers_timelines"] = timelines
self.session.settings.write()
except Exception as e:
log.error("Error updating Bluesky followers timelines settings: %s", e)
return True
class FollowingBuffer(UserBuffer):
def __init__(self, *args, **kwargs):
kwargs["api_method"] = "get_follows"
super(FollowingBuffer, self).__init__(*args, **kwargs)
self.sound = "update_followers.ogg"
def remove_buffer(self, force=False):
if not force:
from wxUI import commonMessageDialogs
import widgetUtils
dlg = commonMessageDialogs.remove_buffer()
if dlg != widgetUtils.YES:
return False
try:
self.session.db.pop(self.name, None)
except Exception:
pass
try:
key = self.kwargs.get("actor") or self.kwargs.get("handle") or self.kwargs.get("id")
timelines = self.session.settings["other_buffers"].get("following_timelines") or []
if isinstance(timelines, str):
timelines = [t for t in timelines.split(",") if t]
if key in timelines:
timelines.remove(key)
self.session.settings["other_buffers"]["following_timelines"] = timelines
self.session.settings.write()
except Exception as e:
log.error("Error updating Bluesky following timelines settings: %s", e)
return True
class BlocksBuffer(UserBuffer):
def __init__(self, *args, **kwargs):
kwargs["api_method"] = "get_blocks"
super(BlocksBuffer, self).__init__(*args, **kwargs)
class PostUserListBuffer(UserBuffer):
def __init__(self, *args, **kwargs):
self.post_uri = kwargs.get("post_uri")
self.api_method = kwargs.get("api_method")
super(PostUserListBuffer, self).__init__(*args, **kwargs)
self.type = "post_user_list"
def start_stream(self, mandatory=False, play_sound=True):
if not self.api_method or not self.post_uri:
return 0
count = self.get_max_items()
try:
res = getattr(self.session, self.api_method)(self.post_uri, limit=count)
items = res.get("items", [])
self.next_cursor = res.get("cursor")
return self.process_items(items, play_sound)
except Exception as e:
log.error("Error fetching post user list for %s: %s", self.name, e)
return 0
def get_more_items(self):
if not self.api_method or not self.post_uri or not self.next_cursor:
return
count = self.get_max_items()
try:
res = getattr(self.session, self.api_method)(self.post_uri, limit=count, cursor=self.next_cursor)
items = res.get("items", [])
self.next_cursor = res.get("cursor")
added = self.process_items(items, play_sound=False)
if added:
output.speak(_(u"%s items retrieved") % (str(added)), True)
except Exception as e:
log.error("Error fetching more post user list items for %s: %s", self.name, e)
def remove_buffer(self, force=False):
if not force:
from wxUI import commonMessageDialogs
import widgetUtils
dlg = commonMessageDialogs.remove_buffer()
if dlg != widgetUtils.YES:
return False
try:
self.session.db.pop(self.name, None)
except Exception:
pass
return True

View File

@@ -5,6 +5,7 @@ import logging
import webbrowser
import wx
import requests
import asyncio
import keystrokeEditor
import sessions
import widgetUtils
@@ -25,6 +26,7 @@ from mysc import localization
from mysc.thread_utils import call_threaded
from mysc.repeating_timer import RepeatingTimer
from controller.mastodon import handler as MastodonHandler
from controller.blueski import handler as BlueskiHandler # Added import
from . import settings, userAlias
log = logging.getLogger("mainController")
@@ -93,6 +95,17 @@ class Controller(object):
[results.append(self.search_buffer(i.name, i.account)) for i in buffers if i.account == account and (i.type != "account")]
return results
def get_handler(self, type):
"""Return the controller handler for a given session type."""
try:
if type == "mastodon":
return MastodonHandler.Handler()
if type == "blueski":
return BlueskiHandler.Handler()
except Exception:
log.exception("Error creating handler for type %s", type)
return None
def bind_other_events(self):
""" Binds the local application events with their functions."""
log.debug("Binding other application events...")
@@ -107,6 +120,7 @@ class Controller(object):
pub.subscribe(self.invisible_shorcuts_changed, "invisible-shorcuts-changed")
pub.subscribe(self.create_account_buffer, "core.create_account")
pub.subscribe(self.change_buffer_title, "core.change_buffer_title")
pub.subscribe(self.handle_compose_dialog_send, "compose_dialog.send_post") # For new compose dialog
# Mastodon specific events.
pub.subscribe(self.mastodon_new_item, "mastodon.new_item")
@@ -114,6 +128,9 @@ class Controller(object):
pub.subscribe(self.mastodon_new_conversation, "mastodon.conversation_received")
pub.subscribe(self.mastodon_error_post, "mastodon.error_post")
# Bluesky specific events.
pub.subscribe(self.blueski_new_item, "blueski.new_item")
# connect application events to GUI
widgetUtils.connect_event(self.view, widgetUtils.CLOSE_EVENT, self.exit_)
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.show_hide, menuitem=self.view.show_hide)
@@ -191,10 +208,12 @@ class Controller(object):
def get_handler(self, type):
handler = self.handlers.get(type)
if handler == None:
if handler is None:
if type == "mastodon":
handler = MastodonHandler.Handler()
self.handlers[type]=handler
elif type == "blueski":
handler = BlueskiHandler.Handler()
self.handlers[type] = handler
return handler
def __init__(self):
@@ -204,7 +223,7 @@ class Controller(object):
# main window
self.view = view.mainFrame()
# buffers list.
self.buffers = []
self.buffers: list[buffers.base.Buffer] = [] # Added type hint
self.started = False
# accounts list.
self.accounts = []
@@ -235,14 +254,26 @@ class Controller(object):
for i in sessions.sessions:
log.debug("Working on session %s" % (i,))
if sessions.sessions[i].is_logged == False:
self.create_ignored_session_buffer(sessions.sessions[i])
continue
# Valid types currently are mastodon (Work in progress)
# More can be added later.
valid_session_types = ["mastodon"]
if sessions.sessions[i].session_id in config.app["sessions"]["ignored_sessions"]:
self.create_ignored_session_buffer(sessions.sessions[i])
continue
# Try auto-login for sessions if credentials exist
try:
sessions.sessions[i].login()
except Exception:
log.exception("Auto-login attempt failed for session %s", i)
if sessions.sessions[i].is_logged == False:
self.create_ignored_session_buffer(sessions.sessions[i])
continue
# Supported session types
valid_session_types = ["mastodon", "blueski"]
if sessions.sessions[i].type in valid_session_types:
handler = self.get_handler(type=sessions.sessions[i].type)
handler.create_buffers(sessions.sessions[i], controller=self)
try:
handler = self.get_handler(type=sessions.sessions[i].type)
if handler is not None:
handler.create_buffers(sessions.sessions[i], controller=self)
except Exception:
log.exception("Error creating buffers for session %s (%s)", i, sessions.sessions[i].type)
log.debug("Setting updates to buffers every %d seconds..." % (60*config.app["app-settings"]["update_period"],))
self.update_buffers_function = RepeatingTimer(60*config.app["app-settings"]["update_period"], self.update_buffers)
self.update_buffers_function.start()
@@ -251,29 +282,83 @@ class Controller(object):
""" Starts all buffer objects. Loads their items."""
for i in sessions.sessions:
if sessions.sessions[i].is_logged == False: continue
self.start_buffers(sessions.sessions[i])
self.set_buffer_positions(sessions.sessions[i])
if hasattr(sessions.sessions[i], "start_streaming"):
sessions.sessions[i].start_streaming()
if config.app["app-settings"]["play_ready_sound"] == True:
call_threaded(self._start_session_buffers, sessions.sessions[i])
if len(sessions.sessions) > 0 and config.app["app-settings"]["play_ready_sound"] == True:
sessions.sessions[list(sessions.sessions.keys())[0]].sound.play("ready.ogg")
if config.app["app-settings"]["speak_ready_msg"] == True:
output.speak(_(u"Ready"))
self.started = True
if len(self.accounts) > 0:
b = self.get_first_buffer(self.accounts[0])
self.menubar_current_handler = b.session.type
self.update_menus(handler=self.get_handler(b.session.type))
def _start_session_buffers(self, session):
"""Helper to start buffers for a session in a background thread."""
try:
self.start_buffers(session)
self.set_buffer_positions(session)
if hasattr(session, "start_streaming"):
session.start_streaming()
except Exception:
log.exception("Error starting buffers for session %s", session.session_id)
def create_ignored_session_buffer(self, session):
pub.sendMessage("core.create_account", name=session.get_name(), session_id=session.session_id)
def login_account(self, session_id):
session = None
for i in sessions.sessions:
if sessions.sessions[i].session_id == session_id: session = sessions.sessions[i]
session.login()
if sessions.sessions[i].session_id == session_id:
session = sessions.sessions[i]
break
if not session:
return
old_name = session.get_name()
try:
session.login()
except Exception as e:
log.exception("Login failed for session %s", session_id)
output.speak(_("Login failed for {0}: {1}").format(old_name, str(e)), True)
return
if not session.logged:
output.speak(_("Login failed for {0}. Please check your credentials.").format(old_name), True)
return
new_name = session.get_name()
if old_name != new_name:
log.info(f"Account name changed from {old_name} to {new_name} after login")
if self.current_account == old_name:
self.current_account = new_name
if old_name in self.accounts:
idx = self.accounts.index(old_name)
self.accounts[idx] = new_name
else:
self.accounts.append(new_name)
# Update root buffer name and account
for b in self.buffers:
if b.account == old_name:
b.account = new_name
if hasattr(b, "buffer"):
b.buffer.account = new_name
# If this is the root node, its name matches old_name (e.g. "Bluesky")
if b.name == old_name:
b.name = new_name
if hasattr(b, "buffer"):
b.buffer.name = new_name
# Update tree node label
self.change_buffer_title(old_name, old_name, new_name)
handler = self.get_handler(type=session.type)
if handler != None and hasattr(handler, "create_buffers"):
handler.create_buffers(session=session, controller=self, createAccounts=False)
try:
handler.create_buffers(session=session, controller=self, createAccounts=False)
except Exception:
log.exception("Error creating buffers after login for session %s (%s)", session.session_id, session.type)
self.start_buffers(session)
if hasattr(session, "start_streaming"):
session.start_streaming()
@@ -287,31 +372,91 @@ class Controller(object):
self.view.add_buffer(account.buffer , name=name)
def create_buffer(self, buffer_type="baseBuffer", session_type="twitter", buffer_title="", parent_tab=None, start=False, kwargs={}):
# Copy kwargs to avoid mutating a shared dict across calls
if not isinstance(kwargs, dict):
kwargs = {}
else:
kwargs = dict(kwargs)
log.debug("Creating buffer of type {0} with parent_tab of {2} arguments {1}".format(buffer_type, kwargs, parent_tab))
if kwargs.get("parent") == None:
kwargs["parent"] = self.view.nb
if not hasattr(buffers, session_type):
if not hasattr(buffers, session_type) and session_type != "blueski": # Allow blueski to be handled separately
raise AttributeError("Session type %s does not exist yet." % (session_type))
available_buffers = getattr(buffers, session_type)
if not hasattr(available_buffers, buffer_type):
raise AttributeError("Specified buffer type does not exist: %s" % (buffer_type,))
buffer = getattr(available_buffers, buffer_type)(**kwargs)
if start:
if kwargs.get("function") == "user_timeline":
try:
buffer_panel_class = None
if session_type == "blueski":
from controller.buffers.blueski import timeline as BlueskiTimelines
from controller.buffers.blueski import user as BlueskiUsers
from controller.buffers.blueski import chat as BlueskiChats
if "user_id" in kwargs and "session" not in kwargs:
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
if "name" not in kwargs: kwargs["name"] = buffer_title
buffer_map = {
"home_timeline": BlueskiTimelines.HomeTimeline,
"following_timeline": BlueskiTimelines.FollowingTimeline,
"notifications": BlueskiTimelines.NotificationBuffer,
"conversation": BlueskiTimelines.Conversation,
"likes": BlueskiTimelines.LikesBuffer,
"MentionsBuffer": BlueskiTimelines.MentionsBuffer,
"mentions": BlueskiTimelines.MentionsBuffer,
"SentBuffer": BlueskiTimelines.SentBuffer,
"sent": BlueskiTimelines.SentBuffer,
"SearchBuffer": BlueskiTimelines.SearchBuffer,
"search": BlueskiTimelines.SearchBuffer,
"UserBuffer": BlueskiUsers.UserBuffer,
"FollowersBuffer": BlueskiUsers.FollowersBuffer,
"FollowingBuffer": BlueskiUsers.FollowingBuffer,
"BlocksBuffer": BlueskiUsers.BlocksBuffer,
"PostUserListBuffer": BlueskiUsers.PostUserListBuffer,
"ConversationListBuffer": BlueskiChats.ConversationListBuffer,
"ChatMessageBuffer": BlueskiChats.ChatBuffer,
"chat_messages": BlueskiChats.ChatBuffer,
"UserTimeline": BlueskiTimelines.UserTimeline,
"user_timeline": BlueskiTimelines.UserTimeline,
}
buffer_panel_class = buffer_map.get(buffer_type)
if buffer_panel_class is None:
# Fallback for others including user_timeline to HomeTimeline for now
log.warning(f"Unsupported Blueski buffer type: {buffer_type}. Falling back to HomeTimeline.")
buffer_panel_class = BlueskiTimelines.HomeTimeline
else: # Existing logic for other session types
available_buffers = getattr(buffers, session_type)
if not hasattr(available_buffers, buffer_type):
raise AttributeError("Specified buffer type does not exist: %s" % (buffer_type,))
buffer_panel_class = getattr(available_buffers, buffer_type)
# Instantiate the panel
# Ensure 'parent' kwarg is correctly set if not already
if "parent" not in kwargs:
kwargs["parent"] = self.view.nb # self.view.nb is the wx.Treebook
buffer = buffer_panel_class(**kwargs) # This is the wx.Panel instance
buffer.controller = self
if start:
try:
buffer.start_stream(play_sound=False)
if hasattr(buffer, "start_stream"):
if kwargs.get("function") == "user_timeline":
buffer.start_stream(mandatory=True, play_sound=False)
else:
buffer.start_stream(mandatory=True, play_sound=True)
except ValueError:
commonMessageDialogs.unauthorized()
return
self.buffers.append(buffer)
if parent_tab == None:
log.debug("Appending buffer {}...".format(buffer,))
self.view.add_buffer(buffer.buffer, buffer_title)
else:
call_threaded(buffer.start_stream)
self.buffers.append(buffer)
if parent_tab == None:
log.debug("Appending buffer {}...".format(buffer,))
self.view.add_buffer(buffer.buffer, buffer_title)
else:
self.view.insert_buffer(buffer.buffer, buffer_title, parent_tab)
log.debug("Inserting buffer {0} into control {1}".format(buffer, parent_tab))
self.view.insert_buffer(buffer.buffer, buffer_title, parent_tab)
log.debug("Inserting buffer {0} into control {1}".format(buffer, parent_tab))
except Exception:
log.exception("Error creating buffer '%s' for session_type '%s'", buffer_type, session_type)
def set_buffer_positions(self, session):
"Sets positions for buffers if values exist in the database."
@@ -503,30 +648,216 @@ class Controller(object):
if hasattr(buffer, "post_status"):
buffer.post_status()
def handle_compose_dialog_send(self, session, text, files, reply_to, cw_text, is_sensitive, kwargs, dialog_instance):
"""Handles the actual sending of a post after ComposeDialog publishes data."""
async def do_send_post():
try:
wx.CallAfter(dialog_instance.send_btn.Disable)
wx.CallAfter(wx.BeginBusyCursor)
post_uri = await session.send_message(
message=text,
files=files,
reply_to=reply_to,
cw_text=cw_text,
is_sensitive=is_sensitive,
**kwargs
)
if post_uri:
output.speak(_("Post sent successfully!"), True)
wx.CallAfter(dialog_instance.EndModal, wx.ID_OK)
# Optionally, add to relevant buffer or update UI
# This might involve fetching the new post and adding to message_cache and posts_buffer
# new_post_data = await session.util.get_post_by_uri(post_uri) # Assuming such a util method
# if new_post_data:
# await self.check_buffers(new_post_data) # check_buffers needs to handle PostView or dict
else:
# This case should ideally be handled by send_message raising an error
output.speak(_("Failed to send post. The server did not confirm the post creation."), True)
wx.CallAfter(dialog_instance.send_btn.Enable, True)
except NotificationError as e:
logger.error(f"NotificationError sending post via dialog: {e}", exc_info=True)
output.speak(_("Error sending post: {error}").format(error=str(e)), True)
wx.CallAfter(wx.MessageBox, str(e), _("Post Error"), wx.OK | wx.ICON_ERROR, dialog_instance)
if not dialog_instance.IsBeingDeleted(): wx.CallAfter(dialog_instance.send_btn.Enable, True)
except Exception as e:
logger.error(f"Unexpected error sending post via dialog: {e}", exc_info=True)
output.speak(_("An unexpected error occurred: {error}").format(error=str(e)), True)
wx.CallAfter(wx.MessageBox, str(e), _("Error"), wx.OK | wx.ICON_ERROR, dialog_instance)
if not dialog_instance.IsBeingDeleted(): wx.CallAfter(dialog_instance.send_btn.Enable, True)
finally:
if not dialog_instance.IsBeingDeleted(): wx.CallAfter(wx.EndBusyCursor)
asyncio.create_task(do_send_post())
def post_reply(self, *args, **kwargs):
buffer = self.get_current_buffer()
if hasattr(buffer, "reply"):
return buffer.reply()
def send_dm(self, *args, **kwargs):
buffer = self.get_current_buffer()
if buffer is None:
output.speak(_("No buffer selected."), True)
return
if hasattr(buffer, "send_message"):
buffer.send_message()
else:
output.speak(_("Cannot send messages from this buffer."), True)
def post_retweet(self, *args, **kwargs):
buffer = self.get_current_buffer()
if hasattr(buffer, "share_item"):
return buffer.share_item()
session = getattr(buffer, "session", None)
if not session:
return
if getattr(session, "type", "") == "blueski":
item_uri = None
if hasattr(buffer, "get_selected_item_id"):
item_uri = buffer.get_selected_item_id()
if not item_uri:
output.speak(_("No item selected."), True)
return
if self.showing == False:
dlg = wx.TextEntryDialog(None, _("Write your quote (optional):"), _("Quote"))
if dlg.ShowModal() == wx.ID_OK:
text = dlg.GetValue().strip()
dlg.Destroy()
try:
if text:
uri = session.send_message(text, quote_uri=item_uri)
if uri:
output.speak(_("Quote posted."), True)
else:
output.speak(_("Failed to send quote."), True)
else:
# Confirm repost (share) depending on preference (boost_mode)
ask = True
try:
ask = session.settings["general"].get("boost_mode", "ask") == "ask"
except Exception:
ask = True
if ask:
confirm = wx.MessageDialog(None, _("Would you like to share this post?"), _("Boost"), wx.YES_NO|wx.ICON_QUESTION)
if confirm.ShowModal() != wx.ID_YES:
confirm.Destroy()
return
confirm.Destroy()
r_uri = session.repost(item_uri)
if r_uri:
output.speak(_("Post shared."), True)
else:
output.speak(_("Failed to share post."), True)
except Exception:
log.exception("Error sharing/quoting Bluesky post (invisible)")
output.speak(_("An error occurred while sharing the post."), True)
else:
dlg.Destroy()
return
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
dlg = ATPostDialog(caption=_("Quote post"))
if dlg.ShowModal() == wx.ID_OK:
text, files, cw_text, langs = dlg.get_payload()
dlg.Destroy()
try:
if text or files or cw_text:
uri = session.send_message(text, files=files, cw_text=cw_text, is_sensitive=bool(cw_text), languages=langs, quote_uri=item_uri)
if uri:
output.speak(_("Quote posted."), True)
try:
if hasattr(buffer, "start_stream"):
buffer.start_stream(mandatory=False, play_sound=False)
except Exception:
pass
else:
output.speak(_("Failed to send quote."), True)
else:
# Confirm repost without comment depending on preference
ask = True
try:
ask = session.settings["general"].get("boost_mode", "ask") == "ask"
except Exception:
ask = True
if ask:
confirm = wx.MessageDialog(self.view, _("Would you like to share this post?"), _("Boost"), wx.YES_NO|wx.ICON_QUESTION)
if confirm.ShowModal() != wx.ID_YES:
confirm.Destroy()
return
confirm.Destroy()
r_uri = session.repost(item_uri)
if r_uri:
output.speak(_("Post shared."), True)
else:
output.speak(_("Failed to share post."), True)
except Exception:
log.exception("Error sharing/quoting Bluesky post (dialog)")
output.speak(_("An error occurred while sharing the post."), True)
else:
dlg.Destroy()
return
def add_to_favourites(self, *args, **kwargs):
buffer = self.get_current_buffer()
if hasattr(buffer, "add_to_favorites"):
if hasattr(buffer, "add_to_favorites"): # Generic buffer method
return buffer.add_to_favorites()
elif hasattr(buffer, "toggle_favorite"):
return buffer.toggle_favorite()
elif buffer.session and buffer.session.KIND == "blueski":
# Fallback if buffer doesn't have the method but session is blueski (e.g. ChatBuffer)
# Chat messages can't be liked yet in this implementation, or handled by specific buffer
output.speak(_("This item cannot be liked."), True)
return
def remove_from_favourites(self, *args, **kwargs):
buffer = self.get_current_buffer()
if hasattr(buffer, "remove_from_favorites"):
if hasattr(buffer, "remove_from_favorites"): # Generic buffer method
return buffer.remove_from_favorites()
elif buffer.session and buffer.session.KIND == "blueski":
item_uri = buffer.get_selected_item_id()
if not item_uri:
output.speak(_("No item selected to unlike."), True)
return
like_uri = None
# Check viewer state from message_cache first, then panel's internal viewer_states
if buffer.session and hasattr(buffer.session, "message_cache") and item_uri in buffer.session.message_cache:
cached_post = buffer.session.message_cache[item_uri]
if isinstance(cached_post, dict) and isinstance(cached_post.get("viewer"), dict):
like_uri = cached_post["viewer"].get("like")
elif hasattr(cached_post, "viewer") and cached_post.viewer: # SDK model
like_uri = cached_post.viewer.like
if not like_uri and hasattr(buffer, "get_item_viewer_state"): # Fallback to panel's state if any
like_uri = buffer.get_item_viewer_state(item_uri, "like_uri")
if not like_uri:
output.speak(_("Could not find the original like record for this post, or it's already unliked."), True)
logger.warning(f"Attempted to unlike post {item_uri} but its like_uri was not found.")
return
social_handler = self.get_handler(buffer.session.KIND)
async def _unlike():
result = await social_handler.unlike_item(buffer.session, like_uri)
wx.CallAfter(output.speak, result["message"], True)
if result.get("status") == "success":
if hasattr(buffer, "store_item_viewer_state"):
wx.CallAfter(buffer.store_item_viewer_state, item_uri, "like_uri", None)
# Also update the item in message_cache
if buffer.session and hasattr(buffer.session, "message_cache") and item_uri in buffer.session.message_cache:
cached_post = buffer.session.message_cache[item_uri]
if isinstance(cached_post, dict) and isinstance(cached_post.get("viewer"), dict):
cached_post["viewer"]["like"] = None
elif hasattr(cached_post, "viewer") and cached_post.viewer:
cached_post.viewer.like = None
asyncio.create_task(_unlike())
def toggle_like(self, *args, **kwargs):
buffer = self.get_current_buffer()
@@ -613,6 +944,8 @@ class Controller(object):
def buffer_changed(self, *args, **kwargs):
buffer = self.get_current_buffer()
if buffer is None:
return
old_account = self.current_account
new_account = buffer.account
if new_account != old_account:
@@ -633,18 +966,81 @@ class Controller(object):
self.view.check_menuitem("autoread", autoread)
def update_menus(self, handler):
# Initialize storage for hidden menu items if not present
if not hasattr(self, "_hidden_menu_items"):
self._hidden_menu_items = {}
if not hasattr(self, "_original_menu_labels"):
self._original_menu_labels = {}
if hasattr(handler, "menus"):
for m in list(handler.menus.keys()):
if hasattr(self.view, m):
menu_item = getattr(self.view, m)
if handler.menus[m] == None:
# Store original label on first encounter
if m not in self._original_menu_labels:
self._original_menu_labels[m] = menu_item.GetItemLabel()
if handler.menus[m] == "HIDE":
# Actually hide the menu item by removing it from parent menu
if m not in self._hidden_menu_items:
try:
parent_menu = menu_item.GetMenu()
if parent_menu:
# Store menu item info for restoration
position = self._find_menu_item_position(parent_menu, menu_item)
item_id = menu_item.GetId()
# Remove returns the removed item - store that reference
removed_item = parent_menu.Remove(item_id)
if removed_item:
self._hidden_menu_items[m] = {
"menu": parent_menu,
"item": removed_item,
"position": position
}
except Exception as e:
log.error(f"Error hiding menu item {m}: {e}")
elif handler.menus[m] == None:
# Restore if hidden, then disable
self._restore_menu_item(m)
menu_item.Enable(False)
else:
# Restore if hidden, then enable with new label
self._restore_menu_item(m)
menu_item.Enable(True)
menu_item.SetItemLabel(handler.menus[m])
if hasattr(handler, "item_menu"):
self.view.menubar.SetMenuLabel(1, handler.item_menu)
def _find_menu_item_position(self, menu, item):
"""Find the position of a menu item within its parent menu."""
for i, menu_item in enumerate(menu.GetMenuItems()):
if menu_item.GetId() == item.GetId():
return i
return -1
def _restore_menu_item(self, name):
"""Restore a previously hidden menu item."""
if not hasattr(self, "_hidden_menu_items"):
return
if name not in self._hidden_menu_items:
return
info = self._hidden_menu_items[name]
parent_menu = info["menu"]
item = info["item"]
position = info["position"]
try:
# Re-insert at original position
if position >= 0 and position < parent_menu.GetMenuItemCount():
parent_menu.Insert(position, item)
else:
parent_menu.Append(item)
# Restore original label if available
if hasattr(self, "_original_menu_labels") and name in self._original_menu_labels:
item.SetItemLabel(self._original_menu_labels[name])
except Exception as e:
log.error(f"Error restoring menu item {name}: {e}")
del self._hidden_menu_items[name]
def fix_wrong_buffer(self):
buf = self.get_best_buffer()
if buf == None:
@@ -733,6 +1129,9 @@ class Controller(object):
output.speak(msg, True)
def next_account(self, *args, **kwargs):
if not self.accounts:
output.speak(_("No accounts available."), True)
return
try:
index = self.accounts.index(self.current_account)
except ValueError:
@@ -745,11 +1144,11 @@ class Controller(object):
self.current_account = account
buffer_object = self.get_first_buffer(account)
if buffer_object == None:
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
return
buff = self.view.search(buffer_object.name, account)
if buff == None:
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
return
self.view.change_buffer(buff)
buffer = self.get_current_buffer()
@@ -761,6 +1160,9 @@ class Controller(object):
output.speak(msg, True)
def previous_account(self, *args, **kwargs):
if not self.accounts:
output.speak(_("No accounts available."), True)
return
try:
index = self.accounts.index(self.current_account)
except ValueError:
@@ -773,11 +1175,11 @@ class Controller(object):
self.current_account = account
buffer_object = self.get_first_buffer(account)
if buffer_object == None:
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
return
buff = self.view.search(buffer_object.name, account)
if buff == None:
output.speak(_(u"{0}: This account is not logged into twitter.").format(account), True)
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
return
self.view.change_buffer(buff)
buffer = self.get_current_buffer()
@@ -1023,14 +1425,125 @@ class Controller(object):
log.exception("Error %s starting buffer %s on account %s, with args %r and kwargs %r." % (str(err), i.name, i.account, i.args, i.kwargs))
def update_buffer(self, *args, **kwargs):
bf = self.get_current_buffer()
if not hasattr(bf, "start_stream"):
output.speak(_(u"Unable to update this buffer."))
"""Handles the 'Update buffer' menu command to fetch newest items."""
bf = self.get_current_buffer() # bf is the buffer panel instance
if not bf or not hasattr(bf, "session") or not bf.session:
output.speak(_(u"No active session for this buffer."), True)
return
output.speak(_(u"Updating buffer..."))
n = bf.start_stream(mandatory=True, avoid_autoreading=True)
if n != None:
output.speak(_(u"{0} items retrieved").format(n,))
output.speak(_(u"Updating buffer..."), True)
session = bf.session
output.speak(_(u"Updating buffer..."), True)
session = bf.session
import threading
is_blueski = (getattr(session, "KIND", None) == "blueski" or getattr(session, "type", None) == "blueski")
def do_update_sync():
new_ids = []
try:
if is_blueski:
if hasattr(bf, "start_stream"):
count = bf.start_stream(mandatory=True)
if count: new_ids = [str(x) for x in range(count)]
else:
wx.CallAfter(output.speak, _(u"This buffer type cannot be updated."), True)
return
else: # Generic fallback for other sessions
# If they are async, this might be tricky in a thread without a loop
# But most old sessions in TWBlue are sync (using threads)
if hasattr(bf, "start_stream"):
count = bf.start_stream(mandatory=True, avoid_autoreading=True)
if count: new_ids = [str(x) for x in range(count)]
else:
wx.CallAfter(output.speak, _(u"Unable to update this buffer."), True)
return
# Generic feedback
if bf.type in ["home_timeline", "user_timeline", "notifications", "mentions"]:
wx.CallAfter(output.speak, _("{0} new items.").format(len(new_ids)), True)
except Exception as e:
log.exception("Error updating buffer %s", bf.name)
wx.CallAfter(output.speak, _("An error occurred while updating the buffer."), True)
if is_blueski:
threading.Thread(target=do_update_sync).start()
else:
# Original async logic for others if needed, but likely they are sync too.
# Assuming TWBlue architecture is mostly thread-based for legacy sessions.
# If we have an async loop running, we could use it for async-capable sessions.
# For safety, let's use the thread approach generally if we are not sure about the loop state.
threading.Thread(target=do_update_sync).start()
def get_more_items(self, *args, **kwargs):
"""Handles 'Load previous items' menu command."""
bf = self.get_current_buffer() # bf is the buffer panel instance
if not bf or not hasattr(bf, "session") or not bf.session:
output.speak(_(u"No active session for this buffer."), True)
return
session = bf.session
# The buffer panel (bf) needs to store its own cursor for pagination of older items
# e.g., bf.pagination_cursor or bf.older_items_cursor
# This cursor should be set by the result of previous fetch_..._timeline(new_only=False) calls.
# For Blueski, session methods like fetch_home_timeline store their own cursor (e.g., session.home_timeline_cursor)
# The panel (bf) itself should manage its own cursor for "load more"
current_cursor = None
can_load_more_natively = False
if getattr(session, "KIND", None) == "blueski":
if hasattr(bf, "load_more_posts"): # For BlueskiUserTimelinePanel & BlueskiHomeTimelinePanel
can_load_more_natively = True
if hasattr(bf, "load_more_posts"):
can_load_more_natively = True
elif hasattr(bf, "load_more_users"):
can_load_more_natively = True
elif bf.type == "notifications" and hasattr(bf, "load_more_notifications"): # Check for specific load_more
can_load_more_natively = True
elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"): # Fallback for notifications to refresh
# If load_more_notifications doesn't exist, 'Load More' will just refresh.
can_load_more_natively = True # It will call refresh_notifications via the final 'else'
else:
if hasattr(bf, "get_more_items"):
return bf.get_more_items()
else:
output.speak(_(u"This buffer does not support loading more items in this way."), True)
return
else: # For other non-Blueski session types
if hasattr(bf, "get_more_items"):
return bf.get_more_items()
else:
output.speak(_(u"This buffer does not support loading more items."), True)
return
output.speak(_(u"Loading more items..."), True)
async def do_load_more():
try:
if session.KIND == "blueski":
if hasattr(bf, "load_more_posts"):
await bf.load_more_posts(limit=config.app["app-settings"].get("items_per_request", 20))
elif hasattr(bf, "load_more_users"):
await bf.load_more_users(limit=config.app["app-settings"].get("items_per_request", 30))
elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"):
# This will re-fetch recent, not older. A true "load_more_notifications(cursor=...)" is needed for that.
wx.CallAfter(output.speak, _("Refreshing notifications..."), True)
await bf.refresh_notifications(limit=config.app["app-settings"].get("items_per_request", 20))
# Feedback is handled by panel methods for consistency
except NotificationError as e:
wx.CallAfter(output.speak, str(e), True)
except Exception as e_general:
logger.error(f"Error loading more items for buffer {bf.name}: {e_general}", exc_info=True)
output.speak(_("An error occurred while loading more items."), True)
wx.CallAfter(asyncio.create_task, do_load_more())
def buffer_title_changed(self, buffer):
if buffer.name.endswith("-timeline"):
@@ -1097,6 +1610,33 @@ class Controller(object):
# if "direct_messages" not in buffer.session.settings["other_buffers"]["muted_buffers"]:
# self.notify(buffer.session, sound_to_play)
def blueski_new_item(self, item, session_name, _buffers):
"""Handle new items from Bluesky polling."""
sound_to_play = None
for buff in _buffers:
buffer = self.search_buffer(buff, session_name)
if buffer is None or buffer.session.get_name() != session_name:
continue
if hasattr(buffer, "add_new_item"):
buffer.add_new_item(item)
# Determine sound to play
if buff == "notifications":
sound_to_play = "notification_received.ogg"
elif buff == "home_timeline":
sound_to_play = "tweet_received.ogg"
elif "timeline" in buff:
sound_to_play = "tweet_timeline.ogg"
else:
sound_to_play = None
# Play sound if buffer is not muted
if sound_to_play is not None:
try:
muted = buffer.session.settings["other_buffers"].get("muted_buffers", [])
if buff not in muted:
self.notify(buffer.session, sound_to_play)
except Exception:
pass
def mastodon_error_post(self, name, reply_to, visibility, posts, language):
home = self.search_buffer("home_timeline", name)
if home != None:
@@ -1123,21 +1663,57 @@ class Controller(object):
def user_details(self, *args):
"""Displays a user's profile."""
log.debug("Showing user profile...")
buffer = self.get_best_buffer()
buffer = self.get_current_buffer() # Use current buffer to get context if item is selected
if not buffer or not buffer.session:
buffer = self.get_best_buffer() # Fallback if current buffer has no session
if not buffer or not buffer.session:
output.speak(_("No active session to view user details."), True)
return
handler = self.get_handler(type=buffer.session.type)
if handler and hasattr(handler, 'user_details'):
handler.user_details(buffer)
# The handler's user_details method is responsible for extracting context
# (e.g., selected user) from the buffer and displaying the profile.
# For Blueski, handler.user_details calls the ShowUserProfileDialog.
result = handler.user_details(buffer)
if asyncio.iscoroutine(result):
call_threaded(asyncio.run, result)
else:
output.speak(_("This session type does not support viewing user details in this way."), True)
def openPostTimeline(self, *args, user=None): # "user" here is often the user object from selected item
"""Opens selected user's posts timeline. Renamed to open_user_timeline in handlers for clarity."""
current_buffer = self.get_current_buffer()
if not current_buffer or not current_buffer.session:
current_buffer = self.get_best_buffer()
if not current_buffer or not current_buffer.session:
output.speak(_("No active session available."), True)
return
session_to_use = current_buffer.session
handler = self.get_handler(type=session_to_use.type)
# Prefer the new standardized 'open_user_timeline'
if hasattr(handler, 'open_user_timeline'):
user_payload = user # Use passed 'user' if available
if user_payload is None and hasattr(current_buffer, 'get_selected_item_author_details'):
author_details = current_buffer.get_selected_item_author_details()
if author_details:
user_payload = author_details
result = handler.open_user_timeline(main_controller=self, session=session_to_use, user_payload=user_payload)
if asyncio.iscoroutine(result):
call_threaded(asyncio.run, result)
elif hasattr(handler, 'openPostTimeline'): # Fallback for older handler structure
# This path might not correctly pass main_controller if the old handler expects it differently
handler.openPostTimeline(self, current_buffer, user)
else:
output.speak(_("This session type does not support opening user timelines directly."), True)
def openPostTimeline(self, *args, user=None):
"""Opens selected user's posts timeline
Parameters:
args: Other argument. Useful when binding to widgets.
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
"""
buffer = self.get_best_buffer()
handler = self.get_handler(type=buffer.session.type)
if handler and hasattr(handler, 'openPostTimeline'):
handler.openPostTimeline(self, buffer, user)
def openFollowersTimeline(self, *args, user=None):
"""Opens selected user's followers timeline
@@ -1145,10 +1721,30 @@ class Controller(object):
args: Other argument. Useful when binding to widgets.
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
"""
buffer = self.get_best_buffer()
handler = self.get_handler(type=buffer.session.type)
if handler and hasattr(handler, 'openFollowersTimeline'):
handler.openFollowersTimeline(self, buffer, user)
current_buffer = self.get_current_buffer()
if not current_buffer or not current_buffer.session:
current_buffer = self.get_best_buffer()
if not current_buffer or not current_buffer.session:
output.speak(_("No active session available."), True)
return
session_to_use = current_buffer.session
handler = self.get_handler(type=session_to_use.type)
if user is None and hasattr(current_buffer, 'get_selected_item_author_details'):
author_details = current_buffer.get_selected_item_author_details()
if author_details: user = author_details
if handler and hasattr(handler, 'open_followers_timeline'):
result = handler.open_followers_timeline(main_controller=self, session=session_to_use, user_payload=user)
if asyncio.iscoroutine(result):
call_threaded(asyncio.run, result)
elif handler and hasattr(handler, 'openFollowersTimeline'): # Fallback
handler.openFollowersTimeline(self, current_buffer, user)
else:
output.speak(_("This session type does not support opening followers list."), True)
def openFollowingTimeline(self, *args, user=None):
"""Opens selected user's following timeline
@@ -1156,12 +1752,32 @@ class Controller(object):
args: Other argument. Useful when binding to widgets.
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
"""
buffer = self.get_best_buffer()
handler = self.get_handler(type=buffer.session.type)
if handler and hasattr(handler, 'openFollowingTimeline'):
handler.openFollowingTimeline(self, buffer, user)
current_buffer = self.get_current_buffer()
if not current_buffer or not current_buffer.session:
current_buffer = self.get_best_buffer()
def community_timeline(self, *args, user=None):
if not current_buffer or not current_buffer.session:
output.speak(_("No active session available."), True)
return
session_to_use = current_buffer.session
handler = self.get_handler(type=session_to_use.type)
if user is None and hasattr(current_buffer, 'get_selected_item_author_details'):
author_details = current_buffer.get_selected_item_author_details()
if author_details: user = author_details
if handler and hasattr(handler, 'open_following_timeline'):
result = handler.open_following_timeline(main_controller=self, session=session_to_use, user_payload=user)
if asyncio.iscoroutine(result):
call_threaded(asyncio.run, result)
elif handler and hasattr(handler, 'openFollowingTimeline'): # Fallback
handler.openFollowingTimeline(self, current_buffer, user)
else:
output.speak(_("This session type does not support opening following list."), True)
def community_timeline(self, *args, user=None): # user param seems unused here based on mastodon impl
buffer = self.get_best_buffer()
handler = self.get_handler(type=buffer.session.type)
if handler and hasattr(handler, 'community_timeline'):
@@ -1177,4 +1793,4 @@ class Controller(object):
buffer = self.get_best_buffer()
handler = self.get_handler(type=buffer.session.type)
if handler and hasattr(handler, 'manage_filters'):
handler.manage_filters(self, buffer)
handler.manage_filters(self, buffer)

View File

@@ -456,4 +456,4 @@ class text(messages.basicMessage):
self.message = postDialogs.viewText(title=title, text=text, *args, **kwargs)
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)

View File

@@ -39,4 +39,4 @@ class EditTemplate(object):
else:
return dialog.template.GetValue()
else:
return ""
return ""

View File

@@ -1 +1,2 @@
from . import mastodon
from . import mastodon
from . import blueski

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
actions = {
"up": _(u"Go up in the current buffer"),
"down": _(u"Go down in the current buffer"),
"left": _(u"Go to the previous buffer"),
"right": _(u"Go to the next buffer"),
"next_account": _(u"Focus the next session"),
"previous_account": _(u"Focus the previous session"),
"show_hide": _(u"Show or hide the GUI"),
"post_tweet": _("Make a new post"),
"post_reply": _(u"Reply"),
"post_retweet": _(u"Repost"),
"send_dm": _(u"Send direct message"),
"add_to_favourites": _("Add post to likes"),
"remove_from_favourites": _(u"Remove post from likes"),
"toggle_like": _("Add/remove post from likes"),
"follow": _(u"Open the user actions dialogue"),
"user_details": _(u"See user details"),
"view_item": _(u"Show post"),
"exit": _(u"Quit"),
"open_timeline": _(u"Open user timeline"),
"remove_buffer": _(u"Destroy buffer"),
"url": _(u"Open URL"),
"open_in_browser": _(u"View in browser"),
"volume_up": _(u"Increase volume by 5%"),
"volume_down": _(u"Decrease volume by 5%"),
"go_home": _(u"Jump to the first element of a buffer"),
"go_end": _(u"Jump to the last element of the current buffer"),
"go_page_up": _(u"Jump 20 elements up in the current buffer"),
"go_page_down": _(u"Jump 20 elements down in the current buffer"),
"delete": _("Delete post"),
"clear_buffer": _(u"Empty the current buffer"),
"repeat_item": _(u"Repeat last item"),
"copy_to_clipboard": _(u"Copy to clipboard"),
"toggle_buffer_mute": _(u"Mute/unmute the active buffer"),
"toggle_session_mute": _(u"Mute/unmute the current session"),
"toggle_autoread": _(u"Toggle the automatic reading of incoming posts in the active buffer"),
"search": _(u"Search"),
"find": _(u"Find a string in the currently focused buffer"),
"edit_keystrokes": _(u"Show the keystroke editor"),
"get_more_items": _(u"Load previous items"),
"open_conversation": _(u"View conversation"),
"check_for_updates": _(u"Check and download updates"),
"configuration": _(u"Opens the global settings dialogue"),
"accountConfiguration": _(u"Opens the account settings dialogue"),
"audio": _(u"Try to play a media file"),
"update_buffer": _(u"Updates the buffer and retrieves possible lost items there."),
"ocr_image": _(u"Extracts the text from a picture and displays the result in a dialog."),
"seekLeft": _(u"Seek media backward"),
"seekRight": _(u"Seek media forward"),
"manage_accounts": _(u"Manage accounts"),
}

View File

@@ -63,13 +63,17 @@ class list(object):
def get_selected(self):
if self.system == "Windows":
return self.list.GetFocusedItem()
item = self.list.GetFocusedItem()
if item == -1:
item = self.list.GetFirstSelected()
return item
else:
return self.list.GetSelection()
def select_item(self, pos):
if self.system == "Windows":
self.list.Focus(pos)
self.list.Select(pos)
else:
self.list.SetSelection(pos)

View File

@@ -11,10 +11,13 @@ import paths
import config_utils
import config
import application
import asyncio # For async event handling
import wx
from pubsub import pub
from controller import settings
from sessions.mastodon import session as MastodonSession
from sessions.gotosocial import session as GotosocialSession
from sessions.blueski import session as BlueskiSession # Import Blueski session
from . import manager
from . import wxUI as view
@@ -35,6 +38,7 @@ class sessionManagerController(object):
# Initialize the manager, responsible for storing session objects.
manager.setup()
self.view = view.sessionManagerWindow()
# Handle new account synchronously on the UI thread
pub.subscribe(self.manage_new_account, "sessionmanager.new_account")
pub.subscribe(self.remove_account, "sessionmanager.remove_account")
if self.started == False:
@@ -67,12 +71,44 @@ class sessionManagerController(object):
continue
if config_test.get("mastodon") != None:
name = _("{account_name}@{instance} (Mastodon)").format(account_name=config_test["mastodon"]["user_name"], instance=config_test["mastodon"]["instance"].replace("https://", ""))
if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "":
if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "": # Basic validation
sessionsList.append(name)
self.sessions.append(dict(type=config_test["mastodon"].get("type", "mastodon"), id=i))
else:
elif config_test.get("blueski") != None: # Check for Blueski config
handle = config_test["blueski"].get("handle")
did = config_test["blueski"].get("did") # DID confirms it was authorized
if handle and did:
name = _("{handle} (Bluesky)").format(handle=handle)
sessionsList.append(name)
self.sessions.append(dict(type="blueski", id=i))
else: # Incomplete config, might be an old attempt or error
log.warning(f"Incomplete Blueski session config found for {i}, skipping.")
# Optionally delete malformed config here too
try:
log.debug("Deleting incomplete Blueski session %s" % (i,))
shutil.rmtree(os.path.join(paths.config_path(), i))
except Exception as e:
log.exception(f"Error deleting incomplete Blueski session {i}: {e}")
continue
elif config_test.get("atprotosocial") != None: # Legacy config namespace
handle = config_test["atprotosocial"].get("handle")
did = config_test["atprotosocial"].get("did")
if handle and did:
name = _("{handle} (Bluesky)").format(handle=handle)
sessionsList.append(name)
self.sessions.append(dict(type="blueski", id=i))
else: # Incomplete config, might be an old attempt or error
log.warning(f"Incomplete Blueski session config found for {i}, skipping.")
# Optionally delete malformed config here too
try:
log.debug("Deleting incomplete Blueski session %s" % (i,))
shutil.rmtree(os.path.join(paths.config_path(), i))
except Exception as e:
log.exception(f"Error deleting incomplete Blueski session {i}: {e}")
continue
else: # Unknown or other session type not explicitly handled here for display
try:
log.debug("Deleting session %s" % (i,))
log.debug("Deleting session %s with unknown type" % (i,))
shutil.rmtree(os.path.join(paths.config_path(), i))
except:
output.speak("An exception was raised while attempting to clean malformed session data. See the error log for details. If this message persists, contact the developers.",True)
@@ -97,36 +133,112 @@ class sessionManagerController(object):
s = MastodonSession.Session(i.get("id"))
elif i.get("type") == "gotosocial":
s = GotosocialSession.Session(i.get("id"))
s.get_configuration()
if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
try:
elif i.get("type") == "blueski": # Handle Blueski session type
s = BlueskiSession.Session(i.get("id"))
else:
log.warning(f"Unknown session type '{i.get('type')}' for ID {i.get('id')}. Skipping.")
continue
s.get_configuration() # Load per-session configuration
# For Blueski, this loads from its specific config file.
# Login is now primarily handled by session.start() via mainController,
# which calls _ensure_dependencies_ready().
# Explicit s.login() here might be redundant or premature if full app context isn't ready.
# We'll rely on the mainController to call session.start() which handles login.
# if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
# try:
# # For Blueski, login is async and handled by session.start()
# # if not s.is_ready(): # Only attempt login if not already ready
# # log.info(f"Session {s.uid} ({s.kind}) not ready, login will be attempted by start().")
# pass
# except Exception as e:
# log.exception(f"Exception during pre-emptive login check for session {s.uid} ({s.kind}).")
# continue
# Try to auto-login for Blueski so the app starts with buffers ready
try:
if i.get("type") == "blueski":
s.login()
except Exception as e:
log.exception("Exception during login on a TWBlue session.")
continue
sessions.sessions[i.get("id")] = s
self.new_sessions[i.get("id")] = s
except Exception:
log.exception("Auto-login failed for Blueski session %s", i.get("id"))
sessions.sessions[i.get("id")] = s # Add to global session store
self.new_sessions[i.get("id")] = s # Track as a new session for this manager instance
# self.view.destroy()
def show_auth_error(self):
error = view.auth_error()
error = view.auth_error() # This seems to be a generic auth error display
def manage_new_account(self, type):
# Generic settings for all account types.
location = (str(time.time())[-6:])
location = (str(time.time())[-6:]) # Unique ID for the session config directory
log.debug("Creating %s session in the %s path" % (type, location))
s: sessions.base.baseSession | None = None # Type hint for session object
if type == "mastodon":
s = MastodonSession.Session(location)
result = s.authorise()
if result == True:
self.sessions.append(dict(id=location, type=s.settings["mastodon"].get("type")))
self.view.add_new_session_to_list()
elif type == "blueski":
s = BlueskiSession.Session(location)
# Add other session types here if needed (e.g., gotosocial)
# elif type == "gotosocial":
# s = GotosocialSession.Session(location)
if not s:
log.error(f"Unsupported session type for creation: {type}")
self.view.show_unauthorised_error() # Or a more generic "cannot create" error
return
try:
result = s.authorise()
if result == True:
# Session config (handle, did for atproto) should be saved by authorise/login.
# Here we just update the session manager's internal list and UI.
session_type_for_dict = type # Store the actual type string
if hasattr(s, 'settings') and s.settings and s.settings.get(type) and s.settings[type].get("type"):
# Mastodon might have a more specific type in its settings (e.g. gotosocial)
session_type_for_dict = s.settings[type].get("type")
self.sessions.append(dict(id=location, type=session_type_for_dict))
self.view.add_new_session_to_list() # This should update the UI list
# The session object 's' itself isn't stored in self.new_sessions until do_ok if app is restarting
# But for immediate use if not restarting, it might need to be added to sessions.sessions
sessions.sessions[location] = s # Make it globally available immediately
self.new_sessions[location] = s
# Sync with global config
if location not in config.app["sessions"]["sessions"]:
config.app["sessions"]["sessions"].append(location)
config.app.write()
else: # Authorise returned False or None
self.view.show_unauthorised_error()
# Clean up the directory if authorization failed and nothing was saved
if os.path.exists(os.path.join(paths.config_path(), location)):
try:
shutil.rmtree(os.path.join(paths.config_path(), location))
log.info(f"Cleaned up directory for failed auth: {location}")
except Exception as e_rm:
log.error(f"Error cleaning up directory {location} after failed auth: {e_rm}")
except Exception as e:
log.error(f"Error during new account authorization for type {type}: {e}", exc_info=True)
self.view.show_unauthorised_error() # Show generic error
# Clean up
if os.path.exists(os.path.join(paths.config_path(), location)):
try:
shutil.rmtree(os.path.join(paths.config_path(), location))
except Exception as e_rm:
log.error(f"Error cleaning up directory {location} after exception: {e_rm}")
def remove_account(self, index):
selected_account = self.sessions[index]
self.view.remove_session(index)
self.removed_sessions.append(selected_account.get("id"))
self.sessions.remove(selected_account)
if selected_account.get("id") in config.app["sessions"]["sessions"]:
config.app["sessions"]["sessions"].remove(selected_account.get("id"))
config.app.write()
shutil.rmtree(path=os.path.join(paths.config_path(), selected_account.get("id")), ignore_errors=True)
def configuration(self):

View File

@@ -53,6 +53,10 @@ class sessionManagerWindow(wx.Dialog):
menu = wx.Menu()
mastodon = menu.Append(wx.ID_ANY, _("Mastodon"))
menu.Bind(wx.EVT_MENU, self.on_new_mastodon_account, mastodon)
blueski = menu.Append(wx.ID_ANY, _("Bluesky"))
menu.Bind(wx.EVT_MENU, self.on_new_blueski_account, blueski)
self.PopupMenu(menu, self.new.GetPosition())
def on_new_mastodon_account(self, *args, **kwargs):
@@ -62,6 +66,13 @@ class sessionManagerWindow(wx.Dialog):
if response == wx.ID_YES:
pub.sendMessage("sessionmanager.new_account", type="mastodon")
def on_new_blueski_account(self, *args, **kwargs):
dlg = wx.MessageDialog(self, _("You will be prompted for your Bluesky data (user handle and App Password) to authorize TWBlue. Would you like to authorize your account now?"), _(u"Bluesky Authorization"), wx.YES_NO)
response = dlg.ShowModal()
dlg.Destroy()
if response == wx.ID_YES:
pub.sendMessage("sessionmanager.new_account", type="blueski")
def add_new_session_to_list(self):
total = self.list.get_count()
name = _(u"Authorized account %d") % (total+1)

View File

@@ -59,7 +59,9 @@ class baseSession(object):
if not os.path.exists(path):
log.debug("Creating %s path" % (os.path.join(paths.config_path(), path),))
os.mkdir(path)
config.app["sessions"]["sessions"].append(id)
if self.session_id not in config.app["sessions"]["sessions"]:
config.app["sessions"]["sessions"].append(self.session_id)
config.app.write()
def get_configuration(self):
""" Get settings for a session."""

View File

@@ -0,0 +1,3 @@
from .session import Session
__all__ = ["Session"]

View File

@@ -0,0 +1,478 @@
# -*- coding: utf-8 -*-
"""
Compose functions for Bluesky content display in TWBlue.
These functions format API data into user-readable strings for display in
list controls. They follow the TWBlue compose function pattern:
compose_function(item, db, relative_times, show_screen_names, session)
Returns a list of strings for display columns.
"""
import logging
import arrow
import languageHandler
from sessions.blueski import utils
log = logging.getLogger("sessions.blueski.compose")
def compose_post(post, db, settings, relative_times, show_screen_names=False, safe=True):
"""
Compose a Bluesky post into a list of strings for display.
Format matches Mastodon: [user+", ", text, date+", ", source]
"""
def g(obj, key, default=None):
"""Helper to get attribute from dict or object."""
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
# Resolve Post View or Feed View structure
actual_post = g(post, "post", post)
record = g(actual_post, "record", {})
author = g(actual_post, "author", {})
# Original author info
original_handle = g(author, "handle", "")
original_display_name = g(author, "displayName") or g(author, "display_name") or original_handle or "Unknown"
# Check if this is a repost
reason = g(post, "reason", None)
is_repost = False
reposter_handle = ""
reposter_display_name = ""
if reason:
rtype = g(reason, "$type") or g(reason, "py_type")
if rtype and "reasonRepost" in rtype:
is_repost = True
by = g(reason, "by", {})
reposter_handle = g(by, "handle", "")
reposter_display_name = g(by, "displayName") or g(by, "display_name") or reposter_handle
# User column: show reposter if repost, otherwise original author (like Mastodon)
if is_repost and reposter_handle:
if show_screen_names:
user_str = f"@{reposter_handle}"
else:
if reposter_display_name and reposter_display_name != reposter_handle:
user_str = f"{reposter_display_name} (@{reposter_handle})"
else:
user_str = f"@{reposter_handle}"
else:
if show_screen_names:
user_str = f"@{original_handle}"
else:
if original_display_name and original_display_name != original_handle:
user_str = f"{original_display_name} (@{original_handle})"
else:
user_str = f"@{original_handle}"
# Text
original_text = g(record, "text", "")
# Build text - if repost, format like Mastodon: "Reposted from @original: text"
if is_repost:
text = _("Reposted from @{}: {}").format(original_handle, original_text)
else:
text = original_text
reply_to_handle = utils.extract_reply_to_handle(post)
if reply_to_handle:
if text:
text = _("Replying to @{}: {}").format(reply_to_handle, text)
else:
text = _("Replying to @{}").format(reply_to_handle)
# Check facets for links not visible in text and append them
facets = g(record, "facets", []) or []
hidden_urls = []
for facet in facets:
features = g(facet, "features", []) or []
for feature in features:
ftype = g(feature, "$type") or g(feature, "py_type") or ""
if "link" in ftype.lower():
uri = g(feature, "uri", "")
if uri and uri not in text and uri not in hidden_urls:
# Check if a truncated version is in text (e.g., "example.com/path...")
# by checking if the domain is present
domain_match = False
try:
from urllib.parse import urlparse
parsed = urlparse(uri)
domain = parsed.netloc.replace("www.", "")
if domain and domain in text:
domain_match = True
except:
pass
if not domain_match:
hidden_urls.append(uri)
if hidden_urls:
text += " " + " ".join(f"[{url}]" for url in hidden_urls)
# Labels / Content Warning
labels = g(actual_post, "labels", [])
cw_text = ""
for label in labels:
val = g(label, "val", "")
if val in ["!warn", "porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]:
if not cw_text:
cw_text = _("Sensitive Content")
elif val.startswith("warn:"):
cw_text = val.split("warn:", 1)[-1].strip()
if cw_text:
text = f"CW: {cw_text}\n\n{text}"
# Embeds (Images, Links)
embed = g(actual_post, "embed", None)
if embed:
etype = g(embed, "$type") or g(embed, "py_type")
# Images
if etype and ("images" in etype):
images = g(embed, "images", [])
if images:
text += f" [{len(images)} {_('images')}]"
if etype and ("recordWithMedia" in etype):
media = g(embed, "media", {})
mtype = g(media, "$type") or g(media, "py_type")
if mtype and "images" in mtype:
images = g(media, "images", [])
if images:
text += f" [{len(images)} {_('images')}]"
elif mtype and "external" in mtype:
ext = g(media, "external", {})
title = g(ext, "title", "")
if title:
text += f" [{_('Link')}: {title}]"
elif etype and ("external" in etype):
ext = g(embed, "external", {})
title = g(ext, "title", "")
if title:
text += f" [{_('Link')}: {title}]"
quote_info = utils.extract_quoted_post_info(post)
if quote_info:
if quote_info["kind"] == "not_found":
text += f" [{_('Quoted post not found')}]"
elif quote_info["kind"] == "blocked":
text += f" [{_('Quoted post blocked')}]"
elif quote_info["kind"] == "feed":
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
else:
q_handle = quote_info.get("handle", "unknown")
q_text = quote_info.get("text", "")
if q_text:
text += " " + _("Quoting @{}: {}").format(q_handle, q_text)
else:
text += " " + _("Quoting @{}").format(q_handle)
# Add full URLs from quoted content when they are not visible in text.
for uri in quote_info.get("urls", []):
if uri and uri not in text:
text += f" [{uri}]"
# Date
indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "")
ts_str = ""
if indexed_at:
try:
ts = arrow.get(indexed_at)
if relative_times:
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
else:
ts_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception:
ts_str = str(indexed_at)[:16].replace("T", " ")
# Source / Client
source = "Bluesky"
# Format like Mastodon: add ", " after user and date
return [user_str + ", ", text, ts_str + ", ", source]
def compose_notification(notification, db, settings, relative_times, show_screen_names=False, safe=True):
"""
Compose a Bluesky notification into a list of strings for display.
Format matches Mastodon: [user, text, date]
"""
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
# Author of the notification (who performed the action)
author = g(notification, "author", {})
handle = g(author, "handle", "unknown")
display_name = g(author, "displayName") or g(author, "display_name") or handle
if show_screen_names:
user_str = f"@{handle}"
else:
if display_name and display_name != handle:
user_str = f"{display_name} (@{handle})"
else:
user_str = f"@{handle}"
# Notification reason/type
reason = g(notification, "reason", "unknown")
# Get post text - try multiple locations depending on notification type
record = g(notification, "record", {})
post_text = ""
# For mentions, replies, quotes: text is in the record itself
post_text = g(record, "text", "")
# For likes and reposts: try to get the subject post text
if not post_text and reason in ("like", "repost"):
# First check for hydrated subject text (added by NotificationBuffer)
post_text = g(notification, "_subject_text", "")
# Check if there's a reasonSubject with embedded post data
if not post_text:
reason_subject = g(notification, "reasonSubject") or g(notification, "reason_subject")
if reason_subject:
# Sometimes the subject post is embedded
subject_record = g(reason_subject, "record", {})
post_text = g(subject_record, "text", "")
# Check if there's subject post data in other locations
if not post_text:
subject = g(record, "subject", {})
subject_text = g(subject, "text", "")
if subject_text:
post_text = subject_text
# Format: action text without username (username is already in column 0)
if reason == "like":
if post_text:
text = _("has added to favorites: {status}").format(status=post_text)
else:
text = _("has added to favorites")
elif reason == "repost":
if post_text:
text = _("has reposted: {status}").format(status=post_text)
else:
text = _("has reposted")
elif reason == "follow":
text = _("has followed you.")
elif reason == "mention":
if post_text:
text = _("has mentioned you: {status}").format(status=post_text)
else:
text = _("has mentioned you")
elif reason == "reply":
if post_text:
text = _("has replied: {status}").format(status=post_text)
else:
text = _("has replied")
elif reason == "quote":
if post_text:
text = _("has quoted your post: {status}").format(status=post_text)
else:
text = _("has quoted your post")
elif reason == "starterpack-joined":
text = _("has joined your starter pack.")
else:
text = reason
# Date
indexed_at = g(notification, "indexedAt", "") or g(notification, "indexed_at", "")
ts_str = ""
if indexed_at:
try:
ts = arrow.get(indexed_at)
if relative_times:
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
else:
ts_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception:
ts_str = str(indexed_at)[:16].replace("T", " ")
return [user_str, text, ts_str]
def compose_user(user, db, settings, relative_times, show_screen_names=False, safe=True):
"""
Compose a Bluesky user profile for list display.
Format matches Mastodon: single string with all info.
"""
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
def resolve_profile(obj):
if g(obj, "handle") or g(obj, "did"):
return obj
for key in ("subject", "actor", "profile", "user"):
nested = g(obj, key)
if nested and (g(nested, "handle") or g(nested, "did")):
return nested
return obj
profile = resolve_profile(user)
handle = g(profile, "handle", "unknown")
display_name = g(profile, "displayName") or g(profile, "display_name") or handle
followers = g(profile, "followersCount") or g(profile, "followers_count") or 0
following = g(profile, "followsCount") or g(profile, "follows_count") or 0
posts = g(profile, "postsCount") or g(profile, "posts_count") or 0
created_at = g(profile, "createdAt") or g(profile, "created_at")
ts = ""
if created_at:
try:
original_date = arrow.get(created_at)
if relative_times:
ts = original_date.humanize(locale=languageHandler.curLang[:2])
else:
offset = db.get("utc_offset", 0) if isinstance(db, dict) else 0
ts = original_date.shift(hours=offset).format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception:
ts = ""
# Format like Mastodon: "Name (@handle). X followers, Y following, Z posts. Joined date"
# Use the exact same translatable string as Mastodon (sessions/mastodon/compose.py)
if not ts:
ts = _("unknown")
return [_("%s (@%s). %s followers, %s following, %s posts. Joined %s") % (display_name, handle, followers, following, posts, ts)]
def compose_convo(convo, db, settings, relative_times, show_screen_names=False, safe=True):
"""
Compose a Bluesky chat conversation for list display.
Args:
convo: Conversation dict or ATProto model
db: Session database dict
settings: Session settings
relative_times: If True, use relative time formatting
show_screen_names: If True, show only @handle
safe: If True, handle exceptions gracefully
Returns:
List of strings: [Participants, Last Message, Date]
"""
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
members = g(convo, "members", [])
self_did = db.get("user_id") if isinstance(db, dict) else None
# Build a local DID→name map from conversation members for sender resolution
member_names = {}
for m in members:
did = g(m, "did", None)
if did:
name = g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
member_names[did] = name
# Get other participants (exclude self)
others = []
for m in members:
did = g(m, "did", None)
if self_did and did == self_did:
continue
label = member_names.get(did, "unknown") if did else g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
others.append(label)
if not others:
others = [member_names.get(g(m, "did"), "unknown") if g(m, "did") else "unknown" for m in members]
participants = ", ".join(others)
# Last message
last_msg_obj = g(convo, "lastMessage") or g(convo, "last_message")
last_text = ""
last_sender = ""
if last_msg_obj:
last_text = g(last_msg_obj, "text", "")
sender = g(last_msg_obj, "sender", None)
if sender:
last_sender = g(sender, "display_name") or g(sender, "displayName") or g(sender, "handle")
if not last_sender:
# Resolve DID via local member map
sdid = g(sender, "did")
if sdid:
last_sender = member_names.get(sdid, "")
if not last_sender:
last_sender = sdid or ""
# Date
date_str = ""
if last_msg_obj:
sent_at = g(last_msg_obj, "sentAt") or g(last_msg_obj, "sent_at")
if sent_at:
try:
ts = arrow.get(sent_at)
if relative_times:
date_str = ts.humanize(locale=languageHandler.curLang[:2])
else:
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception:
date_str = str(sent_at)[:16]
if last_sender and last_text:
last_text = _("Last message from {user}: {text}").format(user=last_sender, text=last_text)
elif last_text:
last_text = _("Last message: {text}").format(text=last_text)
return [participants, last_text, date_str]
def compose_chat_message(msg, db, settings, relative_times, show_screen_names=False, safe=True):
"""
Compose an individual chat message for display.
Args:
msg: Chat message dict or ATProto model
db: Session database dict
settings: Session settings
relative_times: If True, use relative time formatting
show_screen_names: If True, show only @handle
safe: If True, handle exceptions gracefully
Returns:
List of strings: [Sender, Text, Date]
"""
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
sender = g(msg, "sender", {})
sender_did = g(sender, "did")
handle = g(sender, "display_name") or g(sender, "displayName") or g(sender, "handle")
if not handle and sender_did and isinstance(db, dict):
# Look up DID in member maps stored by ChatBuffer
for key, val in db.items():
if key.endswith("_members") and isinstance(val, dict) and sender_did in val:
handle = val[sender_did]
break
if not handle:
handle = sender_did or "unknown"
text = g(msg, "text", "")
# Date
sent_at = g(msg, "sentAt") or g(msg, "sent_at")
date_str = ""
if sent_at:
try:
ts = arrow.get(sent_at)
if relative_times:
date_str = ts.humanize(locale=languageHandler.curLang[:2])
else:
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
except Exception:
date_str = str(sent_at)[:16]
return [handle, text, date_str]

View File

@@ -0,0 +1,901 @@
from __future__ import annotations
import logging
import re
from typing import Any
import wx
from pubsub import pub
from sessions import base
from sessions import session_exceptions as Exceptions
import output
import application
import languageHandler
log = logging.getLogger("sessions.blueskiSession")
class Language:
"""Simple language object with code and name attributes, mimicking Mastodon.py format."""
def __init__(self, code: str, name: str):
self.code = code
self.name = name
def __repr__(self):
return f"Language({self.code}, {self.name})"
def get_supported_languages():
"""Returns the list of supported languages with translated names."""
return [
Language("", _("Not set")),
Language("en", _("English")),
Language("es", _("Spanish")),
Language("fr", _("French")),
Language("de", _("German")),
Language("it", _("Italian")),
Language("pt", _("Portuguese")),
Language("ja", _("Japanese")),
Language("ko", _("Korean")),
Language("zh", _("Chinese")),
Language("ru", _("Russian")),
Language("ar", _("Arabic")),
Language("hi", _("Hindi")),
Language("nl", _("Dutch")),
Language("pl", _("Polish")),
Language("tr", _("Turkish")),
Language("uk", _("Ukrainian")),
Language("ca", _("Catalan")),
Language("eu", _("Basque")),
Language("gl", _("Galician")),
]
# Optional import of atproto. Code handles absence gracefully.
try:
from atproto import Client as AtpClient # type: ignore
except Exception: # ImportError or missing deps
AtpClient = None # type: ignore
class Session(base.baseSession):
"""Minimal Bluesky (atproto) session for TWBlue.
Provides basic authorisation, login, and posting support to unblock
the integration while keeping compatibility with TWBlue's session API.
"""
name = "Bluesky"
KIND = "blueski"
def __init__(self, *args, **kwargs):
super(Session, self).__init__(*args, **kwargs)
self.config_spec = "blueski.defaults"
self.type = "blueski"
self.char_limit = 300
self.api = None
self.poller = None
self.supported_languages = get_supported_languages()
self.default_language = languageHandler.curLang[:2]
# Subscribe to pub/sub events from the poller
pub.subscribe(self.on_notification, "blueski.notification_received")
def _ensure_settings_namespace(self) -> None:
"""Migrate legacy atprotosocial settings to blueski namespace."""
try:
if not self.settings:
return
if self.settings.get("blueski") is None and self.settings.get("atprotosocial") is not None:
self.settings["blueski"] = dict(self.settings["atprotosocial"])
try:
del self.settings["atprotosocial"]
except Exception:
pass
try:
self.settings.write()
except Exception:
pass
except Exception:
log.exception("Failed to migrate legacy Blueski settings")
def get_name(self):
"""Return a human-friendly, stable account name for UI.
Prefer the user's handle if available so accounts are uniquely
identifiable, falling back to a generic network name otherwise.
"""
self._ensure_settings_namespace()
try:
# Prefer runtime DB, then persisted settings, then SDK client
handle = (
self.db.get("user_name")
or (self.settings and self.settings.get("blueski", {}).get("handle"))
or (self.settings and self.settings.get("atprotosocial", {}).get("handle"))
or (getattr(getattr(self, "api", None), "me", None) and self.api.me.handle)
)
if handle:
return handle
except Exception:
pass
return self.name
def _ensure_client(self):
if AtpClient is None:
raise RuntimeError(
"The 'atproto' package is not installed. Install it to use Bluesky."
)
if self.api is None:
self.api = AtpClient()
return self.api
def login(self, verify_credentials=True):
self._ensure_settings_namespace()
if self.settings.get("blueski") is None:
raise Exceptions.RequireCredentialsSessionError
handle = self.settings["blueski"].get("handle")
app_password = self.settings["blueski"].get("app_password")
session_string = self.settings["blueski"].get("session_string")
if not handle or (not app_password and not session_string):
self.logged = False
raise Exceptions.RequireCredentialsSessionError
try:
# Ensure db exists (can be set to None on logout paths)
if not isinstance(self.db, dict):
self.db = {}
# Ensure general settings have a default for boost confirmations like Mastodon
try:
if "general" in self.settings and self.settings["general"].get("boost_mode") is None:
self.settings["general"]["boost_mode"] = "ask"
except Exception:
pass
api = self._ensure_client()
# Prefer resuming session if we have one
if session_string:
try:
api.import_session_string(session_string)
except Exception:
# Fall back to login below
pass
if not getattr(api, "me", None):
# Fresh login
api.login(handle, app_password)
# Cache basics
if getattr(api, "me", None) is None:
raise RuntimeError("Bluesky SDK client has no 'me' after login")
self.db["user_name"] = api.me.handle
self.db["user_id"] = api.me.did
# Persist DID in settings for session manager display
self.settings["blueski"]["did"] = api.me.did
# Export session for future reuse
try:
self.settings["blueski"]["session_string"] = api.export_session_string()
except Exception:
pass
self.settings.write()
self.logged = True
log.debug("Logged in to Bluesky as %s", api.me.handle)
except Exception as e:
log.exception("Bluesky login failed")
self.logged = False
raise e
def authorise(self):
self._ensure_settings_namespace()
if self.logged:
raise Exceptions.AlreadyAuthorisedError("Already authorised.")
# Ask for handle
dlg = wx.TextEntryDialog(
None,
_("Enter your Bluesky handle (e.g., username.bsky.social)"),
_("Bluesky Login"),
)
if dlg.ShowModal() != wx.ID_OK:
dlg.Destroy()
return
handle = dlg.GetValue().strip()
dlg.Destroy()
# Ask for app password
pwd = wx.PasswordEntryDialog(
None,
_("Enter your Bluesky App Password (from Settings > App passwords)"),
_("Bluesky Login"),
)
if pwd.ShowModal() != wx.ID_OK:
pwd.Destroy()
return
app_password = pwd.GetValue().strip()
pwd.Destroy()
# Create session folder and config, then attempt login
self.create_session_folder()
self.get_configuration()
self.settings["blueski"]["handle"] = handle
self.settings["blueski"]["app_password"] = app_password
self.settings.write()
try:
self.login()
except Exceptions.RequireCredentialsSessionError:
return
except Exception:
log.exception("Authorisation failed")
wx.MessageBox(
_("We could not log in to Bluesky. Please verify your handle and app password."),
_("Login error"), wx.ICON_ERROR
)
return False
return True
def get_message_url(self, message_id, context=None):
# message_id may be full at:// URI or rkey
self._ensure_settings_namespace()
handle = self.db.get("user_name") or self.settings["blueski"].get("handle", "")
rkey = message_id
if isinstance(message_id, str) and message_id.startswith("at://"):
parts = message_id.split("/")
rkey = parts[-1]
return f"https://bsky.app/profile/{handle}/post/{rkey}"
def send_message(self, message, files=None, reply_to=None, cw_text=None, is_sensitive=False, **kwargs):
if not self.logged:
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
self._ensure_settings_namespace()
try:
api = self._ensure_client()
# Basic text-only post for now. Attachments and CW can be extended later.
# Prefer convenience if available
uri = None
text = message or ""
# Naive CW handling: prepend CW label to text if provided
if cw_text:
text = f"CW: {cw_text}\n\n{text}" if text else f"CW: {cw_text}"
# Build base record
record: dict[str, Any] = {
"$type": "app.bsky.feed.post",
"text": text,
}
# Facets (Links and Mentions)
try:
facets = self._get_facets(text, api)
if facets:
record["facets"] = facets
except:
pass
# Labels (CW)
if cw_text:
record["labels"] = {
"$type": "com.atproto.label.defs#selfLabels",
"values": [{"val": "warn"}]
}
# createdAt
try:
record["createdAt"] = api.get_current_time_iso()
except Exception:
pass
# languages
langs = kwargs.get("langs") or kwargs.get("languages")
if isinstance(langs, (list, tuple)) and langs:
record["langs"] = list(langs)
# Helper to build a StrongRef (uri+cid) for a given post URI
def _get_strong_ref(uri: str):
try:
# Try typed models first
posts_res = api.app.bsky.feed.get_posts({"uris": [uri]})
posts = getattr(posts_res, "posts", None) or []
except Exception:
try:
posts_res = api.app.bsky.feed.get_posts(uris=[uri])
posts = getattr(posts_res, "posts", None) or []
except Exception:
posts = []
if posts:
post0 = posts[0]
post_uri = getattr(post0, "uri", uri)
post_cid = getattr(post0, "cid", None) or (post0.get("cid") if isinstance(post0, dict) else None)
if post_cid:
return {"uri": post_uri, "cid": post_cid}
return None
# Upload images if provided
embed_images = []
if files:
for f in files:
path = f
alt = ""
if isinstance(f, dict):
path = f.get("path") or f.get("file")
alt = f.get("alt") or f.get("alt_text") or ""
if not path:
continue
try:
with open(path, "rb") as fp:
data = fp.read()
# Try typed upload
try:
up = api.com.atproto.repo.upload_blob(data)
blob_ref = getattr(up, "blob", None) or getattr(up, "data", None) or up
except Exception:
# Some SDK variants expose upload via api.upload_blob
up = api.upload_blob(data)
blob_ref = getattr(up, "blob", None) or getattr(up, "data", None) or up
if blob_ref:
embed_images.append({
"image": blob_ref,
"alt": alt or "",
})
except Exception:
log.exception("Error uploading media for Bluesky post")
continue
# Quote post (takes precedence over images)
quote_uri = kwargs.get("quote_uri") or kwargs.get("quote")
if quote_uri:
strong = _get_strong_ref(quote_uri)
if strong:
record["embed"] = {
"$type": "app.bsky.embed.record",
"record": strong,
}
embed_images = [] # Ignore images when quoting
if embed_images and not record.get("embed"):
record["embed"] = {
"$type": "app.bsky.embed.images",
"images": embed_images,
}
# Helper: normalize various incoming identifiers to an at:// URI
def _normalize_to_uri(identifier: str) -> str | None:
try:
if not isinstance(identifier, str):
return None
if identifier.startswith("at://"):
return identifier
if "bsky.app/profile/" in identifier and "/post/" in identifier:
# Accept full web URL and try to resolve via get_post_thread below
return identifier
# Accept bare rkey case by constructing a guess using own handle
handle = self.db.get("user_name") or self.settings["blueski"].get("handle")
did = self.db.get("user_id") or self.settings["blueski"].get("did")
if handle and did and len(identifier) in (13, 14, 15):
# rkey length is typically ~13 chars base32
return f"at://{did}/app.bsky.feed.post/{identifier}"
except Exception:
pass
return None
# Reply-to handling (sets correct root/parent strong refs)
if reply_to:
# Resolve to proper at:// uri when possible
reply_uri = _normalize_to_uri(reply_to) or reply_to
reply_cid = kwargs.get("reply_to_cid")
parent_ref = None
if reply_uri and reply_cid:
parent_ref = {"uri": reply_uri, "cid": reply_cid}
if not parent_ref:
parent_ref = _get_strong_ref(reply_uri)
root_ref = parent_ref
# Try to fetch thread to find actual root for deep replies
try:
# atproto SDK usually exposes get_post_thread
thread_res = None
try:
thread_res = api.app.bsky.feed.get_post_thread({"uri": reply_uri})
except Exception:
# Try typed model call variant if available
from atproto import models as at_models # type: ignore
params = at_models.AppBskyFeedGetPostThread.Params(uri=reply_uri)
thread_res = api.app.bsky.feed.get_post_thread(params)
thread = getattr(thread_res, "thread", None)
# Walk to the root if present
node = thread
while node and getattr(node, "parent", None):
node = getattr(node, "parent")
root_uri = getattr(node, "post", None)
if root_uri:
root_uri = getattr(root_uri, "uri", None)
if root_uri and isinstance(root_uri, str):
maybe_root = _get_strong_ref(root_uri)
if maybe_root:
root_ref = maybe_root
except Exception:
# If anything fails, keep parent as root for a simple two-level reply
pass
if parent_ref:
record["reply"] = {
"root": root_ref or parent_ref,
"parent": parent_ref,
}
# Fallback to convenience if available
try:
if hasattr(api, "send_post") and not embed_images and not langs and not cw_text:
res = api.send_post(text)
uri = getattr(res, "uri", None) or getattr(res, "cid", None)
else:
out = api.com.atproto.repo.create_record({
"repo": api.me.did,
"collection": "app.bsky.feed.post",
"record": record,
})
uri = getattr(out, "uri", None)
except Exception:
log.exception("Error creating Bluesky post record")
uri = None
if not uri:
raise RuntimeError("Post did not return a URI")
return uri
except Exception:
log.exception("Error sending Bluesky post")
output.speak(_("An error occurred while posting to Bluesky."), True)
return None
def _get_facets(self, text, api):
facets = []
# Mentions
for m in re.finditer(r'@([a-zA-Z0-9.-]+)', text):
handle = m.group(1)
try:
# We should probably cache this identity lookup
res = api.com.atproto.identity.resolve_handle({'handle': handle})
did = res.did
facets.append({
'index': {
'byteStart': len(text[:m.start()].encode('utf-8')),
'byteEnd': len(text[:m.end()].encode('utf-8'))
},
'features': [{'$type': 'app.bsky.richtext.facet#mention', 'did': did}]
})
except:
continue
# Links
for m in re.finditer(r'(https?://[^\s]+)', text):
url = m.group(1)
facets.append({
'index': {
'byteStart': len(text[:m.start()].encode('utf-8')),
'byteEnd': len(text[:m.end()].encode('utf-8'))
},
'features': [{'$type': 'app.bsky.richtext.facet#link', 'uri': url}]
})
return facets
def delete_post(self, uri: str) -> bool:
"""Delete a post by its AT URI."""
api = self._ensure_client()
try:
# at://did:plc:xxx/app.bsky.feed.post/rkey
parts = uri.split("/")
rkey = parts[-1]
api.com.atproto.repo.delete_record({
"repo": api.me.did,
"collection": "app.bsky.feed.post",
"rkey": rkey
})
return True
except:
log.exception("Error deleting Bluesky post")
return False
def block_user(self, did: str) -> bool:
"""Block a user by their DID."""
api = self._ensure_client()
try:
api.com.atproto.repo.create_record({
"repo": api.me.did,
"collection": "app.bsky.graph.block",
"record": {
"$type": "app.bsky.graph.block",
"subject": did,
"createdAt": api.get_current_time_iso()
}
})
return True
except:
log.exception("Error blocking Bluesky user")
return False
def unblock_user(self, block_uri: str) -> bool:
"""Unblock a user by the URI of the block record."""
api = self._ensure_client()
try:
parts = block_uri.split("/")
rkey = parts[-1]
api.com.atproto.repo.delete_record({
"repo": api.me.did,
"collection": "app.bsky.graph.block",
"rkey": rkey
})
return True
except:
log.exception("Error unblocking Bluesky user")
return False
def get_profile(self, actor: str) -> Any:
api = self._ensure_client()
try:
return api.app.bsky.actor.get_profile({"actor": actor})
except Exception:
log.exception("Error fetching Bluesky profile for %s", actor)
return None
def get_profiles(self, actors: list[str]) -> dict[str, Any]:
api = self._ensure_client()
if not actors:
return {"items": []}
# API limit is 25 actors per request, batch if needed
all_profiles = []
batch_size = 25
for i in range(0, len(actors), batch_size):
batch = actors[i:i + batch_size]
try:
res = api.app.bsky.actor.get_profiles({"actors": batch})
profiles = getattr(res, "profiles", []) or []
all_profiles.extend(profiles)
except Exception:
log.exception("Error fetching Bluesky profiles batch")
return {"items": all_profiles}
def get_post_likes(self, uri: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
api = self._ensure_client()
try:
params = {"uri": uri, "limit": limit}
if cursor:
params["cursor"] = cursor
res = api.app.bsky.feed.get_likes(params)
return {"items": getattr(res, "likes", []) or [], "cursor": getattr(res, "cursor", None)}
except Exception:
log.exception("Error fetching Bluesky likes for %s", uri)
return {"items": [], "cursor": None}
def get_post_reposts(self, uri: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
api = self._ensure_client()
try:
params = {"uri": uri, "limit": limit}
if cursor:
params["cursor"] = cursor
# SDK uses get_reposted_by (camel or snake)
feed = api.app.bsky.feed
if hasattr(feed, "get_reposted_by"):
res = feed.get_reposted_by(params)
else:
res = feed.get_repostedBy(params)
return {"items": getattr(res, "reposted_by", None) or getattr(res, "repostedBy", None) or getattr(res, "reposted_by", []) or [], "cursor": getattr(res, "cursor", None)}
except Exception:
log.exception("Error fetching Bluesky reposts for %s", uri)
return {"items": [], "cursor": None}
def follow_user(self, did: str) -> bool:
api = self._ensure_client()
try:
api.com.atproto.repo.create_record({
"repo": api.me.did,
"collection": "app.bsky.graph.follow",
"record": {
"$type": "app.bsky.graph.follow",
"subject": did,
"createdAt": api.get_current_time_iso()
}
})
return True
except Exception:
log.exception("Error following Bluesky user")
return False
def unfollow_user(self, follow_uri: str) -> bool:
api = self._ensure_client()
try:
parts = follow_uri.split("/")
rkey = parts[-1]
api.com.atproto.repo.delete_record({
"repo": api.me.did,
"collection": "app.bsky.graph.follow",
"rkey": rkey
})
return True
except Exception:
log.exception("Error unfollowing Bluesky user")
return False
def mute_user(self, did: str) -> bool:
api = self._ensure_client()
try:
graph = api.app.bsky.graph
if hasattr(graph, "mute_actor"):
graph.mute_actor({"actor": did})
elif hasattr(graph, "muteActor"):
graph.muteActor({"actor": did})
else:
return False
return True
except Exception:
log.exception("Error muting Bluesky user")
return False
def unmute_user(self, did: str) -> bool:
api = self._ensure_client()
try:
graph = api.app.bsky.graph
if hasattr(graph, "unmute_actor"):
graph.unmute_actor({"actor": did})
elif hasattr(graph, "unmuteActor"):
graph.unmuteActor({"actor": did})
else:
return False
return True
except Exception:
log.exception("Error unmuting Bluesky user")
return False
def repost(self, post_uri: str, post_cid: str | None = None) -> str | None:
"""Create a simple repost of a given post. Returns URI of the repost record or None."""
if not self.logged:
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
try:
api = self._ensure_client()
def _get_strong_ref(uri: str):
try:
posts_res = api.app.bsky.feed.get_posts({"uris": [uri]})
posts = getattr(posts_res, "posts", None) or []
except Exception:
try:
posts_res = api.app.bsky.feed.get_posts(uris=[uri])
posts = getattr(posts_res, "posts", None) or []
except Exception:
posts = []
if posts:
post0 = posts[0]
s_uri = getattr(post0, "uri", uri)
s_cid = getattr(post0, "cid", None) or (post0.get("cid") if isinstance(post0, dict) else None)
if s_cid:
return {"uri": s_uri, "cid": s_cid}
return None
if not post_cid:
strong = _get_strong_ref(post_uri)
if not strong:
return None
post_uri = strong["uri"]
post_cid = strong["cid"]
out = api.com.atproto.repo.create_record({
"repo": api.me.did,
"collection": "app.bsky.feed.repost",
"record": {
"$type": "app.bsky.feed.repost",
"subject": {"uri": post_uri, "cid": post_cid},
"createdAt": getattr(api, "get_current_time_iso", lambda: None)() or None,
},
})
return getattr(out, "uri", None)
except Exception:
log.exception("Error creating Bluesky repost record")
return None
def like(self, post_uri: str, post_cid: str | None = None) -> str | None:
"""Create a like for a given post."""
if not self.logged:
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
try:
api = self._ensure_client()
# Resolve strong ref if needed
def _get_strong_ref(uri: str):
try:
posts_res = api.app.bsky.feed.get_posts({"uris": [uri]})
posts = getattr(posts_res, "posts", None) or []
except Exception:
try: posts_res = api.app.bsky.feed.get_posts(uris=[uri])
except: posts_res = None
posts = getattr(posts_res, "posts", None) or []
if posts:
p = posts[0]
return {"uri": getattr(p, "uri", uri), "cid": getattr(p, "cid", None)}
return None
if not post_cid:
strong = _get_strong_ref(post_uri)
if not strong: return None
post_uri = strong["uri"]
post_cid = strong["cid"]
out = api.com.atproto.repo.create_record({
"repo": api.me.did,
"collection": "app.bsky.feed.like",
"record": {
"$type": "app.bsky.feed.like",
"subject": {"uri": post_uri, "cid": post_cid},
"createdAt": getattr(api, "get_current_time_iso", lambda: None)() or None,
},
})
return getattr(out, "uri", None)
except Exception:
log.exception("Error creating Bluesky like")
return None
def get_followers(self, actor: str | None = None, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
api = self._ensure_client()
actor = actor or api.me.did
res = api.app.bsky.graph.get_followers({"actor": actor, "limit": limit, "cursor": cursor})
return {"items": res.followers, "cursor": res.cursor}
def get_follows(self, actor: str | None = None, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
api = self._ensure_client()
actor = actor or api.me.did
res = api.app.bsky.graph.get_follows({"actor": actor, "limit": limit, "cursor": cursor})
return {"items": res.follows, "cursor": res.cursor}
def get_blocks(self, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
api = self._ensure_client()
res = api.app.bsky.graph.get_blocks({"limit": limit, "cursor": cursor})
return {"items": res.blocks, "cursor": res.cursor}
def list_convos(self, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
api = self._ensure_client()
# Chat API requires using the chat proxy
dm_client = api.with_bsky_chat_proxy()
dm = dm_client.chat.bsky.convo
params = {"limit": limit}
if cursor:
params["cursor"] = cursor
try:
res = dm.list_convos(params)
return {"items": res.convos, "cursor": getattr(res, "cursor", None)}
except Exception:
log.exception("Error listing conversations")
return {"items": [], "cursor": None}
def get_convo(self, convo_id: str):
"""Fetch a single conversation by ID, returning the convo object or None."""
api = self._ensure_client()
dm_client = api.with_bsky_chat_proxy()
dm = dm_client.chat.bsky.convo
try:
res = dm.get_convo({"convoId": convo_id})
return res.convo
except Exception:
log.exception("Error fetching conversation %s", convo_id)
return None
def get_convo_messages(self, convo_id: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
api = self._ensure_client()
dm_client = api.with_bsky_chat_proxy()
dm = dm_client.chat.bsky.convo
params = {"convoId": convo_id, "limit": limit}
if cursor:
params["cursor"] = cursor
try:
res = dm.get_messages(params)
return {"items": res.messages, "cursor": getattr(res, "cursor", None)}
except Exception:
log.exception("Error getting conversation messages")
return {"items": [], "cursor": None}
def send_chat_message(self, convo_id: str, text: str) -> Any:
api = self._ensure_client()
dm_client = api.with_bsky_chat_proxy()
dm = dm_client.chat.bsky.convo
try:
return dm.send_message({
"convoId": convo_id,
"message": {
"text": text
}
})
except Exception:
log.exception("Error sending chat message")
raise
def get_or_create_convo(self, members: list[str]) -> dict[str, Any] | None:
"""Get or create a conversation with the given members (DIDs)."""
api = self._ensure_client()
dm_client = api.with_bsky_chat_proxy()
dm = dm_client.chat.bsky.convo
try:
res = dm.get_convo_for_members({"members": members})
return res.convo
except Exception:
log.exception("Error getting/creating conversation")
return None
# Streaming/Polling methods
def start_streaming(self):
"""Start the background poller for notifications."""
if not self.logged:
log.debug("Cannot start Bluesky poller: not logged in.")
return
if self.poller is not None and self.poller.is_alive():
log.debug("Bluesky poller already running for %s", self.get_name())
return
try:
from sessions.blueski.streaming import BlueskyPoller
poll_interval = 60
try:
poll_interval = self.settings["general"].get("update_period", 60)
except Exception:
pass
self.poller = BlueskyPoller(
session=self,
session_name=self.get_name(),
poll_interval=poll_interval
)
self.poller.start()
log.info("Started Bluesky poller for session %s", self.get_name())
except Exception:
log.exception("Failed to start Bluesky poller")
def stop_streaming(self):
"""Stop the background poller."""
if self.poller is not None:
self.poller.stop()
self.poller = None
log.info("Stopped Bluesky poller for session %s", self.get_name())
def on_notification(self, notification, session_name):
"""Handle notification received from the poller via pub/sub."""
# Discard if notification is for a different session
if self.get_name() != session_name:
return
# Add notification to the notifications buffer
try:
num = self.order_buffer("notifications", [notification])
if num > 0:
pub.sendMessage(
"blueski.new_item",
session_name=self.get_name(),
item=notification,
_buffers=["notifications"]
)
except Exception:
log.exception("Error processing Bluesky notification")
def order_buffer(self, buffer_name, items):
"""Add items to the specified buffer's database.
Returns the number of new items added.
"""
if buffer_name not in self.db:
self.db[buffer_name] = []
# Get existing URIs to avoid duplicates
existing_uris = set()
for item in self.db[buffer_name]:
uri = None
if isinstance(item, dict):
uri = item.get("uri")
else:
uri = getattr(item, "uri", None)
if uri:
existing_uris.add(uri)
# Add new items
new_count = 0
for item in items:
uri = None
if isinstance(item, dict):
uri = item.get("uri")
else:
uri = getattr(item, "uri", None)
if uri and uri in existing_uris:
continue
if uri:
existing_uris.add(uri)
# Insert at beginning (newest first)
self.db[buffer_name].insert(0, item)
new_count += 1
return new_count

View File

@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
"""
Bluesky polling-based update system for TWBlue.
Since Bluesky's Firehose requires complex CAR/CBOR decoding and filtering
of millions of events, we use a polling approach instead of true streaming.
This matches the existing start_stream() pattern used by buffers.
Events are published via pub/sub to maintain consistency with Mastodon's
streaming implementation.
"""
import logging
import threading
import time
from pubsub import pub
log = logging.getLogger("sessions.blueski.streaming")
class BlueskyPoller:
"""
Polling-based update system for Bluesky.
Periodically checks for new notifications and publishes them via pub/sub.
This provides a similar interface to Mastodon's StreamListener but uses
polling instead of WebSocket streaming.
"""
def __init__(self, session, session_name, poll_interval=60):
"""
Initialize the poller.
Args:
session: The Bluesky session instance
session_name: Unique identifier for this session (for pub/sub routing)
poll_interval: Seconds between API polls (default 60, min 30)
"""
self.session = session
self.session_name = session_name
self.poll_interval = max(30, poll_interval) # Minimum 30 seconds to respect rate limits
self._stop_event = threading.Event()
self._thread = None
self._last_notification_cursor = None
self._last_seen_notification_uri = None
def start(self):
"""Start the polling thread."""
if self._thread is not None and self._thread.is_alive():
log.warning(f"Bluesky poller for {self.session_name} is already running.")
return
self._stop_event.clear()
self._thread = threading.Thread(
target=self._poll_loop,
name=f"BlueskyPoller-{self.session_name}",
daemon=True
)
self._thread.start()
log.info(f"Bluesky poller started for {self.session_name} (interval: {self.poll_interval}s)")
def stop(self):
"""Stop the polling thread."""
if self._thread is None:
return
self._stop_event.set()
self._thread.join(timeout=5)
self._thread = None
log.info(f"Bluesky poller stopped for {self.session_name}")
def is_alive(self):
"""Check if the polling thread is running."""
return self._thread is not None and self._thread.is_alive()
def _poll_loop(self):
"""Main polling loop running in background thread."""
log.debug(f"Polling loop started for {self.session_name}")
# Initial delay to let the app fully initialize
time.sleep(5)
while not self._stop_event.is_set():
try:
self._check_notifications()
except Exception as e:
log.exception(f"Error in Bluesky polling loop for {self.session_name}: {e}")
# Wait for next poll interval, checking stop event periodically
for _ in range(self.poll_interval):
if self._stop_event.is_set():
break
time.sleep(1)
log.debug(f"Polling loop ended for {self.session_name}")
def _check_notifications(self):
"""Check for new notifications and publish events."""
if not self.session.logged:
return
try:
api = self.session._ensure_client()
if not api:
return
# Fetch recent notifications
res = api.app.bsky.notification.list_notifications({"limit": 20})
notifications = getattr(res, "notifications", [])
if not notifications:
return
# Track which notifications are new
new_notifications = []
newest_uri = None
for notif in notifications:
uri = getattr(notif, "uri", None)
if not uri:
continue
# First time running - just record the newest and don't flood
if self._last_seen_notification_uri is None:
newest_uri = uri
break
# Check if we've seen this notification before
if uri == self._last_seen_notification_uri:
break
new_notifications.append(notif)
if newest_uri is None:
newest_uri = uri
# Update last seen
if newest_uri:
self._last_seen_notification_uri = newest_uri
# Publish new notifications (in reverse order so oldest first)
for notif in reversed(new_notifications):
self._publish_notification(notif)
except Exception as e:
log.debug(f"Error checking notifications for {self.session_name}: {e}")
def _publish_notification(self, notification):
"""Publish a notification event via pub/sub."""
try:
reason = getattr(notification, "reason", "unknown")
log.debug(f"Publishing Bluesky notification: {reason} for {self.session_name}")
pub.sendMessage(
"blueski.notification_received",
notification=notification,
session_name=self.session_name
)
# Also publish specific events for certain notification types
if reason == "mention":
pub.sendMessage(
"blueski.mention_received",
notification=notification,
session_name=self.session_name
)
elif reason == "reply":
pub.sendMessage(
"blueski.reply_received",
notification=notification,
session_name=self.session_name
)
elif reason == "follow":
pub.sendMessage(
"blueski.follow_received",
notification=notification,
session_name=self.session_name
)
except Exception as e:
log.exception(f"Error publishing notification event: {e}")
def create_poller(session, session_name, poll_interval=60):
"""
Factory function to create a BlueskyPoller instance.
Args:
session: The Bluesky session instance
session_name: Unique identifier for this session
poll_interval: Seconds between polls (default 60)
Returns:
BlueskyPoller instance
"""
return BlueskyPoller(session, session_name, poll_interval)

View File

@@ -0,0 +1,307 @@
# -*- coding: utf-8 -*-
import arrow
import languageHandler
from string import Template
from sessions.blueski import utils
post_variables = [
"date",
"display_name",
"screen_name",
"reply_to",
"source",
"lang",
"safe_text",
"text",
"image_descriptions",
"visibility",
"pinned",
]
person_variables = [
"display_name",
"screen_name",
"description",
"followers",
"following",
"favorites",
"posts",
"created_at",
]
notification_variables = ["display_name", "screen_name", "text", "date"]
def _g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
def _extract_labels(obj):
labels = _g(obj, "labels", None)
if labels is None:
return []
if isinstance(labels, dict):
return labels.get("values", []) or []
if isinstance(labels, list):
return labels
return []
def _extract_cw_text(post, record):
labels = _extract_labels(post) + _extract_labels(record)
for label in labels:
val = _g(label, "val", "")
if val == "warn":
return _("Sensitive Content")
if isinstance(val, str) and val.startswith("warn:"):
return val.split("warn:", 1)[-1].strip()
return ""
def _extract_image_descriptions(post, record):
def collect_images(embed):
if not embed:
return []
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
if "recordWithMedia" in etype:
media = _g(embed, "media")
mtype = _g(media, "$type") or _g(media, "py_type") or ""
if "images" in mtype:
return list(_g(media, "images", []) or [])
return []
if "images" in etype:
return list(_g(embed, "images", []) or [])
return []
images = []
images.extend(collect_images(_g(post, "embed")))
if not images:
images.extend(collect_images(_g(record, "embed")))
descriptions = []
for idx, img in enumerate(images, start=1):
alt = _g(img, "alt", "") or ""
if alt:
descriptions.append(_("Media description {index}: {alt}").format(index=idx, alt=alt))
return "\n".join(descriptions)
def process_date(field, relative_times=True, offset_hours=0):
original_date = arrow.get(field)
if relative_times:
return original_date.humanize(locale=languageHandler.curLang[:2])
return original_date.shift(hours=offset_hours).format(_("dddd, MMMM D, YYYY H:m:s"), locale=languageHandler.curLang[:2])
def _extract_link_info(post, record):
"""Extract link information from post embeds and facets."""
embed = _g(post, "embed")
if not embed:
return None
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
# Direct external embed
if "external" in etype.lower():
ext = _g(embed, "external", {})
title = _g(ext, "title", "")
if title:
return title
# RecordWithMedia with external
if "recordWithMedia" in etype:
media = _g(embed, "media", {})
mtype = _g(media, "$type") or _g(media, "py_type") or ""
if "external" in mtype.lower():
ext = _g(media, "external", {})
title = _g(ext, "title", "")
if title:
return title
return None
def render_post(post, template, settings, relative_times=False, offset_hours=0):
actual_post = _g(post, "post", post)
record = _g(actual_post, "record") or _g(post, "record") or {}
author = _g(actual_post, "author") or _g(post, "author") or {}
reason = _g(post, "reason")
is_repost = False
reposter = None
if reason:
rtype = _g(reason, "$type") or _g(reason, "py_type") or ""
if "reasonRepost" in rtype:
is_repost = True
reposter = _g(reason, "by")
if is_repost and reposter:
display_name = _g(reposter, "displayName") or _g(reposter, "display_name") or _g(reposter, "handle", "")
screen_name = _g(reposter, "handle", "")
else:
display_name = _g(author, "displayName") or _g(author, "display_name") or _g(author, "handle", "")
screen_name = _g(author, "handle", "")
text = _g(record, "text", "") or ""
if is_repost:
original_handle = _g(author, "handle", "")
text = _("Reposted from @{handle}: {text}").format(handle=original_handle, text=text)
quote_info = utils.extract_quoted_post_info(post)
if quote_info:
if quote_info["kind"] == "not_found":
text += f" [{_('Quoted post not found')}]"
elif quote_info["kind"] == "blocked":
text += f" [{_('Quoted post blocked')}]"
elif quote_info["kind"] == "feed":
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
else:
q_handle = quote_info.get("handle", "unknown")
q_text = quote_info.get("text", "")
if q_text:
text += " " + _("Quoting @{handle}: {text}").format(handle=q_handle, text=q_text)
else:
text += " " + _("Quoting @{handle}").format(handle=q_handle)
# Add link indicator for external embeds
link_title = _extract_link_info(actual_post, record)
if link_title:
text += f" [{_('Link')}: {link_title}]"
reply_to_handle = utils.extract_reply_to_handle(post)
reply_to = ""
if reply_to_handle:
reply_to = _("Replying to @{handle}. ").format(handle=reply_to_handle)
cw_text = _extract_cw_text(actual_post, record)
safe_text = text
if cw_text:
# Include link info in safe_text even with content warning
if link_title:
safe_text = _("Content warning: {cw}").format(cw=cw_text) + f" [{_('Link')}: {link_title}]"
else:
safe_text = _("Content warning: {cw}").format(cw=cw_text)
# Backward compatibility: older user templates may not include $reply_to.
# In that case, prepend the reply marker directly so users still get context.
if reply_to and "$reply_to" not in template:
text = reply_to + text
safe_text = reply_to + safe_text
reply_to = ""
created_at = _g(record, "createdAt") or _g(record, "created_at")
indexed_at = _g(actual_post, "indexedAt") or _g(actual_post, "indexed_at")
date_field = created_at or indexed_at
date = process_date(date_field, relative_times, offset_hours) if date_field else ""
langs = _g(record, "langs") or _g(record, "languages") or []
lang = langs[0] if isinstance(langs, list) and langs else ""
image_descriptions = _extract_image_descriptions(actual_post, record)
available_data = dict(
date=date,
display_name=display_name,
screen_name=screen_name,
reply_to=reply_to,
source="Bluesky",
lang=lang,
safe_text=safe_text,
text=text,
image_descriptions=image_descriptions,
visibility=_("Public"),
pinned="",
)
return Template(_(template)).safe_substitute(**available_data)
def render_user(user, template, settings, relative_times=True, offset_hours=0):
# Resolve nested profile structure (subject, actor, profile, user)
def resolve_profile(obj):
if _g(obj, "handle") or _g(obj, "did"):
return obj
for key in ("subject", "actor", "profile", "user"):
nested = _g(obj, key)
if nested and (_g(nested, "handle") or _g(nested, "did")):
return nested
return obj
profile = resolve_profile(user)
display_name = _g(profile, "displayName") or _g(profile, "display_name") or _g(profile, "handle", "")
screen_name = _g(profile, "handle", "")
description = _g(profile, "description", "") or ""
followers = _g(profile, "followersCount") or _g(profile, "followers_count") or 0
following = _g(profile, "followsCount") or _g(profile, "follows_count") or 0
posts = _g(profile, "postsCount") or _g(profile, "posts_count") or 0
created_at = _g(profile, "createdAt") or _g(profile, "created_at")
created = ""
if created_at:
created = process_date(created_at, relative_times, offset_hours)
available_data = dict(
display_name=display_name,
screen_name=screen_name,
description=description,
followers=followers,
following=following,
favorites="",
posts=posts,
created_at=created,
)
return Template(_(template)).safe_substitute(**available_data)
def render_notification(notification, template, post_template, settings, relative_times=False, offset_hours=0):
author = _g(notification, "author") or {}
display_name = _g(author, "displayName") or _g(author, "display_name") or _g(author, "handle", "")
screen_name = _g(author, "handle", "")
reason = _g(notification, "reason", "unknown")
record = _g(notification, "record") or {}
# Get post text - try multiple locations depending on notification type
post_text = _g(record, "text", "") or ""
# For likes and reposts: try to get the subject post text
if not post_text and reason in ("like", "repost"):
# First check for hydrated subject text (added by NotificationBuffer)
post_text = _g(notification, "_subject_text", "") or ""
# Check if there's a reasonSubject with embedded post data
if not post_text:
reason_subject = _g(notification, "reasonSubject") or _g(notification, "reason_subject")
if reason_subject:
subject_record = _g(reason_subject, "record", {})
post_text = _g(subject_record, "text", "") or ""
# Check subject in record
if not post_text:
subject = _g(record, "subject", {})
post_text = _g(subject, "text", "") or ""
# Format: action text without username (username is already in display_name for template)
if reason == "like":
text = _("has added to favorites: {status}").format(status=post_text) if post_text else _("has added to favorites")
elif reason == "repost":
text = _("has reposted: {status}").format(status=post_text) if post_text else _("has reposted")
elif reason == "follow":
text = _("has followed you.")
elif reason == "mention":
text = _("has mentioned you: {status}").format(status=post_text) if post_text else _("has mentioned you")
elif reason == "reply":
text = _("has replied: {status}").format(status=post_text) if post_text else _("has replied")
elif reason == "quote":
text = _("has quoted your post: {status}").format(status=post_text) if post_text else _("has quoted your post")
else:
text = reason
indexed_at = _g(notification, "indexedAt") or _g(notification, "indexed_at")
date = process_date(indexed_at, relative_times, offset_hours) if indexed_at else ""
available_data = dict(
display_name=display_name,
screen_name=screen_name,
text=text,
date=date,
)
return Template(_(template)).safe_substitute(**available_data)

View File

@@ -0,0 +1,444 @@
# -*- coding: utf-8 -*-
"""
Utility functions for Bluesky session.
"""
import logging
import re
log = logging.getLogger("sessions.blueski.utils")
url_re = re.compile(r'https?://[^\s<>\[\]()"\',]+[^\s<>\[\]()"\',.:;!?]')
def g(obj, key, default=None):
"""Helper to get attribute from dict or object."""
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
def is_audio_or_video(post):
"""
Check if post contains audio or video content.
Args:
post: Bluesky post object (FeedViewPost or PostView)
Returns:
bool: True if post has audio/video media
"""
actual_post = g(post, "post", post)
embed = g(actual_post, "embed", None)
if not embed:
return False
etype = g(embed, "$type") or g(embed, "py_type") or ""
# Check for video embed
if "video" in etype.lower():
return True
# Check for external link that might be video (YouTube, etc.)
if "external" in etype.lower():
ext = g(embed, "external", {})
uri = g(ext, "uri", "")
video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"]
for host in video_hosts:
if host in uri.lower():
return True
# Check in recordWithMedia wrapper
if "recordwithmedia" in etype.lower():
media = g(embed, "media", {})
mtype = g(media, "$type") or g(media, "py_type") or ""
if "video" in mtype.lower():
return True
if "external" in mtype.lower():
ext = g(media, "external", {})
uri = g(ext, "uri", "")
video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"]
for host in video_hosts:
if host in uri.lower():
return True
return False
def _extract_images_from_embed(embed):
"""Extract image URLs from an embed object."""
images = []
if not embed:
return images
etype = g(embed, "$type") or g(embed, "py_type") or ""
def extract_images(img_list):
result = []
for img in (img_list or []):
url = None
# Try all possible URL field names
for key in ["fullsize", "thumb", "url", "uri", "src"]:
val = g(img, key)
if val and isinstance(val, str) and val.startswith("http"):
url = val
break
# Also check for nested 'image' object
if not url:
image_obj = g(img, "image", {})
if image_obj:
for key in ["ref", "$link", "url", "uri"]:
val = g(image_obj, key)
if val:
url = val
break
if url:
result.append({
"url": url,
"alt": g(img, "alt", "") or ""
})
return result
# Direct images embed (app.bsky.embed.images or app.bsky.embed.images#view)
if "images" in etype.lower():
images.extend(extract_images(g(embed, "images", [])))
# Check in recordWithMedia wrapper
if "recordwithmedia" in etype.lower():
media = g(embed, "media", {})
mtype = g(media, "$type") or g(media, "py_type") or ""
if "images" in mtype.lower():
images.extend(extract_images(g(media, "images", [])))
return images
def is_image(post):
"""
Check if post contains image content.
Args:
post: Bluesky post object (FeedViewPost or PostView)
Returns:
bool: True if post has image media
"""
actual_post = g(post, "post", post)
embed = g(actual_post, "embed", None)
if not embed:
return False
etype = g(embed, "$type") or g(embed, "py_type") or ""
# Direct images embed
if "images" in etype.lower():
images = g(embed, "images", [])
if images and len(images) > 0:
return True
# Check in recordWithMedia wrapper
if "recordwithmedia" in etype.lower():
media = g(embed, "media", {})
mtype = g(media, "$type") or g(media, "py_type") or ""
if "images" in mtype.lower():
images = g(media, "images", [])
if images and len(images) > 0:
return True
return False
def get_image_urls(post):
"""
Get URLs for image attachments from post for OCR.
Args:
post: Bluesky post object
Returns:
list: List of dicts with 'url' and 'alt' keys
"""
actual_post = g(post, "post", post)
embed = g(actual_post, "embed", None)
return _extract_images_from_embed(embed)
def get_media_urls(post):
"""
Get URLs for media attachments (video/audio) from post.
Args:
post: Bluesky post object
Returns:
list: List of media URLs
"""
urls = []
actual_post = g(post, "post", post)
embed = g(actual_post, "embed", None)
if not embed:
return urls
etype = g(embed, "$type") or g(embed, "py_type") or ""
def extract_video_urls(video_embed):
"""Extract URLs from a video embed object."""
result = []
# Playlist URL (HLS stream)
playlist = g(video_embed, "playlist", None)
if playlist:
result.append(playlist)
# Alternative URL fields
for key in ["url", "uri"]:
val = g(video_embed, key)
if val and val not in result:
result.append(val)
return result
# Direct video embed (app.bsky.embed.video#view)
if "video" in etype.lower():
urls.extend(extract_video_urls(embed))
# Check in recordWithMedia wrapper
if "recordWithMedia" in etype or "record_with_media" in etype.lower():
media = g(embed, "media", {})
mtype = g(media, "$type") or g(media, "py_type") or ""
if "video" in mtype.lower():
urls.extend(extract_video_urls(media))
# Also check for external in media
if "external" in mtype.lower():
ext = g(media, "external", {})
uri = g(ext, "uri", "")
if uri and uri not in urls:
urls.append(uri)
# External links (YouTube, etc.)
if "external" in etype.lower():
ext = g(embed, "external", {})
uri = g(ext, "uri", "")
if uri and uri not in urls:
urls.append(uri)
return urls
def find_urls(post):
"""
Find all URLs in post content.
Args:
post: Bluesky post object
Returns:
list: List of URLs found
"""
urls = []
actual_post = g(post, "post", post)
record = g(actual_post, "record", {})
# Check facets for link annotations
facets = g(record, "facets", []) or []
for facet in facets:
features = g(facet, "features", []) or []
for feature in features:
ftype = g(feature, "$type") or g(feature, "py_type")
if ftype and "link" in ftype.lower():
uri = g(feature, "uri", "")
if uri and uri not in urls:
urls.append(uri)
# Check embed for external links
embed = g(actual_post, "embed", None)
if embed:
etype = g(embed, "$type") or g(embed, "py_type")
if etype and "external" in etype:
ext = g(embed, "external", {})
uri = g(ext, "uri", "")
if uri and uri not in urls:
urls.append(uri)
# Also search plain text for URLs using regex (fallback)
text = g(record, "text", "")
if text:
text_urls = url_re.findall(text)
for u in text_urls:
if u not in urls:
urls.append(u)
# Include URLs from quoted post, if present.
quote_info = extract_quoted_post_info(post)
if quote_info and quote_info.get("kind") == "post":
for uri in quote_info.get("urls", []):
if uri and uri not in urls:
urls.append(uri)
return urls
def find_item(item, items_list):
"""
Find item index in list by URI.
Args:
item: Item to find
items_list: List to search
Returns:
int or None: Index if found, None otherwise
"""
item_uri = g(item, "uri") or g(g(item, "post"), "uri")
if not item_uri:
return None
for i, existing in enumerate(items_list):
existing_uri = g(existing, "uri") or g(g(existing, "post"), "uri")
if existing_uri == item_uri:
return i
return None
def _resolve_quoted_record_from_embed(embed):
"""Resolve quoted record payload from a Bluesky embed structure."""
if not embed:
return None
etype = (g(embed, "$type") or g(embed, "py_type") or "").lower()
candidate = None
if "recordwithmedia" in etype:
record_view = g(embed, "record")
candidate = g(record_view, "record") or record_view
elif "record" in etype:
candidate = g(embed, "record") or embed
else:
record_view = g(embed, "record")
if record_view is not None:
candidate = g(record_view, "record") or record_view
if not candidate:
return None
# Unwrap one extra layer if still wrapped in a record-view container.
nested = g(candidate, "record")
nested_type = (g(nested, "$type") or g(nested, "py_type") or "").lower() if nested else ""
if nested and ("view" in nested_type or "record" in nested_type):
return nested
return candidate
def extract_reply_to_handle(post):
"""
Best-effort extraction of the replied-to handle for a Bluesky post.
Returns:
str | None: Handle (without @) when available.
"""
actual_post = g(post, "post", post)
# Fast path: pre-hydrated by buffers/session.
cached = g(post, "_reply_to_handle", None) or g(actual_post, "_reply_to_handle", None)
if cached:
return cached
# Feed views frequently include hydrated reply context.
reply_view = g(post, "reply", None) or g(actual_post, "reply", None)
if reply_view:
parent = g(reply_view, "parent", None) or g(reply_view, "post", None) or reply_view
parent_post = g(parent, "post", None) or parent
parent_author = g(parent_post, "author", None) or g(parent, "author", None)
handle = g(parent_author, "handle", None)
if handle:
return handle
# Some payloads include parent author directly under record.reply.parent.
record = g(actual_post, "record", {}) or {}
record_reply = g(record, "reply", None)
if record_reply:
parent = g(record_reply, "parent", None) or record_reply
parent_post = g(parent, "post", None) or parent
parent_author = g(parent_post, "author", None) or g(parent, "author", None)
handle = g(parent_author, "handle", None)
if handle:
return handle
# When only record.reply is available, we generally only have strong refs.
# No handle can be resolved here without extra API calls.
return None
def extract_quoted_post_info(post):
"""
Extract quoted content metadata from a Bluesky post.
Returns:
dict | None: one of:
- {"kind": "not_found"}
- {"kind": "blocked"}
- {"kind": "feed", "feed_name": "..."}
- {"kind": "post", "handle": "...", "text": "...", "urls": ["..."]}
"""
actual_post = g(post, "post", post)
record = g(actual_post, "record", {}) or {}
embed = g(actual_post, "embed", None) or g(record, "embed", None)
quote_rec = _resolve_quoted_record_from_embed(embed)
if not quote_rec:
return None
qtype = (g(quote_rec, "$type") or g(quote_rec, "py_type") or "").lower()
if "viewnotfound" in qtype:
return {"kind": "not_found"}
if "viewblocked" in qtype:
return {"kind": "blocked"}
if "generatorview" in qtype:
return {"kind": "feed", "feed_name": g(quote_rec, "displayName", "Feed")}
q_author = g(quote_rec, "author", {}) or {}
q_handle = g(q_author, "handle", "unknown") or "unknown"
q_value = g(quote_rec, "value") or g(quote_rec, "record") or {}
q_text = g(q_value, "text", "") or g(quote_rec, "text", "")
if not q_text:
nested_value = g(q_value, "value") or {}
q_text = g(nested_value, "text", "")
q_urls = []
q_facets = g(q_value, "facets", []) or []
for facet in q_facets:
features = g(facet, "features", []) or []
for feature in features:
ftype = (g(feature, "$type") or g(feature, "py_type") or "").lower()
if "link" in ftype:
uri = g(feature, "uri", "")
if uri and uri not in q_urls:
q_urls.append(uri)
q_embed = g(quote_rec, "embed", None) or g(q_value, "embed", None)
if q_embed:
q_etype = (g(q_embed, "$type") or g(q_embed, "py_type") or "").lower()
if "external" in q_etype:
ext = g(q_embed, "external", {})
uri = g(ext, "uri", "")
if uri and uri not in q_urls:
q_urls.append(uri)
if "recordwithmedia" in q_etype:
media = g(q_embed, "media", {})
mtype = (g(media, "$type") or g(media, "py_type") or "").lower()
if "external" in mtype:
ext = g(media, "external", {})
uri = g(ext, "uri", "")
if uri and uri not in q_urls:
q_urls.append(uri)
for uri in url_re.findall(q_text or ""):
if uri not in q_urls:
q_urls.append(uri)
return {
"kind": "post",
"handle": q_handle,
"text": q_text or "",
"urls": q_urls,
}

View File

@@ -458,4 +458,4 @@ class Session(base.baseSession):
# Now, add notification to its buffer.
num = self.order_buffer("notifications", [notification])
if num > 0:
pub.sendMessage("mastodon.new_item", session_name=self.get_name(), item=notification, _buffers=["notifications"])
pub.sendMessage("mastodon.new_item", session_name=self.get_name(), item=notification, _buffers=["notifications"])

View File

View File

@@ -0,0 +1,212 @@
# -*- coding: utf-8 -*-
import builtins
import unittest
from sessions.blueski import compose, templates, utils
class TestBlueskyQuotedPosts(unittest.TestCase):
def setUp(self):
if not hasattr(builtins, "_"):
builtins._ = lambda s: s
def _build_quoted_post(self):
return {
"post": {
"author": {"handle": "alice.bsky.social", "displayName": "Alice"},
"record": {"text": "Main post text"},
"embed": {
"$type": "app.bsky.embed.recordWithMedia#view",
"record": {
"$type": "app.bsky.embed.record#view",
"record": {
"$type": "app.bsky.embed.record#viewRecord",
"author": {"handle": "bob.bsky.social"},
"value": {"text": "Quoted post text"},
},
},
"media": {
"$type": "app.bsky.embed.images#view",
"images": [],
},
},
"indexedAt": "2026-02-15T10:00:00Z",
}
}
def _with_reply_context(self, item, reply_to_handle="carol.bsky.social"):
if isinstance(item, dict):
item["reply"] = {
"parent": {
"author": {
"handle": reply_to_handle,
}
}
}
return item
def test_extract_quoted_post_info_with_text(self):
item = self._build_quoted_post()
info = utils.extract_quoted_post_info(item)
self.assertIsNotNone(info)
self.assertEqual(info["kind"], "post")
self.assertEqual(info["handle"], "bob.bsky.social")
self.assertEqual(info["text"], "Quoted post text")
def test_compose_post_includes_quoted_text(self):
item = self._build_quoted_post()
result = compose.compose_post(
item,
db={},
settings={"general": {}},
relative_times=True,
show_screen_names=False,
safe=True,
)
self.assertIn("Quoting @bob.bsky.social: Quoted post text", result[1])
def test_template_render_post_includes_quoted_text(self):
item = self._build_quoted_post()
rendered = templates.render_post(
item,
template="$display_name, $safe_text $date.",
settings={"general": {}},
relative_times=True,
offset_hours=0,
)
self.assertIn("Quoting @bob.bsky.social: Quoted post text", rendered)
def test_extract_quoted_post_info_includes_urls(self):
item = self._build_quoted_post()
quoted = item["post"]["embed"]["record"]["record"]
quoted["value"]["facets"] = [
{
"features": [
{
"$type": "app.bsky.richtext.facet#link",
"uri": "https://example.com/full-link",
}
]
}
]
info = utils.extract_quoted_post_info(item)
self.assertIn("https://example.com/full-link", info["urls"])
def test_find_urls_includes_quoted_urls(self):
item = self._build_quoted_post()
quoted = item["post"]["embed"]["record"]["record"]
quoted["value"]["facets"] = [
{
"features": [
{
"$type": "app.bsky.richtext.facet#link",
"uri": "https://example.com/quoted-only",
}
]
}
]
urls = utils.find_urls(item)
self.assertIn("https://example.com/quoted-only", urls)
def test_compose_post_appends_full_quoted_url(self):
item = self._build_quoted_post()
quoted = item["post"]["embed"]["record"]["record"]
quoted["value"]["text"] = "Mira example.com/..."
quoted["value"]["facets"] = [
{
"features": [
{
"$type": "app.bsky.richtext.facet#link",
"uri": "https://example.com/full-target",
}
]
}
]
result = compose.compose_post(
item,
db={},
settings={"general": {}},
relative_times=True,
show_screen_names=False,
safe=True,
)
self.assertIn("[https://example.com/full-target]", result[1])
def test_extract_reply_to_handle_from_record_parent_author(self):
item = {
"post": {
"record": {
"text": "Reply text",
"reply": {
"parent": {
"uri": "at://did:plc:parent/app.bsky.feed.post/abc",
"author": {"handle": "parent.user"},
}
},
}
}
}
handle = utils.extract_reply_to_handle(item)
self.assertEqual(handle, "parent.user")
def test_template_render_post_reply_fallback_without_reply_to_variable(self):
item = {
"post": {
"author": {"handle": "alice.bsky.social", "displayName": "Alice"},
"record": {
"text": "Reply body",
"reply": {
"parent": {
"uri": "at://did:plc:parent/app.bsky.feed.post/abc",
"author": {"handle": "parent.user"},
}
},
},
"indexedAt": "2026-02-15T10:00:00Z",
}
}
rendered = templates.render_post(
item,
template="$display_name, $safe_text $date.",
settings={"general": {}},
relative_times=True,
offset_hours=0,
)
self.assertIn("Replying to @parent.user.", rendered)
def test_extract_reply_to_handle(self):
item = self._with_reply_context(self._build_quoted_post())
handle = utils.extract_reply_to_handle(item)
self.assertEqual(handle, "carol.bsky.social")
def test_compose_post_includes_reply_context(self):
item = self._with_reply_context(self._build_quoted_post())
result = compose.compose_post(
item,
db={},
settings={"general": {}},
relative_times=True,
show_screen_names=False,
safe=True,
)
self.assertIn("Replying to @carol.bsky.social:", result[1])
def test_template_render_post_exposes_reply_to_variable(self):
item = self._with_reply_context(self._build_quoted_post())
rendered = templates.render_post(
item,
template="$reply_to$text",
settings={"general": {}},
relative_times=True,
offset_hours=0,
)
self.assertIn("Replying to @carol.bsky.social.", rendered)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,362 @@
# -*- coding: utf-8 -*-
import sys
import unittest
from unittest.mock import patch, AsyncMock, MagicMock, PropertyMock
# Assuming paths are set up correctly for test environment to find these
from sessions.blueski.session import Session as BlueskiSession
from sessions.session_exceptions import SessionLoginError, SessionError
from approve.notifications import NotificationError # Assuming this is the correct import path
from atproto.xrpc_client.models.common import XrpcError
from atproto.xrpc_client import models as atp_models # For ATProto models
from atproto.xrpc_client.models import ids # For lexicon IDs
# Mock wx for headless testing
class MockWxDialog:
def __init__(self, parent, message, caption, value="", style=0):
self.message = message
self.caption = caption
self.value = value
self.return_code = mock_wx.ID_CANCEL # Default to cancel, specific tests can change this
def ShowModal(self):
return self.return_code
def GetValue(self):
return self.value
def Destroy(self):
pass
class MockWxMessageBox(MockWxDialog):
pass
# Need to mock wx before it's imported by other modules if they do it at import time.
# Patching directly where used in session.py is generally safer.
mock_wx = MagicMock()
mock_wx.TextEntryDialog = MockWxDialog
mock_wx.PasswordEntryDialog = MockWxDialog
mock_wx.MessageBox = MockWxMessageBox
mock_wx.ID_OK = 1
mock_wx.ID_CANCEL = 2
mock_wx.ICON_ERROR = 16
mock_wx.ICON_INFORMATION = 64
mock_wx.OK = 4
mock_wx.YES_NO = 1 # Example, actual value might differ but not critical for test logic
mock_wx.YES = 1 # Example
mock_wx.ICON_QUESTION = 32 # Example
# Mock config objects
# This structure tries to mimic how config is accessed in session.py
# e.g., config.sessions.blueski[user_id].handle
class MockConfigNode:
def __init__(self, initial_value=None):
self._value = initial_value
self.get = MagicMock(return_value=self._value)
self.set = AsyncMock() # .set() is async
class MockUserSessionConfig:
def __init__(self):
self.handle = MockConfigNode("")
self.app_password = MockConfigNode("")
self.did = MockConfigNode("")
# Add other config values if session.py uses them for blueski
class MockBlueskiConfig:
def __init__(self):
self._user_configs = {"test_user": MockUserSessionConfig()}
def __getitem__(self, key):
return self._user_configs.get(key, MagicMock(return_value=MockUserSessionConfig())) # Return a mock if key not found
class MockSessionsConfig:
def __init__(self):
self.blueski = MockBlueskiConfig()
mock_config_global = MagicMock()
mock_config_global.sessions = MockSessionsConfig()
class TestBlueskiSession(unittest.IsolatedAsyncioTestCase):
@patch('sessions.blueski.session.wx', mock_wx)
@patch('sessions.blueski.session.config', mock_config_global)
def setUp(self):
self.mock_approval_api = MagicMock()
# Reset mocks for user_config part of global mock_config_global for each test
self.mock_user_config_instance = MockUserSessionConfig()
mock_config_global.sessions.blueski.__getitem__.return_value = self.mock_user_config_instance
self.session = BlueskiSession(approval_api=self.mock_approval_api, user_id="test_user", channel_id="test_channel")
self.session.db = {}
self.session.save_db = AsyncMock()
self.session.notify_session_ready = AsyncMock()
self.session.send_text_notification = MagicMock()
# Mock the util property to return a MagicMock for BlueskiUtils
self.mock_util_instance = AsyncMock() # Make it an AsyncMock if its methods are async
self.mock_util_instance._own_did = None # These are set directly by session.login
self.mock_util_instance._own_handle = None
# Add any methods from util that are directly called by session methods being tested
# e.g., self.mock_util_instance.get_own_did = MagicMock(return_value="did:plc:test")
self.session._util = self.mock_util_instance
self.session.util # Call property to ensure _util is set if it's lazy loaded
def test_session_initialization(self):
self.assertIsInstance(self.session, BlueskiSession)
self.assertEqual(self.session.KIND, "blueski")
self.assertIsNone(self.session.client)
self.assertEqual(self.session.user_id, "test_user")
@patch('sessions.blueski.session.AsyncClient')
async def test_login_successful(self, MockAsyncClient):
mock_client_instance = MockAsyncClient.return_value
# Use actual ATProto models for spec if possible for better type checking in mocks
mock_profile = MagicMock(spec=atp_models.ComAtprotoServerDefs.Session)
mock_profile.access_jwt = "fake_access_jwt"
mock_profile.refresh_jwt = "fake_refresh_jwt"
mock_profile.did = "did:plc:testdid"
mock_profile.handle = "testhandle.bsky.social"
mock_client_instance.login = AsyncMock(return_value=mock_profile)
self.session.config_get = MagicMock(return_value=None) # Simulate no pre-existing config
result = await self.session.login("testhandle.bsky.social", "test_password")
self.assertTrue(result)
self.assertIsNotNone(self.session.client)
mock_client_instance.login.assert_called_once_with("testhandle.bsky.social", "test_password")
self.assertEqual(self.session.db.get("access_jwt"), "fake_access_jwt")
self.assertEqual(self.session.db.get("did"), "did:plc:testdid")
self.assertEqual(self.session.db.get("handle"), "testhandle.bsky.social")
self.session.save_db.assert_called_once()
self.mock_user_config_instance.handle.set.assert_called_once_with("testhandle.bsky.social")
self.mock_user_config_instance.app_password.set.assert_called_once_with("test_password")
self.mock_user_config_instance.did.set.assert_called_once_with("did:plc:testdid")
self.assertEqual(self.session._util._own_did, "did:plc:testdid")
self.assertEqual(self.session._util._own_handle, "testhandle.bsky.social")
self.session.notify_session_ready.assert_called_once()
@patch('sessions.blueski.session.AsyncClient')
async def test_login_failure_xrpc(self, MockAsyncClient):
mock_client_instance = MockAsyncClient.return_value
mock_client_instance.login = AsyncMock(side_effect=XrpcError(error="AuthenticationFailed", message="Invalid credentials"))
self.session.config_get = MagicMock(return_value=None)
with self.assertRaises(NotificationError) as ctx:
await self.session.login("testhandle.bsky.social", "wrong_password")
self.assertTrue("Invalid handle or app password." in str(ctx.exception) or "Invalid credentials" in str(ctx.exception))
self.assertIsNone(self.session.client)
self.session.notify_session_ready.assert_not_called()
@patch('sessions.blueski.session.wx', new=mock_wx)
@patch.object(BlueskiSession, 'login', new_callable=AsyncMock)
async def test_authorise_successful(self, mock_login_method):
mock_login_method.return_value = True
mock_wx.TextEntryDialog.return_value.GetValue = MagicMock(return_value="test_handle")
mock_wx.TextEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK)
mock_wx.PasswordEntryDialog.return_value.GetValue = MagicMock(return_value="password_ok")
mock_wx.PasswordEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK)
self.session.config_get = MagicMock(return_value="prefill_handle") # For pre-filling handle dialog
result = await self.session.authorise()
self.assertTrue(result)
mock_login_method.assert_called_once_with("test_handle", "password_ok")
# Further check if wx.MessageBox was called with success
# This requires more complex mocking or inspection of calls to mock_wx.MessageBox
@patch('sessions.blueski.session.wx', new=mock_wx)
@patch.object(BlueskiSession, 'login', new_callable=AsyncMock)
async def test_authorise_login_fails_with_notification_error(self, mock_login_method):
mock_login_method.side_effect = NotificationError("Specific login failure from mock.")
mock_wx.TextEntryDialog.return_value.GetValue = MagicMock(return_value="test_handle")
mock_wx.TextEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK)
mock_wx.PasswordEntryDialog.return_value.GetValue = MagicMock(return_value="any_password")
mock_wx.PasswordEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK)
self.session.config_get = MagicMock(return_value="")
result = await self.session.authorise()
self.assertFalse(result)
mock_login_method.assert_called_once()
# --- Test Sending Posts ---
async def test_send_simple_post_successful(self):
self.session.is_ready = MagicMock(return_value=True) # Assume session is ready
self.session.util.post_status = AsyncMock(return_value="at://mock_post_uri")
post_uri = await self.session.send_message("Test text post")
self.assertEqual(post_uri, "at://mock_post_uri")
self.session.util.post_status.assert_called_once_with(
text="Test text post", media_ids=None, reply_to_uri=None, quote_uri=None,
cw_text=None, is_sensitive=False, langs=None, tags=None
)
async def test_send_post_with_quote_and_lang(self):
self.session.is_ready = MagicMock(return_value=True)
self.session.util.post_status = AsyncMock(return_value="at://mock_post_uri_quote")
post_uri = await self.session.send_message(
"Quoting another post",
quote_uri="at://did:plc:someuser/app.bsky.feed.post/somepostid",
langs=["en", "es"]
)
self.assertEqual(post_uri, "at://mock_post_uri_quote")
self.session.util.post_status.assert_called_once_with(
text="Quoting another post", media_ids=None, reply_to_uri=None,
quote_uri="at://did:plc:someuser/app.bsky.feed.post/somepostid",
cw_text=None, is_sensitive=False, langs=["en", "es"], tags=None
)
@patch('sessions.blueski.session.os.path.basename', return_value="image.png") # Mock os.path.basename
async def test_send_post_with_media(self, mock_basename):
self.session.is_ready = MagicMock(return_value=True)
mock_blob_info = {"blob_ref": MagicMock(spec=atp_models.ComAtprotoRepoStrongRef.Blob), "alt_text": "A test image"}
self.session.util.upload_media = AsyncMock(return_value=mock_blob_info)
self.session.util.post_status = AsyncMock(return_value="at://mock_post_uri_media")
post_uri = await self.session.send_message(
"Post with media", files=["dummy/path/image.png"], media_alt_texts=["A test image"]
)
self.assertEqual(post_uri, "at://mock_post_uri_media")
self.session.util.upload_media.assert_called_once_with("dummy/path/image.png", "image/png", alt_text="A test image")
self.session.util.post_status.assert_called_once_with(
text="Post with media", media_ids=[mock_blob_info], reply_to_uri=None, quote_uri=None,
cw_text=None, is_sensitive=False, langs=None, tags=None
)
async def test_send_post_util_failure(self):
self.session.is_ready = MagicMock(return_value=True)
self.session.util.post_status = AsyncMock(side_effect=NotificationError("Failed to post from util"))
with self.assertRaisesRegex(NotificationError, "Failed to post from util"):
await self.session.send_message("This will fail")
# --- Test Fetching Timelines ---
def _create_mock_feed_view_post(self, uri_suffix):
post_view = MagicMock(spec=atp_models.AppBskyFeedDefs.PostView)
post_view.uri = f"at://did:plc:test/app.bsky.feed.post/{uri_suffix}"
post_view.cid = f"cid_{uri_suffix}"
author_mock = MagicMock(spec=atp_models.AppBskyActorDefs.ProfileViewBasic)
author_mock.did = "did:plc:author"
author_mock.handle = "author.bsky.social"
post_view.author = author_mock
record_mock = MagicMock(spec=atp_models.AppBskyFeedPost.Main)
record_mock.text = f"Text of post {uri_suffix}"
record_mock.createdAt = "2024-01-01T00:00:00Z"
post_view.record = record_mock
feed_view_post = MagicMock(spec=atp_models.AppBskyFeedDefs.FeedViewPost)
feed_view_post.post = post_view
feed_view_post.reason = None
feed_view_post.reply = None
return feed_view_post
async def test_fetch_home_timeline_successful(self):
self.session.is_ready = MagicMock(return_value=True)
mock_post1 = self._create_mock_feed_view_post("post1")
mock_post2 = self._create_mock_feed_view_post("post2")
self.session.util.get_timeline = AsyncMock(return_value=([mock_post1, mock_post2], "cursor_for_home"))
self.session.order_buffer = AsyncMock(return_value=["uri1", "uri2"])
processed_uris, next_cursor = await self.session.fetch_home_timeline(limit=5, new_only=True)
self.session.util.get_timeline.assert_called_once_with(algorithm=None, limit=5, cursor=None)
self.session.order_buffer.assert_called_once_with(items=[mock_post1, mock_post2], new_only=True, buffer_name="home_timeline_buffer")
self.assertEqual(self.session.home_timeline_cursor, "cursor_for_home")
self.assertEqual(processed_uris, ["uri1", "uri2"])
async def test_fetch_user_timeline_successful(self):
self.session.is_ready = MagicMock(return_value=True)
mock_post3 = self._create_mock_feed_view_post("post3")
self.session.util.get_author_feed = AsyncMock(return_value=([mock_post3], "cursor_for_user"))
self.session.order_buffer = AsyncMock(return_value=["uri3"])
processed_uris, next_cursor = await self.session.fetch_user_timeline(
user_did="did:plc:targetuser", limit=10, filter_type="posts_no_replies"
)
self.session.util.get_author_feed.assert_called_once_with(
actor_did="did:plc:targetuser", limit=10, cursor=None, filter="posts_no_replies"
)
self.session.order_buffer.assert_called_once_with(items=[mock_post3], new_only=False, buffer_name='user_timeline_did:plc:targetuser')
self.assertEqual(next_cursor, "cursor_for_user")
self.assertEqual(processed_uris, ["uri3"])
async def test_fetch_timeline_failure(self):
self.session.is_ready = MagicMock(return_value=True)
self.session.util.get_timeline = AsyncMock(side_effect=NotificationError("API error for timeline"))
with self.assertRaisesRegex(NotificationError, "API error for timeline"):
await self.session.fetch_home_timeline()
# --- Test Fetching Notifications ---
def _create_mock_notification(self, reason: str, uri_suffix: str, isRead: bool = False):
notif = MagicMock(spec=atp_models.AppBskyNotificationListNotifications.Notification)
notif.uri = f"at://did:plc:test/app.bsky.feed.like/{uri_suffix}"
notif.cid = f"cid_notif_{uri_suffix}"
author_mock = MagicMock(spec=atp_models.AppBskyActorDefs.ProfileView)
author_mock.did = f"did:plc:otheruser{uri_suffix}"
author_mock.handle = f"other{uri_suffix}.bsky.social"
author_mock.displayName = f"Other User {uri_suffix}"
author_mock.avatar = "http://example.com/avatar.png"
notif.author = author_mock
notif.reason = reason
notif.reasonSubject = f"at://did:plc:test/app.bsky.feed.post/mypost{uri_suffix}" if reason != "follow" else None
if reason in ["mention", "reply", "quote"]:
record_mock = MagicMock(spec=atp_models.AppBskyFeedPost.Main)
record_mock.text = f"Notification related text for {reason}"
record_mock.createdAt = "2024-01-02T00:00:00Z"
notif.record = record_mock
else:
notif.record = MagicMock()
notif.isRead = isRead
notif.indexedAt = "2024-01-02T00:00:00Z"
return notif
async def test_fetch_notifications_successful_and_handler_dispatch(self):
self.session.is_ready = MagicMock(return_value=True)
self.session.util = AsyncMock()
mock_like_notif = self._create_mock_notification("like", "like1", isRead=False)
mock_mention_notif = self._create_mock_notification("mention", "mention1", isRead=False)
self.session.util.get_notifications = AsyncMock(return_value=([mock_like_notif, mock_mention_notif], "next_notif_cursor"))
self.session._handle_like_notification = AsyncMock()
self.session._handle_mention_notification = AsyncMock()
self.session._handle_repost_notification = AsyncMock()
self.session._handle_follow_notification = AsyncMock()
self.session._handle_reply_notification = AsyncMock()
self.session._handle_quote_notification = AsyncMock()
returned_cursor = await self.session.fetch_notifications(limit=10)
self.session.util.get_notifications.assert_called_once_with(limit=10, cursor=None)
self.session._handle_like_notification.assert_called_once_with(mock_like_notif)
self.session._handle_mention_notification.assert_called_once_with(mock_mention_notif)
self.assertEqual(returned_cursor, "next_notif_cursor")
if __name__ == '__main__':
unittest.main()
# Minimal wx mock for running tests headlessly
if 'wx' not in sys.modules: # type: ignore
sys.modules['wx'] = MagicMock()
mock_wx_module = sys.modules['wx']
mock_wx_module.ID_OK = 1
mock_wx_module.ID_CANCEL = 2
mock_wx_module.ICON_ERROR = 16
mock_wx_module.ICON_INFORMATION = 64
mock_wx_module.OK = 4
mock_wx_module.TextEntryDialog = MockWxDialog
mock_wx_module.PasswordEntryDialog = MockWxDialog
mock_wx_module.MessageBox = MockWxMessageBox
mock_wx_module.CallAfter = MagicMock()
mock_wx_module.GetApp = MagicMock()

0
src/twblue.pot Normal file
View File

View File

@@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
import wx
import languageHandler
from multiplatform_widgets import widgets
class HomePanel(wx.Panel):
def __init__(self, parent, name, account="Unknown"):
super().__init__(parent, name=name)
self.name = name
self.account = account
self.type = "home_timeline"
self.sizer = wx.BoxSizer(wx.VERTICAL)
# List
self.list = widgets.list(self, _("Author"), _("Post"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES)
self.list.set_windows_size(0, 120)
self.list.set_windows_size(1, 400)
self.list.set_windows_size(2, 120)
self.list.set_size()
# Buttons
self.post = wx.Button(self, -1, _("Post"))
self.repost = wx.Button(self, -1, _("Repost"))
self.reply = wx.Button(self, -1, _("Reply"))
self.like = wx.Button(self, wx.ID_ANY, _("Like"))
# self.bookmark = wx.Button(self, wx.ID_ANY, _("Bookmark")) # Not yet common in Bsky API usage here
self.dm = wx.Button(self, -1, _("Chat"))
btnSizer = wx.BoxSizer(wx.HORIZONTAL)
btnSizer.Add(self.post, 0, wx.ALL, 5)
btnSizer.Add(self.repost, 0, wx.ALL, 5)
btnSizer.Add(self.reply, 0, wx.ALL, 5)
btnSizer.Add(self.like, 0, wx.ALL, 5)
# btnSizer.Add(self.bookmark, 0, wx.ALL, 5)
btnSizer.Add(self.dm, 0, wx.ALL, 5)
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5)
self.SetSizer(self.sizer)
# Some helper methods expected by controller might be needed?
# Controller accesses self.buffer.list directly.
# Some older code expected .set_position, .post, .message, .actions attributes or buttons on the panel?
# Mastodon panels usually have bottom buttons (Post, Reply, etc).
# I should add them if I want to "reuse Mastodon".
# But for now, simple list is what the previous code had.
def set_focus_function(self, func):
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func)
def set_position(self, reverse):
if reverse:
self.list.select_item(0)
else:
self.list.select_item(self.list.get_count() - 1)
def set_focus_in_list(self):
self.list.list.SetFocus()
class NotificationPanel(HomePanel):
pass
class UserPanel(wx.Panel):
def __init__(self, parent, name, account="Unknown"):
super().__init__(parent, name=name)
self.name = name
self.account = account
self.type = "user"
self.sizer = wx.BoxSizer(wx.VERTICAL)
# List: User
self.list = widgets.list(self, _("User"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES)
self.list.set_windows_size(0, 600)
self.list.set_size()
# Buttons
self.post = wx.Button(self, -1, _("Post"))
self.actions = wx.Button(self, -1, _("Actions"))
self.message = wx.Button(self, -1, _("Message"))
btnSizer = wx.BoxSizer(wx.HORIZONTAL)
btnSizer.Add(self.post, 0, wx.ALL, 5)
btnSizer.Add(self.actions, 0, wx.ALL, 5)
btnSizer.Add(self.message, 0, wx.ALL, 5)
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5)
self.SetSizer(self.sizer)
def set_focus_function(self, func):
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func)
def set_position(self, reverse):
if reverse:
self.list.select_item(0)
else:
self.list.select_item(self.list.get_count() - 1)
def set_focus_in_list(self):
self.list.list.SetFocus()
class ChatPanel(wx.Panel):
"""Panel for conversation list, similar to Mastodon's conversationListPanel."""
def __init__(self, parent, name, account="Unknown"):
super().__init__(parent, name=name)
self.name = name
self.account = account
self.type = "chat"
self.sizer = wx.BoxSizer(wx.VERTICAL)
# List: User, Text, Date (like Mastodon)
self.list = widgets.list(self, _("User"), _("Text"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES)
self.list.set_windows_size(0, 200)
self.list.set_windows_size(1, 600)
self.list.set_windows_size(2, 200)
self.list.set_size()
# Buttons (like Mastodon: Post, Reply)
self.post = wx.Button(self, -1, _("Post"))
self.reply = wx.Button(self, -1, _("Reply"))
self.new_chat = wx.Button(self, -1, _("New Chat"))
btnSizer = wx.BoxSizer(wx.HORIZONTAL)
btnSizer.Add(self.post, 0, wx.ALL, 5)
btnSizer.Add(self.reply, 0, wx.ALL, 5)
btnSizer.Add(self.new_chat, 0, wx.ALL, 5)
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5)
self.SetSizer(self.sizer)
def set_focus_function(self, func):
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func)
def set_position(self, reversed=False):
if reversed == False:
self.list.select_item(self.list.get_count()-1)
else:
self.list.select_item(0)
def set_focus_in_list(self):
self.list.list.SetFocus()
class ChatMessagePanel(HomePanel):
def __init__(self, parent, name, account="Unknown"):
super().__init__(parent, name, account)
self.type = "chat_messages"
# Adjust buttons for chat
self.repost.Hide()
self.like.Hide()
self.dm.Hide() # Hide Chat button since we're already in a chat
self.reply.SetLabel(_("Send Message"))
# Refresh columns
self.list.list.ClearAll()
self.list.list.InsertColumn(0, _("Sender"))
self.list.list.InsertColumn(1, _("Message"))
self.list.list.InsertColumn(2, _("Date"))
self.list.set_windows_size(0, 100)
self.list.set_windows_size(1, 400)
self.list.set_windows_size(2, 100)
self.list.set_size()

View File

@@ -59,3 +59,8 @@ def remove_filter():
return dlg.ShowModal()
def error_removing_filters():
return wx.MessageDialog(None, _("TWBlue was unable to remove the filter you specified. Please try again."), _("Error"), wx.ICON_ERROR).ShowModal()
def common_error(message):
"""Show a generic error dialog with the provided message."""
dlg = wx.MessageDialog(None, message, _("Error"), wx.OK | wx.ICON_ERROR)
return dlg.ShowModal()

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
import wx
import languageHandler
class AccountSettingsDialog(wx.Dialog):
def __init__(self, parent=None, ask_before_boost=True):
super(AccountSettingsDialog, self).__init__(parent, title=_("Bluesky Account Settings"))
panel = wx.Panel(self)
sizer = wx.BoxSizer(wx.VERTICAL)
# Ask before boost/share
self.ask_before_boost = wx.CheckBox(panel, wx.ID_ANY, _("Ask confirmation before sharing a post"))
self.ask_before_boost.SetValue(bool(ask_before_boost))
sizer.Add(self.ask_before_boost, 0, wx.ALL, 8)
templates_box = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Templates")), wx.VERTICAL)
self.template_post = wx.Button(panel, wx.ID_ANY, _("Edit template for posts"))
self.template_person = wx.Button(panel, wx.ID_ANY, _("Edit template for persons"))
self.template_notification = wx.Button(panel, wx.ID_ANY, _("Edit template for notifications"))
templates_box.Add(self.template_post, 0, wx.ALL, 4)
templates_box.Add(self.template_person, 0, wx.ALL, 4)
templates_box.Add(self.template_notification, 0, wx.ALL, 4)
sizer.Add(templates_box, 0, wx.EXPAND | wx.ALL, 8)
# Buttons
btn_sizer = self.CreateSeparatedButtonSizer(wx.OK | wx.CANCEL)
panel.SetSizer(sizer)
main = wx.BoxSizer(wx.VERTICAL)
main.Add(panel, 1, wx.EXPAND | wx.ALL, 10)
if btn_sizer:
main.Add(btn_sizer, 0, wx.EXPAND | wx.ALL, 10)
self.SetSizerAndFit(main)
def get_values(self):
return {
"ask_before_boost": self.ask_before_boost.GetValue(),
}
def set_template_labels(self, post_template, person_template, notification_template):
self.template_post.SetLabel(_("Edit template for posts. Current template: {}").format(post_template))
self.template_person.SetLabel(_("Edit template for persons. Current template: {}").format(person_template))
self.template_notification.SetLabel(_("Edit template for notifications. Current template: {}").format(notification_template))

View File

@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
"""Context menus for Bluesky buffers."""
import wx
class baseMenu(wx.Menu):
"""Base context menu for Bluesky posts."""
def __init__(self):
super(baseMenu, self).__init__()
self.repost = wx.MenuItem(self, wx.ID_ANY, _("&Repost"))
self.Append(self.repost)
self.quote = wx.MenuItem(self, wx.ID_ANY, _("&Quote"))
self.Append(self.quote)
self.reply = wx.MenuItem(self, wx.ID_ANY, _("Re&ply"))
self.Append(self.reply)
self.like = wx.MenuItem(self, wx.ID_ANY, _("&Like"))
self.Append(self.like)
self.unlike = wx.MenuItem(self, wx.ID_ANY, _("&Unlike"))
self.Append(self.unlike)
self.openUrl = wx.MenuItem(self, wx.ID_ANY, _("&Open URL"))
self.Append(self.openUrl)
self.openInBrowser = wx.MenuItem(self, wx.ID_ANY, _("Open in &browser"))
self.Append(self.openInBrowser)
self.view = wx.MenuItem(self, wx.ID_ANY, _("&Show post"))
self.Append(self.view)
self.copy = wx.MenuItem(self, wx.ID_ANY, _("&Copy to clipboard"))
self.Append(self.copy)
self.remove = wx.MenuItem(self, wx.ID_ANY, _("&Delete"))
self.Append(self.remove)
self.userActions = wx.MenuItem(self, wx.ID_ANY, _("&User actions..."))
self.Append(self.userActions)
class notificationMenu(wx.Menu):
"""Context menu for Bluesky notifications."""
def __init__(self, notification_type="like"):
super(notificationMenu, self).__init__()
# Notification types that have associated posts
post_types = ["like", "repost", "mention", "reply", "quote"]
if notification_type in post_types:
self.repost = wx.MenuItem(self, wx.ID_ANY, _("&Repost"))
self.Append(self.repost)
self.reply = wx.MenuItem(self, wx.ID_ANY, _("Re&ply"))
self.Append(self.reply)
self.like = wx.MenuItem(self, wx.ID_ANY, _("&Like"))
self.Append(self.like)
self.openUrl = wx.MenuItem(self, wx.ID_ANY, _("&Open URL"))
self.Append(self.openUrl)
self.openInBrowser = wx.MenuItem(self, wx.ID_ANY, _("Open in &browser"))
self.Append(self.openInBrowser)
self.view = wx.MenuItem(self, wx.ID_ANY, _("&Show post"))
self.Append(self.view)
self.copy = wx.MenuItem(self, wx.ID_ANY, _("&Copy to clipboard"))
self.Append(self.copy)
self.userActions = wx.MenuItem(self, wx.ID_ANY, _("&User actions..."))
self.Append(self.userActions)
class userMenu(wx.Menu):
"""Context menu for Bluesky user lists."""
def __init__(self):
super(userMenu, self).__init__()
self.timeline = wx.MenuItem(self, wx.ID_ANY, _("View &timeline"))
self.Append(self.timeline)
self.followers = wx.MenuItem(self, wx.ID_ANY, _("View f&ollowers"))
self.Append(self.followers)
self.following = wx.MenuItem(self, wx.ID_ANY, _("View &following"))
self.Append(self.following)
self.dm = wx.MenuItem(self, wx.ID_ANY, _("Send &message"))
self.Append(self.dm)
self.view = wx.MenuItem(self, wx.ID_ANY, _("View &profile"))
self.Append(self.view)
self.copy = wx.MenuItem(self, wx.ID_ANY, _("&Copy to clipboard"))
self.Append(self.copy)
self.userActions = wx.MenuItem(self, wx.ID_ANY, _("&User actions..."))
self.Append(self.userActions)
class chatMenu(wx.Menu):
"""Context menu for Bluesky chat messages."""
def __init__(self):
super(chatMenu, self).__init__()
self.reply = wx.MenuItem(self, wx.ID_ANY, _("&Reply"))
self.Append(self.reply)
self.copy = wx.MenuItem(self, wx.ID_ANY, _("&Copy to clipboard"))
self.Append(self.copy)
self.view = wx.MenuItem(self, wx.ID_ANY, _("&Show message"))
self.Append(self.view)
self.userActions = wx.MenuItem(self, wx.ID_ANY, _("&User actions..."))
self.Append(self.userActions)

View File

@@ -0,0 +1,292 @@
# -*- coding: utf-8 -*-
import wx
class Post(wx.Dialog):
def __init__(self, caption=_("Post"), text="", languages=[], *args, **kwds):
super(Post, self).__init__(parent=None, id=wx.ID_ANY, *args, **kwds)
self.SetTitle(caption)
main_sizer = wx.BoxSizer(wx.VERTICAL)
# Text
post_label = wx.StaticText(self, wx.ID_ANY, caption)
main_sizer.Add(post_label, 0, wx.ALL, 6)
self.text = wx.TextCtrl(self, wx.ID_ANY, text, style=wx.TE_MULTILINE)
self.Bind(wx.EVT_CHAR_HOOK, self.handle_keys, self.text)
self.text.SetMinSize((400, 160))
main_sizer.Add(self.text, 1, wx.EXPAND | wx.ALL, 6)
# Sensitive + CW
self.sensitive = wx.CheckBox(self, wx.ID_ANY, _("S&ensitive content"))
self.sensitive.SetValue(False)
self.sensitive.Bind(wx.EVT_CHECKBOX, self.on_sensitivity_changed)
main_sizer.Add(self.sensitive, 0, wx.ALL, 5)
spoiler_box = wx.BoxSizer(wx.HORIZONTAL)
spoiler_label = wx.StaticText(self, wx.ID_ANY, _("Content warning"))
self.spoiler = wx.TextCtrl(self, wx.ID_ANY)
self.spoiler.Enable(False)
spoiler_box.Add(spoiler_label, 0, wx.ALL, 5)
spoiler_box.Add(self.spoiler, 1, wx.ALL, 10)
main_sizer.Add(spoiler_box, 0, wx.EXPAND | wx.ALL, 5)
# Attachments (images only)
attach_box = wx.StaticBoxSizer(wx.VERTICAL, self, _("Attachments (images)"))
self.attach_list = wx.ListCtrl(self, style=wx.LC_REPORT | wx.LC_SINGLE_SEL)
self.attach_list.InsertColumn(0, _("File"))
self.attach_list.InsertColumn(1, _("Alt"))
attach_box.Add(self.attach_list, 1, wx.EXPAND | wx.ALL, 5)
btn_row = wx.BoxSizer(wx.HORIZONTAL)
self.btn_add = wx.Button(self, wx.ID_ADD, _("Add image..."))
self.btn_remove = wx.Button(self, wx.ID_REMOVE, _("Remove"))
self.btn_remove.Enable(False)
btn_row.Add(self.btn_add, 0, wx.ALL, 2)
btn_row.Add(self.btn_remove, 0, wx.ALL, 2)
attach_box.Add(btn_row, 0, wx.ALIGN_LEFT)
main_sizer.Add(attach_box, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 6)
# Language (single optional)
lang_row = wx.BoxSizer(wx.HORIZONTAL)
lang_row.Add(wx.StaticText(self, label=_("&Language")), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4)
self.language = wx.ComboBox(self, wx.ID_ANY, choices=languages, style=wx.CB_DROPDOWN | wx.CB_READONLY)
self.language.SetSelection(0)
lang_row.Add(self.language, 0, wx.ALIGN_CENTER_VERTICAL)
main_sizer.Add(lang_row, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 6)
# Text actions (spellcheck, translate, autocomplete)
text_actions_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.autocomplete_users = wx.Button(self, wx.ID_ANY, _("Auto&complete users"))
text_actions_sizer.Add(self.autocomplete_users, 0, wx.ALL, 2)
self.spellcheck = wx.Button(self, wx.ID_ANY, _("Check &spelling"))
text_actions_sizer.Add(self.spellcheck, 0, wx.ALL, 2)
self.translate = wx.Button(self, wx.ID_ANY, _("&Translate"))
text_actions_sizer.Add(self.translate, 0, wx.ALL, 2)
main_sizer.Add(text_actions_sizer, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 6)
# Buttons
btn_sizer = wx.StdDialogButtonSizer()
self.send = wx.Button(self, wx.ID_ANY, _("&Send"))
self.send.SetDefault()
self.send.Bind(wx.EVT_BUTTON, lambda evt: self.EndModal(wx.ID_OK))
btn_sizer.AddButton(self.send)
self.close = wx.Button(self, wx.ID_CLOSE, "")
btn_sizer.AddButton(self.close)
btn_sizer.Realize()
main_sizer.Add(btn_sizer, 0, wx.ALIGN_RIGHT | wx.ALL, 4)
self.SetSizer(main_sizer)
main_sizer.Fit(self)
self.SetEscapeId(self.close.GetId())
self.Layout()
# Bindings
self.btn_add.Bind(wx.EVT_BUTTON, self.on_add)
self.btn_remove.Bind(wx.EVT_BUTTON, self.on_remove)
self.attach_list.Bind(wx.EVT_LIST_ITEM_SELECTED, lambda evt: self.btn_remove.Enable(True))
self.attach_list.Bind(wx.EVT_LIST_ITEM_DESELECTED, lambda evt: self.btn_remove.Enable(False))
def handle_keys(self, event):
shift = event.ShiftDown()
if event.GetKeyCode() == wx.WXK_RETURN and not shift and hasattr(self, "send"):
self.EndModal(wx.ID_OK)
else:
event.Skip()
def on_sensitivity_changed(self, *args, **kwargs):
self.spoiler.Enable(self.sensitive.GetValue())
def on_add(self, evt):
if self.attach_list.GetItemCount() >= 4:
wx.MessageBox(_("You can attach up to 4 images."), _("Attachment limit"), wx.ICON_INFORMATION)
return
fd = wx.FileDialog(self, _("Select image"), wildcard=_("Image files (*.png;*.jpg;*.jpeg;*.gif)|*.png;*.jpg;*.jpeg;*.gif"), style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST)
if fd.ShowModal() != wx.ID_OK:
fd.Destroy()
return
path = fd.GetPath()
fd.Destroy()
alt_dlg = wx.TextEntryDialog(self, _("Alternative text (optional)"), _("Description"))
alt = ""
if alt_dlg.ShowModal() == wx.ID_OK:
alt = alt_dlg.GetValue()
alt_dlg.Destroy()
idx = self.attach_list.InsertItem(self.attach_list.GetItemCount(), path)
self.attach_list.SetItem(idx, 1, alt)
def on_remove(self, evt):
sel = self.attach_list.GetFirstSelected()
if sel != -1:
self.attach_list.DeleteItem(sel)
def get_payload(self):
text = self.text.GetValue().strip()
cw_text = self.spoiler.GetValue().strip() if self.sensitive.GetValue() else None
lang_index = self.language.GetSelection()
files = []
for i in range(self.attach_list.GetItemCount()):
files.append({
"path": self.attach_list.GetItemText(i, 0),
"alt": self.attach_list.GetItemText(i, 1),
})
return text, files, cw_text, lang_index
class viewPost(wx.Dialog):
def set_title(self, length):
self.SetTitle(_("Post - %i characters ") % length)
def __init__(self, text="", reposts_count=0, likes_count=0, source="", date="", privacy="", *args, **kwargs):
super(viewPost, self).__init__(parent=None, id=wx.ID_ANY, size=(850, 850))
self.init_ui(text, reposts_count, likes_count, source, date, privacy)
def init_ui(self, text, reposts_count, likes_count, source, date, privacy):
panel = wx.Panel(self)
main_sizer = wx.BoxSizer(wx.VERTICAL)
main_sizer.Add(self.create_text_section(panel, text), 1, wx.EXPAND | wx.ALL, 5)
main_sizer.Add(self.create_image_description_section(panel), 1, wx.EXPAND | wx.ALL, 5)
main_sizer.Add(self.create_info_section(panel, privacy, reposts_count, likes_count, source, date), 0, wx.EXPAND | wx.ALL, 5)
main_sizer.Add(self.create_buttons_section(panel), 0, wx.ALIGN_RIGHT | wx.ALL, 5)
panel.SetSizer(main_sizer)
self.SetClientSize(main_sizer.CalcMin())
def create_text_section(self, panel, text):
sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Post")), wx.VERTICAL)
self.text = wx.TextCtrl(panel, -1, text, style=wx.TE_READONLY | wx.TE_MULTILINE)
sizer.Add(self.text, 1, wx.EXPAND | wx.ALL, 5)
return sizer
def create_image_description_section(self, panel):
sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Image description")), wx.VERTICAL)
self.image_description = wx.TextCtrl(panel, -1, style=wx.TE_READONLY | wx.TE_MULTILINE)
self.image_description.Enable(False)
sizer.Add(self.image_description, 1, wx.EXPAND | wx.ALL, 5)
return sizer
def create_info_section(self, panel, privacy, reposts_count, likes_count, source, date):
sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Information")), wx.VERTICAL)
flex_sizer = wx.FlexGridSizer(cols=3, hgap=10, vgap=10)
flex_sizer.AddGrowableCol(1)
flex_sizer.Add(wx.StaticText(panel, -1, _("Privacy")), 0, wx.ALIGN_CENTER_VERTICAL)
flex_sizer.Add(wx.TextCtrl(panel, -1, privacy, style=wx.TE_READONLY | wx.TE_MULTILINE), 1, wx.EXPAND)
flex_sizer.Add(self.create_reposts_section(panel, reposts_count), 1, wx.EXPAND | wx.ALL, 5)
flex_sizer.Add(self.create_likes_section(panel, likes_count), 1, wx.EXPAND | wx.ALL, 5)
flex_sizer.Add(wx.StaticText(panel, -1, _("Source")), 0, wx.ALIGN_CENTER_VERTICAL)
flex_sizer.Add(wx.TextCtrl(panel, -1, source, style=wx.TE_READONLY | wx.TE_MULTILINE), 1, wx.EXPAND)
flex_sizer.Add(wx.StaticText(panel, -1, _("Date")), 0, wx.ALIGN_CENTER_VERTICAL)
flex_sizer.Add(wx.TextCtrl(panel, -1, date, style=wx.TE_READONLY | wx.TE_MULTILINE), 1, wx.EXPAND)
sizer.Add(flex_sizer, 1, wx.EXPAND | wx.ALL, 5)
return sizer
def create_reposts_section(self, panel, reposts_count):
sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Reposts")), wx.VERTICAL)
self.reposts_button = wx.Button(panel, -1, str(reposts_count))
self.reposts_button.Enable(False)
sizer.Add(self.reposts_button, 1, wx.EXPAND | wx.ALL, 5)
return sizer
def create_likes_section(self, panel, likes_count):
sizer = wx.StaticBoxSizer(wx.StaticBox(panel, wx.ID_ANY, _("Likes")), wx.VERTICAL)
self.likes_button = wx.Button(panel, -1, str(likes_count))
self.likes_button.Enable(False)
sizer.Add(self.likes_button, 1, wx.EXPAND | wx.ALL, 5)
return sizer
def create_buttons_section(self, panel):
sizer = wx.BoxSizer(wx.HORIZONTAL)
self.share = wx.Button(panel, wx.ID_ANY, _("&Copy link to clipboard"))
self.share.Enable(False)
self.spellcheck = wx.Button(panel, wx.ID_ANY, _("Check &spelling..."))
self.translateButton = wx.Button(panel, wx.ID_ANY, _("&Translate..."))
cancelButton = wx.Button(panel, wx.ID_CANCEL, _("C&lose"))
cancelButton.SetDefault()
sizer.Add(self.share, 0, wx.ALL, 5)
sizer.Add(self.spellcheck, 0, wx.ALL, 5)
sizer.Add(self.translateButton, 0, wx.ALL, 5)
sizer.Add(cancelButton, 0, wx.ALL, 5)
return sizer
def set_text(self, text):
self.text.ChangeValue(text)
def get_text(self):
return self.text.GetValue()
def text_focus(self):
self.text.SetFocus()
def onSelect(self, ev):
self.text.SelectAll()
def enable_button(self, buttonName):
if hasattr(self, buttonName):
return getattr(self, buttonName).Enable()
class viewText(wx.Dialog):
def __init__(self, title="", text="", *args, **kwargs):
super(viewText, self).__init__(parent=None, id=wx.ID_ANY, size=(850, 850), title=title)
panel = wx.Panel(self)
label = wx.StaticText(panel, -1, _("Text"))
self.text = wx.TextCtrl(panel, -1, text, style=wx.TE_READONLY | wx.TE_MULTILINE)
self.text.SetMinSize((500, 300))
self.text.SetFocus()
textBox = wx.BoxSizer(wx.HORIZONTAL)
textBox.Add(label, 0, wx.ALL, 5)
textBox.Add(self.text, 1, wx.EXPAND, 5)
mainBox = wx.BoxSizer(wx.VERTICAL)
mainBox.Add(textBox, 1, wx.EXPAND | wx.ALL, 5)
self.spellcheck = wx.Button(panel, -1, _("Check &spelling..."), size=wx.DefaultSize)
self.translateButton = wx.Button(panel, -1, _("&Translate..."), size=wx.DefaultSize)
cancelButton = wx.Button(panel, wx.ID_CANCEL, _("C&lose"), size=wx.DefaultSize)
cancelButton.SetDefault()
buttonsBox = wx.BoxSizer(wx.HORIZONTAL)
buttonsBox.Add(self.spellcheck, 0, wx.ALL, 5)
buttonsBox.Add(self.translateButton, 0, wx.ALL, 5)
buttonsBox.Add(cancelButton, 0, wx.ALL, 5)
mainBox.Add(buttonsBox, 0, wx.ALL, 5)
panel.SetSizer(mainBox)
self.SetMinSize((600, 400))
self.Layout()
class RepostDialog(wx.Dialog):
def __init__(self):
super(RepostDialog, self).__init__(None, title=_("Repost"))
p = wx.Panel(self)
sizer = wx.BoxSizer(wx.VERTICAL)
lbl = wx.StaticText(p, wx.ID_ANY, _("What would you like to do with this post?"))
sizer.Add(lbl, 0, wx.ALL, 10)
btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.btn_repost = wx.Button(p, wx.ID_ANY, _("Repost"))
self.btn_quote = wx.Button(p, wx.ID_ANY, _("Quote"))
self.btn_cancel = wx.Button(p, wx.ID_CANCEL, _("Cancel"))
btn_sizer.Add(self.btn_repost, 0, wx.ALL, 5)
btn_sizer.Add(self.btn_quote, 0, wx.ALL, 5)
btn_sizer.Add(self.btn_cancel, 0, wx.ALL, 5)
sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER)
p.SetSizer(sizer)
sizer.Fit(self)
self.btn_repost.Bind(wx.EVT_BUTTON, self.on_repost)
self.btn_quote.Bind(wx.EVT_BUTTON, self.on_quote)
self.result = 0
def on_repost(self, event):
self.result = 1
self.EndModal(wx.ID_OK)
def on_quote(self, event):
self.result = 2
self.EndModal(wx.ID_OK)
def repost_question():
dlg = RepostDialog()
dlg.ShowModal()
result = dlg.result
dlg.Destroy()
return result

View File

@@ -0,0 +1,412 @@
# -*- coding: utf-8 -*-
import wx
import logging
import languageHandler
import builtins
import requests
from io import BytesIO
from threading import Thread
from pubsub import pub
_ = getattr(builtins, "_", lambda s: s)
logger = logging.getLogger(__name__)
def returnTrue():
return True
class ShowUserProfileDialog(wx.Dialog):
def __init__(self, parent, session, user_identifier: str): # user_identifier can be DID or handle
super(ShowUserProfileDialog, self).__init__(parent, title=_("User Profile"), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
self.session = session
self.user_identifier = user_identifier
self.profile_data = None # Will store the formatted profile dict
self.target_user_did = None # Will store the resolved DID of the profile being viewed
self._init_ui()
self.SetMinSize((400, 300))
self.CentreOnParent()
Thread(target=self.load_profile_data, daemon=True).start()
def _init_ui(self):
self.panel = wx.Panel(self)
main_sizer = wx.BoxSizer(wx.VERTICAL)
# Profile Info Section (StaticTexts for labels and values)
self.info_grid_sizer = wx.FlexGridSizer(cols=2, vgap=5, hgap=5)
self.info_grid_sizer.AddGrowableCol(1, 1)
# Basic text fields (name, handle, bio)
fields = [
(_("&Name:"), "displayName"), (_("&Handle:"), "handle"),
(_("&Bio:"), "description")
]
self.profile_field_ctrls = {}
for label_text, data_key in fields:
lbl = wx.StaticText(self.panel, label=label_text)
style = wx.TE_READONLY | wx.TE_PROCESS_TAB
if data_key == "description":
style |= wx.TE_MULTILINE
else:
style |= wx.BORDER_NONE
val_ctrl = wx.TextCtrl(self.panel, style=style)
if data_key != "description":
val_ctrl.SetBackgroundColour(self.panel.GetBackgroundColour())
val_ctrl.AcceptsFocusFromKeyboard = returnTrue
self.info_grid_sizer.Add(lbl, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2)
self.info_grid_sizer.Add(val_ctrl, 1, wx.EXPAND | wx.ALL, 2)
self.profile_field_ctrls[data_key] = val_ctrl
# Banner image
bannerLabel = wx.StaticText(self.panel, label=_("Banner:"))
self.bannerImage = wx.StaticBitmap(self.panel)
self.bannerImage.AcceptsFocusFromKeyboard = returnTrue
self.info_grid_sizer.Add(bannerLabel, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2)
self.info_grid_sizer.Add(self.bannerImage, 0, wx.ALL, 2)
# Avatar image
avatarLabel = wx.StaticText(self.panel, label=_("Avatar:"))
self.avatarImage = wx.StaticBitmap(self.panel)
self.avatarImage.AcceptsFocusFromKeyboard = returnTrue
self.info_grid_sizer.Add(avatarLabel, 0, wx.ALIGN_RIGHT | wx.ALIGN_TOP | wx.ALL, 2)
self.info_grid_sizer.Add(self.avatarImage, 0, wx.ALL, 2)
main_sizer.Add(self.info_grid_sizer, 1, wx.EXPAND | wx.ALL, 10)
# Timeline buttons (like Mastodon - with counters)
timeline_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.posts_btn = wx.Button(self.panel, label=_("0 pos&ts"))
self.posts_btn.Bind(wx.EVT_BUTTON, self.onPosts)
timeline_sizer.Add(self.posts_btn, 0, wx.ALL, 3)
self.following_btn = wx.Button(self.panel, label=_("0 &following"))
self.following_btn.Bind(wx.EVT_BUTTON, self.onFollowing)
timeline_sizer.Add(self.following_btn, 0, wx.ALL, 3)
self.followers_btn = wx.Button(self.panel, label=_("0 fo&llowers"))
self.followers_btn.Bind(wx.EVT_BUTTON, self.onFollowers)
timeline_sizer.Add(self.followers_btn, 0, wx.ALL, 3)
main_sizer.Add(timeline_sizer, 0, wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, 5)
# Action Buttons
actions_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.follow_btn = wx.Button(self.panel, label=_("&Follow"))
self.unfollow_btn = wx.Button(self.panel, label=_("U&nfollow"))
self.mute_btn = wx.Button(self.panel, label=_("&Mute"))
self.unmute_btn = wx.Button(self.panel, label=_("Unmu&te"))
self.block_btn = wx.Button(self.panel, label=_("&Block"))
self.unblock_btn = wx.Button(self.panel, label=_("Unbl&ock"))
self.follow_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="follow_user": self.on_user_action(evt, cmd))
self.unfollow_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unfollow_user": self.on_user_action(evt, cmd))
self.mute_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="mute_user": self.on_user_action(evt, cmd))
self.unmute_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unmute_user": self.on_user_action(evt, cmd))
self.block_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="block_user": self.on_user_action(evt, cmd))
self.unblock_btn.Bind(wx.EVT_BUTTON, lambda evt, cmd="unblock_user": self.on_user_action(evt, cmd))
actions_sizer.Add(self.follow_btn, 0, wx.ALL, 3)
actions_sizer.Add(self.unfollow_btn, 0, wx.ALL, 3)
actions_sizer.Add(self.mute_btn, 0, wx.ALL, 3)
actions_sizer.Add(self.unmute_btn, 0, wx.ALL, 3)
actions_sizer.Add(self.block_btn, 0, wx.ALL, 3)
actions_sizer.Add(self.unblock_btn, 0, wx.ALL, 3)
main_sizer.Add(actions_sizer, 0, wx.ALIGN_CENTER | wx.TOP | wx.BOTTOM, 10)
# Close Button
close_btn = wx.Button(self.panel, wx.ID_CANCEL, _("&Close"))
close_btn.SetDefault()
main_sizer.Add(close_btn, 0, wx.ALIGN_RIGHT | wx.ALL, 10)
self.SetEscapeId(close_btn.GetId())
self.panel.SetSizer(main_sizer)
self.Fit()
def load_profile_data(self):
wx.CallAfter(self.SetStatusText, _("Loading profile..."))
for ctrl in self.profile_field_ctrls.values():
wx.CallAfter(ctrl.SetValue, _("Loading..."))
# Initially hide all action buttons until state is known
wx.CallAfter(self.follow_btn.Hide)
wx.CallAfter(self.unfollow_btn.Hide)
wx.CallAfter(self.mute_btn.Hide)
wx.CallAfter(self.unmute_btn.Hide)
wx.CallAfter(self.block_btn.Hide)
wx.CallAfter(self.unblock_btn.Hide)
try:
api = self.session._ensure_client()
try:
raw_profile = api.app.bsky.actor.get_profile({"actor": self.user_identifier})
except Exception:
raw_profile = None
wx.CallAfter(self._apply_profile_data, raw_profile)
except Exception as e:
logger.error(f"Error loading profile for {self.user_identifier}: {e}", exc_info=True)
wx.CallAfter(self._apply_profile_error, e)
def _apply_profile_data(self, raw_profile):
if raw_profile:
self.profile_data = self._format_profile_data(raw_profile)
self.target_user_did = self.profile_data.get("did")
self.user_identifier = self.target_user_did or self.user_identifier
self.update_ui_fields()
self.update_action_buttons_state()
self.SetTitle(_("Profile: {handle}").format(handle=self.profile_data.get("handle", "")))
self.SetStatusText(_("Profile loaded."))
else:
for ctrl in self.profile_field_ctrls.values():
ctrl.SetValue(_("Not found."))
self.SetStatusText(_("Profile not found for '{ident}'.").format(ident=self.user_identifier))
wx.MessageBox(_("User profile for '{ident}' not found.").format(ident=self.user_identifier), _("Error"), wx.OK | wx.ICON_ERROR, self)
self.Layout()
def _apply_profile_error(self, err):
for ctrl in self.profile_field_ctrls.values():
ctrl.SetValue(_("Error loading."))
self.SetStatusText(_("Error loading profile."))
wx.MessageBox(_("Error loading profile: {error}").format(error=str(err)), _("Error"), wx.OK | wx.ICON_ERROR, self)
self.Layout()
def update_ui_fields(self):
if not self.profile_data:
return
for key, ctrl in self.profile_field_ctrls.items():
value = self.profile_data.get(key)
if key == "description" and value:
ctrl.SetMinSize((-1, 60))
if isinstance(value, (int, float)):
ctrl.SetValue(str(value))
else:
ctrl.SetValue(value or _("N/A"))
# Update timeline buttons with counts
posts_count = self.profile_data.get("postsCount") or 0
followers_count = self.profile_data.get("followersCount") or 0
following_count = self.profile_data.get("followsCount") or 0
self.posts_btn.SetLabel(_("{count} pos&ts. Click to open posts timeline").format(count=posts_count))
self.followers_btn.SetLabel(_("{count} fo&llowers. Click to open followers timeline").format(count=followers_count))
self.following_btn.SetLabel(_("{count} &following. Click to open following timeline").format(count=following_count))
# Start image download in background thread
Thread(target=self._download_images, daemon=True).start()
self.Layout()
def _download_images(self):
"""Downloads avatar and banner images from Bluesky server."""
avatar_url = self.profile_data.get("avatar") if self.profile_data else None
banner_url = self.profile_data.get("banner") if self.profile_data else None
avatar_bytes = None
banner_bytes = None
try:
if banner_url:
resp = requests.get(banner_url, timeout=10)
if resp.status_code == 200:
banner_bytes = resp.content
except Exception as e:
logger.debug(f"Failed to download banner: {e}")
try:
if avatar_url:
resp = requests.get(avatar_url, timeout=10)
if resp.status_code == 200:
avatar_bytes = resp.content
except Exception as e:
logger.debug(f"Failed to download avatar: {e}")
wx.CallAfter(self._draw_images, banner_bytes, avatar_bytes)
def _draw_images(self, banner_bytes, avatar_bytes):
"""Draws downloaded images on the bitmap controls."""
try:
if banner_bytes:
banner_image = wx.Image(BytesIO(banner_bytes), wx.BITMAP_TYPE_ANY)
banner_image.Rescale(300, 100, wx.IMAGE_QUALITY_HIGH)
self.bannerImage.SetBitmap(banner_image.ConvertToBitmap())
if avatar_bytes:
avatar_image = wx.Image(BytesIO(avatar_bytes), wx.BITMAP_TYPE_ANY)
avatar_image.Rescale(150, 150, wx.IMAGE_QUALITY_HIGH)
self.avatarImage.SetBitmap(avatar_image.ConvertToBitmap())
self.Layout()
self.Fit()
except Exception as e:
logger.debug(f"Failed to draw images: {e}")
def onPosts(self, *args):
"""Open this user's posts timeline."""
if self.profile_data:
pub.sendMessage('execute-action', action='openPostTimeline', kwargs=dict(user=self.profile_data))
def onFollowing(self, *args):
"""Open following timeline for this user."""
if self.profile_data:
pub.sendMessage('execute-action', action='openFollowingTimeline', kwargs=dict(user=self.profile_data))
def onFollowers(self, *args):
"""Open followers timeline for this user."""
if self.profile_data:
pub.sendMessage('execute-action', action='openFollowersTimeline', kwargs=dict(user=self.profile_data))
def update_action_buttons_state(self):
if not self.profile_data or not self.target_user_did or self.target_user_did == self._get_own_did():
self.follow_btn.Hide()
self.unfollow_btn.Hide()
self.mute_btn.Hide()
self.unmute_btn.Hide()
self.block_btn.Hide()
self.unblock_btn.Hide()
self.Layout()
return
viewer_state = self.profile_data.get("viewer", {})
is_following = bool(viewer_state.get("following"))
is_muted = bool(viewer_state.get("muted"))
# 'blocking' in viewer state is the URI of *our* block record, if we are blocking them.
is_blocking_them = bool(viewer_state.get("blocking"))
# 'blockedBy' means *they* are blocking us. If true, most actions might fail or be hidden.
is_blocked_by_them = bool(viewer_state.get("blockedBy"))
if is_blocked_by_them: # If they block us, we can't do much.
self.follow_btn.Hide()
self.unfollow_btn.Hide()
self.mute_btn.Hide()
self.unmute_btn.Hide()
# We can still block them, or unblock them if we previously did.
self.block_btn.Show(not is_blocking_them)
self.unblock_btn.Show(is_blocking_them)
self.Layout()
return
self.follow_btn.Show(not is_following and not is_blocking_them)
self.unfollow_btn.Show(is_following and not is_blocking_them)
self.mute_btn.Show(not is_muted and not is_blocking_them)
self.unmute_btn.Show(is_muted and not is_blocking_them)
self.block_btn.Show(not is_blocking_them) # Show block if we are not currently blocking them (even if they block us)
self.unblock_btn.Show(is_blocking_them) # Show unblock if we are currently blocking them
self.Layout() # Refresh sizer to show/hide buttons correctly
def on_user_action(self, event, command: str):
if not self.target_user_did: # Should be set by load_profile_data
wx.MessageBox(_("User identifier (DID) not available for this action."), _("Error"), wx.OK | wx.ICON_ERROR)
return
# Confirmation for sensitive actions
confirmation_map = {
"unfollow_user": _("Are you sure you want to unfollow @{handle}?").format(handle=self.profile_data.get("handle","this user")),
"block_user": _("Are you sure you want to block @{handle}? This will prevent them from interacting with you and hide their content.").format(handle=self.profile_data.get("handle","this user")),
# Unblock usually doesn't need confirmation, but can be added if desired.
}
if command in confirmation_map:
dlg = wx.MessageDialog(self, confirmation_map[command], _("Confirm Action"), wx.YES_NO | wx.ICON_QUESTION)
if dlg.ShowModal() != wx.ID_YES:
dlg.Destroy()
return
dlg.Destroy()
wx.BeginBusyCursor()
self.SetStatusText(_("Performing action: {action}...").format(action=command))
action_button = event.GetEventObject()
if action_button:
action_button.Disable()
try:
ok = False
if command == "follow_user" and hasattr(self.session, "follow_user"):
ok = self.session.follow_user(self.target_user_did)
elif command == "unfollow_user" and hasattr(self.session, "unfollow_user"):
viewer_state = self.profile_data.get("viewer", {}) if self.profile_data else {}
follow_uri = viewer_state.get("following")
if follow_uri:
ok = self.session.unfollow_user(follow_uri)
else:
raise RuntimeError(_("Follow information not available."))
elif command == "mute_user" and hasattr(self.session, "mute_user"):
ok = self.session.mute_user(self.target_user_did)
elif command == "unmute_user" and hasattr(self.session, "unmute_user"):
ok = self.session.unmute_user(self.target_user_did)
elif command == "block_user" and hasattr(self.session, "block_user"):
ok = self.session.block_user(self.target_user_did)
elif command == "unblock_user" and hasattr(self.session, "unblock_user"):
viewer_state = self.profile_data.get("viewer", {}) if self.profile_data else {}
block_uri = viewer_state.get("blocking")
if not block_uri:
raise RuntimeError(_("Block information not available."))
ok = self.session.unblock_user(block_uri)
else:
raise RuntimeError(_("This action is not supported yet."))
if not ok:
raise RuntimeError(_("Action failed."))
wx.EndBusyCursor()
wx.MessageBox(_("Action completed."), _("Success"), wx.OK | wx.ICON_INFORMATION, self)
# Reload profile data in a new thread
Thread(target=self.load_profile_data, daemon=True).start()
except Exception as e:
wx.EndBusyCursor()
if action_button:
action_button.Enable()
self.SetStatusText(_("Action failed."))
wx.MessageBox(str(e), _("Error"), wx.OK | wx.ICON_ERROR, self)
def _get_own_did(self):
if isinstance(self.session.db, dict):
did = self.session.db.get("user_id")
if did:
return did
try:
api = self.session._ensure_client()
if getattr(api, "me", None):
return api.me.did
except Exception:
pass
return None
def _format_profile_data(self, profile_model):
def g(obj, key, default=None):
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
def get_count(*keys):
for k in keys:
val = g(profile_model, k)
if val is not None:
return val
return None
return {
"did": g(profile_model, "did"),
"handle": g(profile_model, "handle"),
"displayName": g(profile_model, "displayName") or g(profile_model, "display_name") or g(profile_model, "handle"),
"description": g(profile_model, "description"),
"avatar": g(profile_model, "avatar"),
"banner": g(profile_model, "banner"),
"followersCount": get_count("followersCount", "followers_count"),
"followsCount": get_count("followsCount", "follows_count", "followingCount", "following_count"),
"postsCount": get_count("postsCount", "posts_count"),
"viewer": g(profile_model, "viewer") or {},
}
def SetStatusText(self, text): # Simple status text for dialog title
self.SetTitle(f"{_('User Profile')} - {text}")

View File

@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
import wx
class UserActionsDialog(wx.Dialog):
def __init__(self, users=None, default="follow", *args, **kwargs):
super(UserActionsDialog, self).__init__(parent=None, *args, **kwargs)
users = users or []
panel = wx.Panel(self)
self.SetTitle(_(u"Action"))
userSizer = wx.BoxSizer()
userLabel = wx.StaticText(panel, -1, _(u"&User"))
default_user = users[0] if users else ""
self.cb = wx.ComboBox(panel, -1, choices=users, value=default_user)
self.cb.SetFocus()
self.autocompletion = wx.Button(panel, -1, _(u"&Autocomplete users"))
userSizer.Add(userLabel, 0, wx.ALL, 5)
userSizer.Add(self.cb, 0, wx.ALL, 5)
userSizer.Add(self.autocompletion, 0, wx.ALL, 5)
actionSizer = wx.BoxSizer(wx.VERTICAL)
label2 = wx.StaticText(panel, -1, _(u"Action"))
self.follow = wx.RadioButton(panel, -1, _(u"&Follow"), name=_(u"Action"), style=wx.RB_GROUP)
self.unfollow = wx.RadioButton(panel, -1, _(u"U&nfollow"))
self.mute = wx.RadioButton(panel, -1, _(u"&Mute"))
self.unmute = wx.RadioButton(panel, -1, _(u"Unmu&te"))
self.block = wx.RadioButton(panel, -1, _(u"&Block"))
self.unblock = wx.RadioButton(panel, -1, _(u"Unbl&ock"))
self.setup_default(default)
hSizer = wx.BoxSizer(wx.HORIZONTAL)
hSizer.Add(label2, 0, wx.ALL, 5)
actionSizer.Add(self.follow, 0, wx.ALL, 5)
actionSizer.Add(self.unfollow, 0, wx.ALL, 5)
actionSizer.Add(self.mute, 0, wx.ALL, 5)
actionSizer.Add(self.unmute, 0, wx.ALL, 5)
actionSizer.Add(self.block, 0, wx.ALL, 5)
actionSizer.Add(self.unblock, 0, wx.ALL, 5)
hSizer.Add(actionSizer, 0, wx.ALL, 5)
sizer = wx.BoxSizer(wx.VERTICAL)
ok = wx.Button(panel, wx.ID_OK, _(u"&OK"))
ok.SetDefault()
cancel = wx.Button(panel, wx.ID_CANCEL, _(u"&Close"))
btnsizer = wx.BoxSizer()
btnsizer.Add(ok)
btnsizer.Add(cancel)
sizer.Add(userSizer)
sizer.Add(hSizer, 0, wx.ALL, 5)
sizer.Add(btnsizer)
panel.SetSizer(sizer)
def get_action(self):
if self.follow.GetValue() == True:
return "follow"
elif self.unfollow.GetValue() == True:
return "unfollow"
elif self.mute.GetValue() == True:
return "mute"
elif self.unmute.GetValue() == True:
return "unmute"
elif self.block.GetValue() == True:
return "block"
elif self.unblock.GetValue() == True:
return "unblock"
def setup_default(self, default):
if default == "follow":
self.follow.SetValue(True)
elif default == "unfollow":
self.unfollow.SetValue(True)
elif default == "mute":
self.mute.SetValue(True)
elif default == "unmute":
self.unmute.SetValue(True)
elif default == "block":
self.block.SetValue(True)
elif default == "unblock":
self.unblock.SetValue(True)
def get_response(self):
return self.ShowModal()
def get_user(self):
return self.cb.GetValue()
def get_position(self):
return self.cb.GetPosition()
def popup_menu(self, menu):
self.PopupMenu(menu, self.cb.GetPosition())

View File

@@ -0,0 +1,394 @@
# -*- coding: utf-8 -*-
import wx
import logging
from pubsub import pub
from multiplatform_widgets import widgets # Assuming this provides generic widgets
from approve.translation import translate as _ # For Approve's _ shortcut
from approve.notifications import NotificationError
logger = logging.getLogger(__name__)
# Supported languages for posts (ISO 639-1 codes) - can be expanded
# This might ideally come from the session or a global config
SUPPORTED_LANG_CHOICES = {
_("English"): "en",
_("Spanish"): "es",
_("French"): "fr",
_("German"): "de",
_("Japanese"): "ja",
_("Portuguese"): "pt",
_("Russian"): "ru",
_("Chinese"): "zh",
# Add more as needed
}
class ComposeDialog(wx.Dialog):
def __init__(self, parent, session, reply_to_uri: str | None = None, quote_uri: str | None = None, initial_text: str = ""):
super(ComposeDialog, self).__init__(parent, title=_("Compose Post"), style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
self.session = session
self.panel_config = self.session.compose_panel.get_panel_configuration()
self.reply_to_uri = reply_to_uri
self.initial_quote_uri = quote_uri # Store initial quote URI
self.current_quote_uri = quote_uri # Mutable quote URI
self.attached_files_info = [] # List of dicts: {"path": str, "alt_text": str}
self._init_ui(initial_text)
self.SetMinSize((550, 450)) # Increased min size
self.CentreOnParent()
def _init_ui(self, initial_text: str):
panel = wx.Panel(self)
main_sizer = wx.BoxSizer(wx.VERTICAL)
# Reply Info (if applicable)
if self.reply_to_uri:
# In a real app, fetch & show post snippet or author
reply_info_label = wx.StaticText(panel, label=_("Replying to: {uri_placeholder}").format(uri_placeholder=self.reply_to_uri[-10:]))
reply_info_label.SetToolTip(self.reply_to_uri)
main_sizer.Add(reply_info_label, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 5)
# Text Area
self.text_ctrl = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_RICH2 | wx.HSCROLL)
self.text_ctrl.SetValue(initial_text)
self.text_ctrl.Bind(wx.EVT_TEXT, self.on_text_changed)
main_sizer.Add(self.text_ctrl, 1, wx.EXPAND | wx.ALL, 5)
# Character Counter
self.max_chars = self.panel_config.get("max_chars", 0)
self.char_count_label = wx.StaticText(panel, label=f"0 / {self.max_chars if self.max_chars > 0 else 'N/A'}")
main_sizer.Add(self.char_count_label, 0, wx.ALIGN_RIGHT | wx.RIGHT | wx.BOTTOM, 5)
self.on_text_changed(None)
# Attachments Area
self.max_media_attachments = self.panel_config.get("max_media_attachments", 0)
if self.max_media_attachments > 0:
attachment_sizer = wx.StaticBoxSizer(wx.VERTICAL, panel, _("Media Attachments") + f" (Max: {self.max_media_attachments})")
self.attachment_list = wx.ListBox(attachment_sizer.GetStaticBox(), style=wx.LB_SINGLE, size=(-1, 60)) # Fixed height for listbox
attachment_sizer.Add(self.attachment_list, 1, wx.EXPAND | wx.ALL, 5)
attach_btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.add_attachment_btn = wx.Button(attachment_sizer.GetStaticBox(), label=_("Add Media..."))
self.add_attachment_btn.Bind(wx.EVT_BUTTON, self.on_add_attachment)
attach_btn_sizer.Add(self.add_attachment_btn, 0, wx.ALL, 2)
self.remove_attachment_btn = wx.Button(attachment_sizer.GetStaticBox(), label=_("Remove Selected"))
self.remove_attachment_btn.Bind(wx.EVT_BUTTON, self.on_remove_attachment)
self.remove_attachment_btn.Enable(False)
self.attachment_list.Bind(wx.EVT_LISTBOX, lambda evt: self.remove_attachment_btn.Enable(self.attachment_list.GetSelection() != wx.NOT_FOUND))
attach_btn_sizer.Add(self.remove_attachment_btn, 0, wx.ALL, 2)
attachment_sizer.Add(attach_btn_sizer, 0, wx.ALIGN_LEFT)
main_sizer.Add(attachment_sizer, 0, wx.EXPAND | wx.ALL, 5)
# Quoting Area
if self.panel_config.get("supports_quoting", False):
quote_box_sizer = wx.StaticBoxSizer(wx.VERTICAL, panel, _("Quoting Post"))
quote_display_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.quote_uri_text_display = wx.TextCtrl(quote_box_sizer.GetStaticBox(), value=self.current_quote_uri or _("None"), style=wx.TE_READONLY | wx.BORDER_NONE)
self.quote_uri_text_display.SetBackgroundColour(panel.GetBackgroundColour())
quote_display_sizer.Add(wx.StaticText(quote_box_sizer.GetStaticBox(), label=_("Quoting URI: ")), 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
quote_display_sizer.Add(self.quote_uri_text_display, 1, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
quote_box_sizer.Add(quote_display_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT, 2)
quote_btn_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.add_quote_btn = wx.Button(quote_box_sizer.GetStaticBox(), label=_("Set/Change Quote..."))
self.add_quote_btn.Bind(wx.EVT_BUTTON, self.on_add_quote)
quote_btn_sizer.Add(self.add_quote_btn, 0, wx.ALL, 2)
self.remove_quote_btn = wx.Button(quote_box_sizer.GetStaticBox(), label=_("Remove Quote"))
self.remove_quote_btn.Bind(wx.EVT_BUTTON, self.on_remove_quote)
self.remove_quote_btn.Enable(bool(self.current_quote_uri))
quote_btn_sizer.Add(self.remove_quote_btn, 0, wx.ALL, 2)
quote_box_sizer.Add(quote_btn_sizer, 0, wx.ALIGN_LEFT)
main_sizer.Add(quote_box_sizer, 0, wx.EXPAND | wx.ALL, 5)
# Options (Content Warning, Language)
options_box = wx.StaticBoxSizer(wx.VERTICAL, panel, _("Options"))
options_grid_sizer = wx.FlexGridSizer(cols=2, vgap=5, hgap=5)
options_grid_sizer.AddGrowableCol(1, 1)
if self.panel_config.get("supports_content_warning", False):
self.sensitive_checkbox = wx.CheckBox(options_box.GetStaticBox(), label=_("Sensitive content (CW)"))
self.sensitive_checkbox.Bind(wx.EVT_CHECKBOX, self.on_sensitive_changed)
options_grid_sizer.Add(self.sensitive_checkbox, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
self.spoiler_text_ctrl = wx.TextCtrl(options_box.GetStaticBox())
self.spoiler_text_ctrl.SetHint(_("Content warning text (optional)"))
self.spoiler_text_ctrl.Enable(False)
options_grid_sizer.Add(self.spoiler_text_ctrl, 1, wx.EXPAND | wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
if self.panel_config.get("supports_language_selection", False):
lang_label = wx.StaticText(options_box.GetStaticBox(), label=_("Languages:"))
options_grid_sizer.Add(lang_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
self.max_langs = self.panel_config.get("max_languages", 1)
self.lang_choices_map = SUPPORTED_LANG_CHOICES # Using global for now
lang_display_names = list(self.lang_choices_map.keys())
if self.max_langs == 1: # Single choice
choices = [_("Automatic")] + lang_display_names
self.lang_choice_ctrl = wx.Choice(options_box.GetStaticBox(), choices=choices)
self.lang_choice_ctrl.SetSelection(0) # Default to Automatic/None
else: # Multiple choices
self.lang_choice_ctrl = wx.CheckListBox(options_box.GetStaticBox(), choices=lang_display_names, size=(-1, 70))
self.lang_choice_ctrl.Bind(wx.EVT_CHECKLISTBOX, self.on_lang_checklist_changed)
options_grid_sizer.Add(self.lang_choice_ctrl, 1, wx.EXPAND | wx.ALIGN_CENTER_VERTICAL | wx.ALL, 2)
if options_grid_sizer.GetChildren():
options_box.Add(options_grid_sizer, 1, wx.EXPAND | wx.ALL, 0) # No border for grid sizer itself
main_sizer.Add(options_box, 0, wx.EXPAND | wx.ALL, 5)
# Buttons (Send, Cancel)
btn_sizer = wx.StdDialogButtonSizer()
self.send_btn = wx.Button(panel, wx.ID_OK, _("Send"))
self.send_btn.SetDefault()
self.send_btn.Bind(wx.EVT_BUTTON, self.on_send)
btn_sizer.AddButton(self.send_btn)
cancel_btn = wx.Button(panel, wx.ID_CANCEL, _("Cancel"))
btn_sizer.AddButton(cancel_btn)
btn_sizer.Realize()
main_sizer.Add(btn_sizer, 0, wx.ALIGN_CENTER | wx.ALL, 5)
panel.SetSizer(main_sizer)
self.Fit()
def on_text_changed(self, event):
text_length = len(self.text_ctrl.GetValue())
self.char_count_label.SetLabel(f"{text_length} / {self.max_chars}")
if self.max_chars > 0 and text_length > self.max_chars:
self.char_count_label.SetForegroundColour(wx.RED)
else:
self.char_count_label.SetForegroundColour(wx.BLACK) # System default
def on_add_attachment(self, event):
max_attachments = self.panel_config.get("max_media_attachments", 0)
if len(self.attached_files_info) >= self.max_media_attachments:
wx.MessageBox(_("Maximum number of attachments ({max}) reached.").format(max=self.max_media_attachments), _("Attachment Limit"), wx.OK | wx.ICON_INFORMATION)
return
supported_mimes = self.panel_config.get("supported_media_types", [])
wildcard_parts = []
if not supported_mimes: # Default if none specified by session
wildcard_parts.append("All files (*.*)|*.*")
else:
for mime_type in supported_mimes:
# Example: "image/jpeg" -> "JPEG files (*.jpg;*.jpeg)|*.jpg;*.jpeg"
name = mime_type.split('/')[0].capitalize() + " " + mime_type.split('/')[1].upper()
if mime_type == "image/jpeg": exts = "*.jpg;*.jpeg"
elif mime_type == "image/png": exts = "*.png"
elif mime_type == "image/gif": exts = "*.gif" # If supported
else: exts = "*." + mime_type.split('/')[-1]
wildcard_parts.append(f"{name} ({exts})|{exts}")
wildcard = "|".join(wildcard_parts) if wildcard_parts else wx.FileSelectorDefaultWildcardStr
dialog = wx.FileDialog(self, _("Select Media File"), wildcard=wildcard, style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST)
if dialog.ShowModal() == wx.ID_OK:
path = dialog.GetPath()
alt_text = ""
if self.panel_config.get("supports_alternative_text", False) and \
any(pt in path.lower() for pt in ['.jpg', '.jpeg', '.png']): # crude check for image
alt_text_dialog = wx.TextEntryDialog(self, _("Enter accessibility description (alt text) for the image:"), _("Image Description"))
if alt_text_dialog.ShowModal() == wx.ID_OK:
alt_text = alt_text_dialog.GetValue()
alt_text_dialog.Destroy()
self.attached_files_info.append({"path": path, "alt_text": alt_text})
self.attachment_list.Append(os.path.basename(path) + (f" ({_('Alt:')} {alt_text})" if alt_text else ""))
dialog.Destroy()
def on_remove_attachment(self, event):
selected_index = self.attachment_list.GetSelection()
if selected_index != wx.NOT_FOUND:
self.attachment_list.Delete(selected_index)
del self.attached_files_info[selected_index]
def on_add_quote(self, event):
dialog = wx.TextEntryDialog(self, _("Enter the AT-URI of the Bluesky post to quote:"), _("Quote Post"), self.current_quote_uri or "")
if dialog.ShowModal() == wx.ID_OK:
self.current_quote_uri = dialog.GetValue().strip()
self.quote_uri_text_display.SetValue(self.current_quote_uri or _("None"))
self.remove_quote_btn.Enable(bool(self.current_quote_uri))
dialog.Destroy()
def on_remove_quote(self, event):
self.current_quote_uri = None
self.quote_uri_text_display.SetValue(_("None"))
self.remove_quote_btn.Enable(False)
def on_sensitive_changed(self, event):
if hasattr(self, 'spoiler_text_ctrl'):
self.spoiler_text_ctrl.Enable(event.IsChecked())
if event.IsChecked():
self.spoiler_text_ctrl.SetFocus()
def on_lang_checklist_changed(self, event):
"""Ensure no more than max_languages are selected for CheckListBox."""
if isinstance(self.lang_choice_ctrl, wx.CheckListBox):
checked_indices = self.lang_choice_ctrl.GetCheckedItems()
if len(checked_indices) > self.max_langs:
# Find the item that was just checked to cause the overflow
# This is a bit tricky as EVT_CHECKLISTBOX triggers after the change.
# A simpler approach is to inform the user and let them uncheck.
wx.MessageBox(
_("You can select a maximum of {num} languages.").format(num=self.max_langs),
_("Language Selection Limit"), wx.OK | wx.ICON_EXCLAMATION
)
# Optionally, uncheck the last checked item if possible to determine
# For now, just warn. User has to manually correct.
def on_send(self, event): # Renamed from async on_send
text_content = self.text_ctrl.GetValue()
if not text_content.strip() and not self.attached_files_info and not self.current_quote_uri:
wx.MessageBox(_("Cannot send an empty post."), _("Error"), wx.OK | wx.ICON_ERROR)
return
# Language processing
langs = []
if hasattr(self, 'lang_choice_ctrl'):
if isinstance(self.lang_choice_ctrl, wx.Choice):
sel_idx = self.lang_choice_ctrl.GetSelection()
if sel_idx > 0: # Index 0 is empty/no selection
lang_display_name = self.lang_choice_ctrl.GetString(sel_idx)
langs.append(self.lang_choices_map[lang_display_name])
elif isinstance(self.lang_choice_ctrl, wx.CheckListBox):
checked_indices = self.lang_choice_ctrl.GetCheckedItems()
if len(checked_indices) > self.max_langs:
wx.MessageBox(_("Please select no more than {num} languages.").format(num=self.max_langs), _("Language Error"), wx.OK | wx.ICON_ERROR)
return
for idx in checked_indices:
lang_display_name = self.lang_choice_ctrl.GetString(idx)
langs.append(self.lang_choices_map[lang_display_name])
# Files and Alt Texts
files_to_send = [f_info["path"] for f_info in self.attached_files_info]
alt_texts_to_send = [f_info["alt_text"] for f_info in self.attached_files_info]
# Content Warning
cw_text = None
is_sensitive_flag = False
if hasattr(self, 'sensitive_checkbox') and self.sensitive_checkbox.IsChecked():
is_sensitive_flag = True
if hasattr(self, 'spoiler_text_ctrl'):
cw_text = self.spoiler_text_ctrl.GetValue().strip() or None # Use None if empty for Bluesky
kwargs_for_send = {
"quote_uri": self.current_quote_uri,
"langs": langs if langs else None,
"media_alt_texts": alt_texts_to_send if alt_texts_to_send else None,
# "tags" could be extracted from text server-side or client-side (not implemented here)
}
# Filter out None values from kwargs to avoid sending them if not set
kwargs_for_send = {k: v for k, v in kwargs_for_send.items() if v is not None}
try:
self.send_btn.Disable()
# This is an async call, so it should be handled appropriately in wxPython
# For simplicity in this step, assuming it's handled by the caller or a wrapper
# In a real wxPython app, this would involve asyncio.create_task and wx.CallAfter
# or running the send in a separate thread and using wx.CallAfter for UI updates.
# For now, we'll make this method async and let the caller handle it.
# wx.BeginBusyCursor() # Indicate work
# Using pubsub to decouple UI from direct async call to session
pub.sendMessage(
"compose_dialog.send_post",
session=self.session,
text=text_content,
files=files_to_send if files_to_send else None,
reply_to=self.reply_to_uri,
cw_text=cw_text,
is_sensitive=is_sensitive_flag,
kwargs=kwargs_for_send
)
# Success will be signaled by another pubsub message if needed, or just close.
# self.EndModal(wx.ID_OK) # Moved to controller after successful send via pubsub
except NotificationError as e:
wx.MessageBox(str(e), _("Post Error"), wx.OK | wx.ICON_ERROR)
except Exception as e:
logger.error("Error sending post from compose dialog: %s", e, exc_info=True)
wx.MessageBox(_("An unexpected error occurred: {error}").format(error=str(e)), _("Error"), wx.OK | wx.ICON_ERROR)
finally:
# wx.EndBusyCursor()
if not self.IsBeingDeleted(): # Ensure dialog still exists
self.send_btn.Enable()
# Do not automatically close here; let the controller do it on success signal.
# self.EndModal(wx.ID_OK) # if successful and no further UI feedback needed in dialog
def get_data(self):
"""Helper to get all data, though on_send handles it directly."""
# This method isn't strictly necessary if on_send does all the work,
# but can be useful for other patterns.
pass
if __name__ == '__main__':
# Example usage (requires a mock session and panel_config)
app = wx.App(False)
class MockComposePanel:
def get_panel_configuration(self):
return {
"max_chars": 300,
"max_media_attachments": 4,
"supported_media_types": ["image/jpeg", "image/png"],
"supports_alternative_text": True,
"supports_content_warning": True,
"supports_language_selection": True,
"max_languages": 3,
"supports_quoting": True,
}
class MockSession:
def __init__(self):
self.compose_panel = MockComposePanel()
self.uid = "mock_user" # Needed by some base methods if called
async def send_message(self, message, files=None, reply_to=None, cw_text=None, is_sensitive=False, **kwargs):
print("MockSession.send_message called:")
print(f" Text: {message}")
print(f" Files: {files}")
print(f" Reply To: {reply_to}")
print(f" CW: {cw_text}, Sensitive: {is_sensitive}")
print(f" kwargs: {kwargs}")
# Simulate success or failure
# raise NotificationError("This is a mock send error!")
return "at://did:plc:mockposturi/app.bsky.feed.post/mockrkey"
# Pubsub listener for the send_post event (simulates what mainController would do)
def on_actual_send(session, text, files, reply_to, cw_text, is_sensitive, kwargs):
print("Pubsub: compose_dialog.send_post received. Calling session.send_message...")
async def do_send():
try:
uri = await session.send_message(
message=text,
files=files,
reply_to=reply_to,
cw_text=cw_text,
is_sensitive=is_sensitive,
**kwargs
)
print(f"Pubsub: Send successful, URI: {uri}")
# In real app, would call dialog.EndModal(wx.ID_OK) via wx.CallAfter
wx.CallAfter(dialog.EndModal, wx.ID_OK)
except Exception as e:
print(f"Pubsub: Send failed: {e}")
# In real app, show error and re-enable send button in dialog via wx.CallAfter
wx.CallAfter(wx.MessageBox, str(e), "Error", wx.OK | wx.ICON_ERROR, dialog)
wx.CallAfter(dialog.send_btn.Enable, True)
asyncio.create_task(do_send())
pub.subscribe(on_actual_send, "compose_dialog.send_post")
session = MockSession()
# Example: dialog = ComposeDialog(None, session, reply_to_uri="at://reply_uri", quote_uri="at://quote_uri", initial_text="Hello")
dialog = ComposeDialog(None, session, initial_text="Hello Bluesky!")
dialog.ShowModal()
dialog.Destroy()
app.MainLoop()

View File

@@ -26,8 +26,15 @@ class UserListDialog(wx.Dialog):
buttons_sizer.Add(self.actions_button, 0, wx.RIGHT, 10)
self.details_button = wx.Button(panel, wx.ID_ANY, _("&View profile"))
buttons_sizer.Add(self.details_button, 0, wx.RIGHT, 10)
self.load_more_button = wx.Button(panel, wx.ID_ANY, _("&Load more"))
self.load_more_button.Hide()
buttons_sizer.Add(self.load_more_button, 0, wx.RIGHT, 10)
close_button = wx.Button(panel, wx.ID_CANCEL, "&Close")
buttons_sizer.Add(close_button, 0)
main_sizer.Add(buttons_sizer, 0, wx.ALIGN_CENTER | wx.BOTTOM, 15)
panel.SetSizer(main_sizer)
# self.SetSizerAndFit(main_sizer)
def add_users(self, users):
for user in users:
self.user_list.Append(user)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff