diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..04ca4806 --- /dev/null +++ b/.claude/settings.local.json @@ -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:*)" + ] + } +} diff --git a/README.md b/README.md index 6e2c22b9..c65bdc2e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/doc/changelog.md b/doc/changelog.md index 92745afa..0ef8b027 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -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: diff --git a/documentation/source/basic_concepts.rst b/documentation/source/basic_concepts.rst index 5ed83eee..c51f50cf 100644 --- a/documentation/source/basic_concepts.rst +++ b/documentation/source/basic_concepts.rst @@ -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. \ No newline at end of file +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). \ No newline at end of file diff --git a/documentation/source/blueski.rst b/documentation/source/blueski.rst new file mode 100644 index 00000000..e30790dc --- /dev/null +++ b/documentation/source/blueski.rst @@ -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. diff --git a/documentation/source/index.rst b/documentation/source/index.rst index 531658f4..16103f8d 100644 --- a/documentation/source/index.rst +++ b/documentation/source/index.rst @@ -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 diff --git a/requirements.txt b/requirements.txt index 8e40cce3..cc8b378c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +zipp==3.23.0 +atproto>=0.0.65 diff --git a/scripts/bootstrap-dev.ps1 b/scripts/bootstrap-dev.ps1 new file mode 100644 index 00000000..6f930224 --- /dev/null +++ b/scripts/bootstrap-dev.ps1 @@ -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" diff --git a/scripts/run-tests.ps1 b/scripts/run-tests.ps1 new file mode 100644 index 00000000..5f824be4 --- /dev/null +++ b/scripts/run-tests.ps1 @@ -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 diff --git a/src/blueski.defaults b/src/blueski.defaults new file mode 100644 index 00000000..e90b775b --- /dev/null +++ b/src/blueski.defaults @@ -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] + diff --git a/src/controller/blueski/__init__.py b/src/controller/blueski/__init__.py new file mode 100644 index 00000000..f98ba158 --- /dev/null +++ b/src/controller/blueski/__init__.py @@ -0,0 +1,3 @@ +from .handler import Handler + +__all__ = ["Handler"] diff --git a/src/controller/blueski/handler.py b/src/controller/blueski/handler.py new file mode 100644 index 00000000..342f2b8e --- /dev/null +++ b/src/controller/blueski/handler.py @@ -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) diff --git a/src/controller/blueski/messages.py b/src/controller/blueski/messages.py new file mode 100644 index 00000000..a0103cb2 --- /dev/null +++ b/src/controller/blueski/messages.py @@ -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 diff --git a/src/controller/blueski/settings.py b/src/controller/blueski/settings.py new file mode 100644 index 00000000..3bd00474 --- /dev/null +++ b/src/controller/blueski/settings.py @@ -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).") diff --git a/src/controller/blueski/templateEditor.py b/src/controller/blueski/templateEditor.py new file mode 100644 index 00000000..1cc7d395 --- /dev/null +++ b/src/controller/blueski/templateEditor.py @@ -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 "" diff --git a/src/controller/blueski/userActions.py b/src/controller/blueski/userActions.py new file mode 100644 index 00000000..34417864 --- /dev/null +++ b/src/controller/blueski/userActions.py @@ -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) diff --git a/src/controller/blueski/userList.py b/src/controller/blueski/userList.py new file mode 100644 index 00000000..a64f6717 --- /dev/null +++ b/src/controller/blueski/userList.py @@ -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() diff --git a/src/controller/buffers/base/account.py b/src/controller/buffers/base/account.py index 46ecbb6d..9cff3332 100644 --- a/src/controller/buffers/base/account.py +++ b/src/controller/buffers/base/account.py @@ -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() \ No newline at end of file + config.app.write() diff --git a/src/controller/buffers/blueski/__init__.py b/src/controller/buffers/blueski/__init__.py new file mode 100644 index 00000000..6cedbfda --- /dev/null +++ b/src/controller/buffers/blueski/__init__.py @@ -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 diff --git a/src/controller/buffers/blueski/base.py b/src/controller/buffers/blueski/base.py new file mode 100644 index 00000000..24173bae --- /dev/null +++ b/src/controller/buffers/blueski/base.py @@ -0,0 +1,1229 @@ +# -*- coding: utf-8 -*- +import logging +import wx +import arrow +import output +import sound +import config +import widgetUtils +import languageHandler +from pubsub import pub +from controller.buffers.base import base +from controller.blueski import messages as blueski_messages +from sessions.blueski import compose, utils, templates +from mysc.thread_utils import call_threaded +from wxUI.buffers.blueski import panels as BlueskiPanels +from wxUI import commonMessageDialogs +from wxUI.dialogs.blueski import menus + +log = logging.getLogger("controller.buffers.blueski.base") + +class BaseBuffer(base.Buffer): + def __init__(self, parent=None, name=None, session=None, *args, **kwargs): + # Adapt params to BaseBuffer + # BaseBuffer expects (parent, function, name, sessionObject, account) + function = "timeline" # Dummy + sessionObject = session + account = session.get_name() if session else "Unknown" + + super(BaseBuffer, self).__init__(parent, function, name=name, sessionObject=sessionObject, account=account, *args, **kwargs) + + self.session = sessionObject + self.account = account + self.name = name + self.create_buffer(parent, name) + self.buffer.account = account + self.invisible = True + compose_func = kwargs.get("compose_func", "compose_post") + self.compose_function = getattr(compose, compose_func) + self.sound = kwargs.get("sound", None) + + # Initialize DB list if needed + if self.name not in self.session.db: + self.session.db[self.name] = [] + + self.bind_events() + + def get_max_items(self): + """Get max items per call from settings.""" + return self.session.settings["general"]["max_posts_per_call"] + + def create_buffer(self, parent, name): + # Default to HomePanel, can be overridden + self.buffer = BlueskiPanels.HomePanel(parent, name, account=self.account) + self.buffer.session = self.session + + def bind_events(self): + # Bind essential events + log.debug("Binding events for buffer %s" % self.name) + 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"): + self.buffer.post.Bind(wx.EVT_BUTTON, self.on_post) + if hasattr(self.buffer, "reply"): + self.buffer.reply.Bind(wx.EVT_BUTTON, self.on_reply) + if hasattr(self.buffer, "repost"): + self.buffer.repost.Bind(wx.EVT_BUTTON, self.on_repost) + if hasattr(self.buffer, "like"): + self.buffer.like.Bind(wx.EVT_BUTTON, self.on_like) + if hasattr(self.buffer, "dm"): + self.buffer.dm.Bind(wx.EVT_BUTTON, self.on_dm) + if hasattr(self.buffer, "actions"): + self.buffer.actions.Bind(wx.EVT_BUTTON, self.user_actions) + + def get_buffer_name(self): + """Get human-readable buffer name.""" + basic_buffers = dict( + home_timeline=_("Home"), + notifications=_("Notifications"), + mentions=_("Mentions"), + sent=_("Sent"), + likes=_("Likes"), + chats=_("Chats"), + ) + if self.name in basic_buffers: + return basic_buffers[self.name] + if hasattr(self, "username"): + if "timeline" in self.name.lower(): + return _("{username}'s timeline").format(username=self.username) + if "followers" in self.name.lower(): + return _("{username}'s followers").format(username=self.username) + if "following" in self.name.lower(): + return _("{username}'s following").format(username=self.username) + return self.name + + def onFocus(self, *args, **kwargs): + """Handle focus event for accessibility features.""" + post = self.get_item() + if not post: + return + + # Update relative time display + if self.session.settings["general"].get("relative_times", False): + try: + index = self.buffer.list.get_selected() + if index < 0: + return + # Only update if the list has at least 3 columns (Author, Text, Date) + if self.buffer.list.list.GetColumnCount() < 3: + return + + def g(obj, key, default=None): + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + actual_post = g(post, "post", post) + indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "") + if indexed_at: + original_date = arrow.get(indexed_at) + ts = original_date.humanize(locale=languageHandler.curLang[:2]) + self.buffer.list.list.SetItem(index, 2, ts) + except Exception as e: + log.error("Error updating relative time on focus: %s", e) + + # Read long posts in GUI + if config.app["app-settings"].get("read_long_posts_in_gui", False) and self.buffer.list.list.HasFocus(): + wx.CallLater(40, output.speak, self.get_message(), interrupt=True) + + # Audio/video indicator sound + if self.session.settings["sound"].get("indicate_audio", False) and utils.is_audio_or_video(post): + self.session.sound.play("audio.ogg") + + # Image indicator sound + if self.session.settings["sound"].get("indicate_img", False) and utils.is_image(post): + self.session.sound.play("image.ogg") + + def auto_read(self, number_of_items): + """Automatically read new items for accessibility.""" + if number_of_items == 0: + return + if self.name in self.session.settings["other_buffers"].get("muted_buffers", []): + return + if self.session.settings["sound"].get("session_mute", False): + return + if self.name not in self.session.settings["other_buffers"].get("autoread_buffers", []): + return + + safe = True + relative_times = self.session.settings["general"].get("relative_times", False) + show_screen_names = self.session.settings["general"].get("show_screen_names", False) + + if number_of_items == 1: + if self.session.settings["general"].get("reverse_timelines", False): + post = self.session.db[self.name][0] + else: + post = self.session.db[self.name][-1] + output.speak(_("New post in {0}").format(self.get_buffer_name())) + output.speak(" ".join(self.compose_function(post, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe))) + elif number_of_items > 1: + output.speak(_("{0} new posts in {1}.").format(number_of_items, self.get_buffer_name())) + + def show_menu(self, ev, pos=0, *args, **kwargs): + """Show context menu for current item.""" + if self.buffer.list.get_count() == 0: + return + menu = menus.baseMenu() + widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.repost) + if hasattr(menu, "quote"): + widgetUtils.connect_event(menu, widgetUtils.MENU, self.quote, menuitem=menu.quote) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.add_to_favorites, menuitem=menu.like) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.url_, menuitem=menu.openUrl) + if hasattr(menu, "openInBrowser"): + widgetUtils.connect_event(menu, widgetUtils.MENU, self.open_in_browser, menuitem=menu.openInBrowser) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.view, menuitem=menu.view) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.copy, menuitem=menu.copy) + widgetUtils.connect_event(menu, widgetUtils.MENU, self.destroy_status, menuitem=menu.remove) + if pos != 0: + self.buffer.PopupMenu(menu, pos) + else: + self.buffer.PopupMenu(menu, self.buffer.list.list.GetPosition()) + + def show_menu_by_key(self, ev): + """Show context menu when pressing menu key.""" + if self.buffer.list.get_count() == 0: + return + if ev.GetKeyCode() == wx.WXK_WINDOWS_MENU: + self.show_menu(widgetUtils.MENU, pos=self.buffer.list.list.GetPosition()) + + def copy(self, *args, **kwargs): + """Copy post to clipboard.""" + pub.sendMessage("execute-action", action="copy_to_clipboard") + + def on_post(self, evt): + dlg = blueski_messages.post(session=self.session, title=_("New Post"), caption=_("New Post")) + if dlg.message.ShowModal() == wx.ID_OK: + text, files, cw, langs = dlg.get_data() + self._send_post_async( + text=text, + files=files, + cw_text=cw, + langs=langs, + success_message=_("Sent."), + error_message=_("An error occurred while posting."), + sound="tweet_send.ogg", + refresh_args=(False, False), + ) + dlg.message.Destroy() + + def on_reply(self, evt): + item = self.get_item() + if not item: return + + # item is a feed object or dict. + # We need its URI. + uri = self.get_selected_item_id() + if not uri: + uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None) + reply_cid = self.get_selected_item_cid() + # Attempt to get CID if present for consistency, though send_message handles it + + def g(obj, key, default=None): + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + author = g(item, "author") + if not author: + post = g(item, "post") or g(item, "record") + author = g(post, "author") if post else None + handle = g(author, "handle", "") + initial_text = f"@{handle} " if handle and not handle.startswith("@") else (f"{handle} " if handle else "") + + dlg = blueski_messages.post(session=self.session, title=_("Reply"), caption=_("Reply"), text=initial_text) + if dlg.message.ShowModal() == wx.ID_OK: + text, files, cw, langs = dlg.get_data() + refresh_args = (True, False) if getattr(self, "type", "") == "conversation" else None + self._send_post_async( + text=text, + files=files, + cw_text=cw, + langs=langs, + reply_to=uri, + reply_to_cid=reply_cid, + success_message=_("Reply sent."), + error_message=_("An error occurred while replying."), + sound="reply_send.ogg", + refresh_args=refresh_args, + ) + dlg.message.Destroy() + + def _send_post_async( + self, + *, + text, + files, + cw_text, + langs, + reply_to=None, + reply_to_cid=None, + success_message="", + error_message="", + sound=None, + refresh_args=None, + ): + if not text and not files: + return + + def do_send(): + try: + uri_resp = self.session.send_message( + message=text, + files=files, + reply_to=reply_to, + reply_to_cid=reply_to_cid, + cw_text=cw_text, + langs=langs, + ) + if uri_resp: + if sound: + wx.CallAfter(self.session.sound.play, sound) + if success_message: + wx.CallAfter(output.speak, success_message) + if refresh_args and hasattr(self, "start_stream"): + try: + wx.CallAfter(self.start_stream, *refresh_args) + except Exception: + pass + else: + wx.CallAfter(output.speak, _("Failed to send post."), True) + except Exception: + log.exception("Error sending Bluesky post") + if error_message: + wx.CallAfter(output.speak, error_message, True) + else: + wx.CallAfter(output.speak, _("An error occurred while posting."), True) + + call_threaded(do_send) + + def on_repost(self, evt): + self.share_item() + + def share_item(self, event=None, item=None, *args, **kwargs): + if item is None: + item = self.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) + + # Get the URI for reposting + uri = g(item, "uri") or g(g(item, "post"), "uri") + cid = g(item, "cid") or g(g(item, "post"), "cid") + if not uri: + output.speak(_("Could not find post to repost."), True) + return + + # Check boost_mode setting + boost_mode = self.session.settings["general"].get("boost_mode", "ask") + if boost_mode == "ask": + from wxUI.dialogs.blueski.postDialogs import repost_question + answer = repost_question() + if answer == 1: + self._direct_repost(uri) + elif answer == 2: + self.quote(item=item) + else: + self._direct_repost(uri) + + def _direct_repost(self, uri): + try: + self.session.repost(uri) + self.session.sound.play("retweet_send.ogg") + output.speak(_("Reposted.")) + except Exception as e: + log.error("Error reposting: %s", e) + output.speak(_("Failed to repost."), True) + + def quote(self, event=None, item=None, *args, **kwargs): + if item is None: + item = self.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) + + uri = g(item, "uri") or g(g(item, "post"), "uri") + if not uri: + output.speak(_("Could not find post to quote."), True) + return + + title = _("Quote post") + caption = _("Write your comment here") + dlg = blueski_messages.post(session=self.session, title=title, caption=caption) + if dlg.message.ShowModal() == wx.ID_OK: + text, files, cw, langs = dlg.get_data() + if text or files: + def do_quote(): + try: + result = self.session.send_message( + message=text, + files=files, + cw_text=cw, + langs=langs, + quote_uri=uri, + ) + if result: + wx.CallAfter(self.session.sound.play, "retweet_send.ogg") + wx.CallAfter(output.speak, _("Quote posted.")) + else: + wx.CallAfter(output.speak, _("Failed to post quote."), True) + except Exception as e: + log.error("Error posting quote: %s", e) + wx.CallAfter(output.speak, _("Failed to post quote."), True) + call_threaded(do_quote) + dlg.message.Destroy() + + def on_like(self, evt): + self.toggle_favorite(confirm=False) + + def toggle_favorite(self, confirm=False, *args, **kwargs): + item = self.get_item() + if not item: + output.speak(_("No item to like."), True) + return + + def g(obj, key, default=None): + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + uri = g(item, "uri") + if not uri: + post = g(item, "post") or g(item, "record") + uri = g(post, "uri") if post else None + + if not uri: + output.speak(_("Could not find post identifier."), True) + return + + if confirm: + if wx.MessageBox(_("Like this post?"), _("Confirm"), wx.YES_NO | wx.ICON_QUESTION) != wx.YES: + return + + # Check if already liked + viewer = g(item, "viewer") + already_liked = g(viewer, "like") if viewer else None + + if already_liked: + output.speak(_("Already liked."), True) + return + + # Perform the like + like_uri = self.session.like(uri) + if not like_uri: + output.speak(_("Failed to like post."), True) + return + + self.session.sound.play("favourite.ogg") + output.speak(_("Liked.")) + + # Update the viewer state in the item + if isinstance(item, dict): + if "viewer" not in item: + item["viewer"] = {} + item["viewer"]["like"] = like_uri + else: + # For SDK models, create or update viewer + if not hasattr(item, "viewer") or item.viewer is None: + # Create a simple object to hold the like state + class Viewer: + def __init__(self): + self.like = None + item.viewer = Viewer() + item.viewer.like = like_uri + + # Refresh the displayed item in the list + try: + index = self.buffer.list.get_selected() + if index > -1: + # Recompose and update the list item + safe = True + relative_times = self.session.settings["general"].get("relative_times", False) + show_screen_names = self.session.settings["general"].get("show_screen_names", False) + post_data = self.compose_function(item, self.session.db, self.session.settings, + relative_times=relative_times, + show_screen_names=show_screen_names, + safe=safe) + + # Update the item in place (only 3 columns: Author, Post, Date) + self.buffer.list.list.SetItem(index, 0, post_data[0]) # Author + self.buffer.list.list.SetItem(index, 1, post_data[1]) # Text + self.buffer.list.list.SetItem(index, 2, post_data[2]) # Date + # Note: compose_post returns 4 items but list only has 3 columns + except Exception as e: + log.error("Error refreshing list item after like: %s", e) + + def add_to_favorites(self, *args, **kwargs): + self.toggle_favorite(confirm=False) + + def remove_from_favorites(self, *args, **kwargs): + # We need unlike support in session + pass + + def on_dm(self, evt): + self.send_message() + + def send_message(self, *args, **kwargs): + # Global shortcut for DM - similar to Mastodon's implementation + + # Use the robust author extraction method + author_details = self.get_selected_item_author_details() + if not author_details: + output.speak(_("No item selected."), True) + return + + did = author_details.get("did") + handle = author_details.get("handle") or "unknown" + + if not did: + output.speak(_("Could not identify user to message."), True) + return + + # Use full post dialog like Mastodon + title = _("Conversation with {0}").format(handle) + caption = _("Write your message here") + initial_text = "@{} ".format(handle) + + 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: + api = self.session._ensure_client() + dm_client = api.with_bsky_chat_proxy() + # Get or create conversation + res = dm_client.chat.bsky.convo.get_convo_for_members({"members": [did]}) + convo_id = res.convo.id + self.session.send_chat_message(convo_id, text) + wx.CallAfter(self.session.sound.play, "dm_sent.ogg") + wx.CallAfter(output.speak, _("Message sent.")) + except Exception as e: + log.error("Error sending Bluesky DM: %s", e) + wx.CallAfter(output.speak, _("Failed to send message."), True) + call_threaded(do_send) + if hasattr(post.message, "Destroy"): + post.message.Destroy() + + def view(self, *args, **kwargs): + self.view_item() + + def view_item(self, item=None): + if item is None: + item = self.get_item() + if not item: + return + if not blueski_messages.has_post_data(item): + pub.sendMessage("execute-action", action="user_details") + return + try: + blueski_messages.viewPost(self.session, item, controller=getattr(self, "controller", None)) + except Exception as e: + log.error("Error opening Bluesky post viewer: %s", e) + + def url_(self, *args, **kwargs): + self.url() + + def url(self, announce=True, item=None, *args, **kwargs): + """Open URLs found in the post content.""" + if item is None: + item = self.get_item() + if not item: + return + + import webbrowser + from wxUI.dialogs import urlList + + urls = utils.find_urls(item) + url = "" + if len(urls) == 1: + url = urls[0] + elif len(urls) > 1: + urls_list = urlList.urlList() + urls_list.populate_list(urls) + if urls_list.get_response() == widgetUtils.OK: + url = urls_list.get_string() + if hasattr(urls_list, "destroy"): + urls_list.destroy() + if url != '': + if announce: + output.speak(_(u"Opening URL..."), True) + webbrowser.open_new_tab(url) + + def user_actions(self, *args, **kwargs): + pub.sendMessage("execute-action", action="follow") + + def view_chat_with_user(self, did, handle): + try: + api = self.session._ensure_client() + res = api.chat.bsky.convo.get_convo_for_members({"members": [did]}) + convo_id = res.convo.id + + title = _("Chat: {0}").format(handle) + self.controller.create_buffer( + buffer_type="chat_messages", + session_type="blueski", + buffer_title=title, + kwargs={"session": self.session, "convo_id": convo_id, "name": title}, + start=True + ) + except: + output.speak(_("Could not open chat."), True) + + def block_user(self, *args, **kwargs): + item = self.get_item() + if not item: return + author = getattr(item, "author", None) or (item.get("post", {}).get("author") if isinstance(item, dict) else item) + did = getattr(author, "did", None) or (author.get("did") if isinstance(author, dict) else None) + handle = getattr(author, "handle", "unknown") or (author.get("handle") if isinstance(author, dict) else "unknown") + + if wx.MessageBox(_("Are you sure you want to block {0}?").format(handle), _("Block"), wx.YES_NO | wx.ICON_WARNING) == wx.YES: + if self.session.block_user(did): + output.speak(_("User blocked.")) + else: + output.speak(_("Failed to block user.")) + + def unblock_user(self, *args, **kwargs): + # Unblocking usually needs the block record URI. + # In a UserBuffer (Blocks), it might be present. + item = self.get_item() + if not item: return + + # Check if item itself is a block record or user object with viewer.blocking + block_uri = None + if isinstance(item, dict): + block_uri = item.get("viewer", {}).get("blocking") + else: + viewer = getattr(item, "viewer", None) + block_uri = getattr(viewer, "blocking", None) if viewer else None + + if not block_uri: + output.speak(_("Could not find block information for this user."), True) + return + + if self.session.unblock_user(block_uri): + output.speak(_("User unblocked.")) + else: + output.speak(_("Failed to unblock user.")) + + def put_items_on_list(self, number_of_items): + list_to_use = self.session.db[self.name] + count = self.buffer.list.get_count() + reverse = False + try: + reverse = self.session.settings["general"].get("reverse_timelines", False) + except: pass + + if number_of_items == 0: + return + + safe = True + relative_times = self.session.settings["general"].get("relative_times", False) + show_screen_names = self.session.settings["general"].get("show_screen_names", False) + + if count == 0: + for i in list_to_use: + post = self.compose_function(i, 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) + # Set selection + total = self.buffer.list.get_count() + if total > 0: + if not reverse: + self.buffer.list.select_item(total - 1) # Bottom + else: + self.buffer.list.select_item(0) # Top + + elif count > 0 and number_of_items > 0: + if not reverse: + items = list_to_use[:number_of_items] # If we prepended items for normal (oldest first) timeline... wait. + # Standard flow: "New items" come from API. + # If standard timeline (oldest at top, newest at bottom): new items appended to DB. + # UI: append to bottom. + items = list_to_use[len(list_to_use)-number_of_items:] + for i in items: + post = self.compose_function(i, 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) + else: + # Reverse timeline (Newest at top). + # New items appended to DB? Or inserted at 0? + # Mastodon BaseBuffer: + # if reverse_timelines == False: items_db.insert(0, i) (Wait, insert at 0?) + # Actually let's look at `get_more_items` in Mastodon BaseBuffer again. + # "if self.session.settings["general"]["reverse_timelines"] == False: items_db.insert(0, i)" + # This means for standard timeline, new items (newer time) go to index 0? + # No, standard timeline usually has oldest at top. Retrieve "more items" usually means "newer items" or "older items" depending on context (streaming vs styling). + + # Let's trust that we just need to insert based on how we updated DB in start_stream. + + # For now, simplistic approach: + items = list_to_use[0:number_of_items] # Assuming we inserted at 0 in DB + # items.reverse() if needed? + for i in items: + post = self.compose_function(i, self.session.db, self.session.settings, relative_times=relative_times, show_screen_names=show_screen_names, safe=safe) + self.buffer.list.insert_item(True, *post) # Insert at 0 (True) + + def reply(self, *args, **kwargs): + self.on_reply(None) + + def post_status(self, *args, **kwargs): + self.on_post(None) + + def destroy_status(self, *args, **kwargs): + # Delete post + item = self.get_item() + if not item: return + uri = self.get_selected_item_id() + if not uri: + if isinstance(item, dict): + uri = item.get("uri") or item.get("post", {}).get("uri") + else: + post = getattr(item, "post", None) + uri = getattr(item, "uri", None) or getattr(post, "uri", None) + if not uri: + output.speak(_("Could not find the post identifier."), True) + return + + # Check if author is self + # Implementation depends on parsing URI or checking active user DID vs author DID + # For now, just try and handle error + if wx.MessageBox(_("Delete this post?"), _("Confirm"), wx.YES_NO | wx.ICON_QUESTION) == wx.YES: + try: + ok = self.session.delete_post(uri) + if not ok: + output.speak(_("Could not delete."), True) + return + index = self.buffer.list.get_selected() + if index > -1 and self.session.db.get(self.name): + try: + self.session.db[self.name].pop(index) + except Exception: + pass + try: + self.buffer.list.remove_item(index) + except Exception: + pass + output.speak(_("Deleted.")) + except Exception as e: + log.error("Error deleting Bluesky post: %s", e) + output.speak(_("Could not delete."), True) + + + def audio(self, event=None, item=None, *args, **kwargs): + """Play audio/video from the current post.""" + if sound.URLPlayer.player.is_playing(): + return sound.URLPlayer.stop_audio() + if item is None: + item = self.get_item() + if not item: + return + urls = utils.get_media_urls(item) + if not urls: + output.speak(_("This post has no playable media."), True) + return + url = "" + if len(urls) == 1: + url = urls[0] + elif len(urls) > 1: + from wxUI.dialogs import urlList + urls_list = urlList.urlList() + urls_list.populate_list(urls) + if urls_list.get_response() == widgetUtils.OK: + url = urls_list.get_string() + if hasattr(urls_list, "destroy"): + urls_list.destroy() + if url: + sound.URLPlayer.play(url, self.session.settings["sound"]["volume"]) + + def ocr_image(self, *args, **kwargs): + """Perform OCR on images in the current post.""" + post = self.get_item() + if not post: + return + + image_list = utils.get_image_urls(post) + if not image_list: + return + + if len(image_list) > 1: + from wxUI.dialogs import urlList + labels = [_("Picture {0}").format(i + 1) for i in range(len(image_list))] + dialog = urlList.urlList(title=_("Select the picture")) + dialog.populate_list(labels) + if dialog.get_response() != widgetUtils.OK: + return + img = image_list[dialog.get_item()] + else: + img = image_list[0] + + url = img.get("url") + if not url: + return + + from extra import ocr as ocr_module + api = ocr_module.OCRSpace.OCRSpaceAPI() + try: + text = api.OCR_URL(url) + except ocr_module.OCRSpace.APIError: + output.speak(_("Unable to extract text"), True) + return + except Exception as e: + log.error("OCR error: %s", e) + output.speak(_("Unable to extract text"), True) + return + + viewer = blueski_messages.text(title=_("OCR Result"), text=text["ParsedText"]) + viewer.message.ShowModal() + viewer.message.Destroy() + + # Also implement "view_item" if standard keymap uses it + def get_formatted_message(self): + return self.compose_function(self.get_item(), 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): + item = self.get_item() + if item is None: + return + relative_times = self.session.settings["general"].get("relative_times", False) + offset_hours = 0 + if isinstance(self.session.db, dict): + offset_hours = self.session.db.get("utc_offset", 0) or 0 + template_settings = self.session.settings.get("templates", {}) + try: + if self.type == "notifications": + template = template_settings.get("notification", "$display_name $text, $date") + post_template = template_settings.get("post", "$display_name, $reply_to$safe_text $date.") + return templates.render_notification(item, template, post_template, self.session.settings, relative_times, offset_hours) + if self.type in ("user", "post_user_list"): + template = template_settings.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.") + return templates.render_user(item, template, self.session.settings, relative_times, offset_hours) + template = template_settings.get("post", "$display_name, $reply_to$safe_text $date.") + return templates.render_post(item, template, self.session.settings, relative_times, offset_hours) + except Exception: + # Fallback to compose if any template render fails. + composed = self.compose_function( + item, + self.session.db, + self.session.settings, + relative_times, + self.session.settings["general"].get("show_screen_names", False), + ) + return " ".join(composed) + + def view_conversation(self, *args, **kwargs): + item = self.get_item() + if not item: return + + uri = item.get("uri") if isinstance(item, dict) else getattr(item, "uri", None) + if not uri: return + + controller = self.controller + + details = self.get_selected_item_author_details() + handle = "Unknown" + if details: + handle = details.get("handle") or "Unknown" + title = _("Conversation: {0}").format(handle) + + controller.create_buffer( + buffer_type="conversation", + session_type="blueski", + buffer_title=title, + kwargs={"session": self.session, "uri": uri, "name": title}, + start=True + ) + + def get_item(self): + index = self.buffer.list.get_selected() + if index > -1 and self.session.db.get(self.name) is not None: + # Logic implies DB order matches UI order + return self.session.db[self.name][index] + + def get_selected_item_id(self): + item = self.get_item() + if not item: + return None + + if isinstance(item, dict): + uri = item.get("uri") + if uri: + return uri + post = item.get("post") or item.get("record") + if isinstance(post, dict): + return post.get("uri") + return getattr(post, "uri", None) + + return getattr(item, "uri", None) or getattr(getattr(item, "post", None), "uri", None) + + def get_selected_item_cid(self): + item = self.get_item() + if not item: + return None + + if isinstance(item, dict): + cid = item.get("cid") + if cid: + return cid + post = item.get("post") or item.get("record") + if isinstance(post, dict): + return post.get("cid") + return getattr(post, "cid", None) + + return getattr(item, "cid", None) or getattr(getattr(item, "post", None), "cid", None) + + def get_selected_item_author_details(self): + item = self.get_item() + if not item: + return None + + def g(obj, key, default=None): + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + author = None + + # Check if item itself is a user object (UserBuffer, FollowersBuffer, etc.) + if g(item, "did") or g(item, "handle"): + author = item + else: + # Use the same pattern as compose_post: get actual_post first + # This handles FeedViewPost (item.post) and PostView (item itself) + actual_post = g(item, "post", item) + author = g(actual_post, "author") + + # If still no author, try other nested structures + if not author: + # Try record.author + record = g(item, "record") + if record: + author = g(record, "author") + + # Try subject (for notifications) + if not author: + subject = g(item, "subject") + if subject: + actual_subject = g(subject, "post", subject) + author = g(actual_subject, "author") + + if not author: + return None + + did = g(author, "did") + handle = g(author, "handle") + + # Only return if we have at least did or handle + if not did and not handle: + return None + + return { + "did": did, + "handle": handle, + } + + def _hydrate_reply_handles(self, items): + """Populate _reply_to_handle on items when reply metadata lacks hydrated author.""" + 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 set_handle(obj, handle): + if not obj or not handle: + return + if isinstance(obj, dict): + obj["_reply_to_handle"] = handle + else: + try: + setattr(obj, "_reply_to_handle", handle) + except Exception: + pass + + def get_parent_uri(item): + actual_post = g(item, "post", item) + record = g(actual_post, "record", {}) or {} + reply = g(record, "reply", None) + if not reply: + return None + parent = g(reply, "parent", None) or reply + return g(parent, "uri", None) + + unresolved_by_parent_uri = {} + for item in items: + if utils.extract_reply_to_handle(item): + continue + parent_uri = get_parent_uri(item) + if not parent_uri: + continue + unresolved_by_parent_uri.setdefault(parent_uri, []).append(item) + + if not unresolved_by_parent_uri: + return + + api = self.session._ensure_client() + if not api: + return + + uris = list(unresolved_by_parent_uri.keys()) + uri_to_handle = {} + chunk_size = 25 + + for start in range(0, len(uris), chunk_size): + chunk = uris[start:start + chunk_size] + try: + try: + res = api.app.bsky.feed.get_posts({"uris": chunk}) + except Exception: + res = api.app.bsky.feed.get_posts(uris=chunk) + posts = list(getattr(res, "posts", None) or []) + for parent_post in posts: + uri = g(parent_post, "uri", None) + author = g(parent_post, "author", None) or {} + handle = g(author, "handle", None) + if uri and handle: + uri_to_handle[uri] = handle + except Exception as e: + log.debug("Could not hydrate reply handles for chunk: %s", e) + + if not uri_to_handle: + return + + for parent_uri, item_list in unresolved_by_parent_uri.items(): + handle = uri_to_handle.get(parent_uri) + if not handle: + continue + for item in item_list: + set_handle(item, handle) + actual_post = g(item, "post", item) + set_handle(actual_post, handle) + + def process_items(self, items, play_sound=True, avoid_autoreading=False): + """ + Process list of items (FeedViewPost objects), update DB, and update UI. + Returns number of new items. + """ + if not items: + return 0 + + # Identify new items + new_items = [] + current_uris = set() + # Create a set of keys from existing db to check duplicates + def get_key(it): + if isinstance(it, dict): + post = it.get("post") + if isinstance(post, dict) and post.get("uri"): + return post.get("uri") + if it.get("uri"): + return it.get("uri") + for key in ("id", "cid", "rev", "convoId", "convo_id", "messageId", "message_id", "msgId", "msg_id"): + if it.get(key): + return it.get(key) + nested = it.get("message") or it.get("record") + if isinstance(nested, dict): + for key in ("id", "cid", "rev", "messageId", "message_id", "msgId", "msg_id"): + if nested.get(key): + return nested.get(key) + if it.get("did"): + return it.get("did") + if it.get("handle"): + return it.get("handle") + author = it.get("author") + if isinstance(author, dict): + return author.get("did") or author.get("handle") + # Chat message fallback — use str() to ensure stable hash/comparison + sent_at = it.get("sentAt") or it.get("sent_at") or it.get("createdAt") or it.get("created_at") + sender = it.get("sender") or (nested.get("sender") if isinstance(nested, dict) else {}) or {} + sender_did = sender.get("did") if isinstance(sender, dict) else None + text = it.get("text") or (nested.get("text") if isinstance(nested, dict) 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 + post = getattr(it, "post", None) + if post is not None: + return getattr(post, "uri", None) + for attr in ("uri", "id", "cid", "rev", "convoId", "convo_id", "messageId", "message_id", "msgId", "msg_id", "did", "handle"): + val = getattr(it, attr, None) + if val: + return val + nested = getattr(it, "message", None) or getattr(it, "record", None) + if nested is not None: + for attr in ("id", "cid", "rev", "messageId", "message_id", "msgId", "msg_id"): + val = getattr(nested, attr, None) + if val: + return val + author = getattr(it, "author", None) + if author is not None: + return getattr(author, "did", None) or getattr(author, "handle", None) + sent_at = getattr(it, "sentAt", None) or getattr(it, "sent_at", None) or getattr(it, "createdAt", None) or getattr(it, "created_at", None) + sender = getattr(it, "sender", None) or (getattr(nested, "sender", None) if nested is not None else None) + sender_did = getattr(sender, "did", None) if sender is not None else None + text = getattr(it, "text", None) or (getattr(nested, "text", None) if nested is not None 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 + + for item in self.session.db[self.name]: + key = get_key(item) + if key: + current_uris.add(key) + + for item in items: + key = get_key(item) + if key: + if key in current_uris: + continue + current_uris.add(key) + new_items.append(item) + + if not new_items: + return 0 + + self._hydrate_reply_handles(new_items) + + # Add to DB + # Reverse timeline setting + reverse = False + try: reverse = self.session.settings["general"].get("reverse_timelines", False) + except: pass + + # If reverse (newest at top), we insert new items at index 0? + # Typically API returns newest first. + # If DB is [Newest ... Oldest] (Reverse order) + # Then we insert new items at 0. + # If DB is [Oldest ... Newest] (Normal order) + # Then we append new items at end. + + # But traditionally APIs return [Newest ... Oldest]. + # So 'items' list is [Newest ... Oldest]. + + if reverse: # Newest at top + # DB: [Newest (Index 0) ... Oldest] + # We want to insert 'new_items' at 0. + # But 'new_items' are also [Newest...Oldest] + # So duplicates check handled. + # We insert the whole block at 0? + for it in reversed(new_items): # Insert oldest of new first, so newest ends up at 0 + self.session.db[self.name].insert(0, it) + else: # Oldest at top + # DB: [Oldest ... Newest] + # APIs return [Newest ... Oldest] + # We want to append them. + # So we append reversed(new_items)? + for it in reversed(new_items): + self.session.db[self.name].append(it) + + # Update UI + self.put_items_on_list(len(new_items)) + + # Play sound + if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]: + self.session.sound.play(self.sound) + + # Auto-read for accessibility + if not avoid_autoreading and len(new_items) > 0: + self.auto_read(len(new_items)) + + return len(new_items) + + def add_new_item(self, item): + """Add a single new item from streaming.""" + self._hydrate_reply_handles([item]) + safe = True + relative_times = self.session.settings["general"].get("relative_times", False) + show_screen_names = self.session.settings["general"].get("show_screen_names", False) + post = self.compose_function(item, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe) + + if self.session.settings["general"].get("reverse_timelines", False): + self.buffer.list.insert_item(True, *post) + self.session.db[self.name].insert(0, item) + else: + self.buffer.list.insert_item(False, *post) + self.session.db[self.name].append(item) + + # Auto-read single item + if self.name in self.session.settings["other_buffers"].get("autoread_buffers", []) and \ + self.name not in self.session.settings["other_buffers"].get("muted_buffers", []) and \ + not self.session.settings["sound"].get("session_mute", False): + output.speak(" ".join(post[:2]), + speech=self.session.settings["reporting"].get("speech_reporting", True), + braille=self.session.settings["reporting"].get("braille_reporting", True)) + + def update_item(self, item, position): + """Update an existing item at the specified position.""" + safe = True + relative_times = self.session.settings["general"].get("relative_times", False) + show_screen_names = self.session.settings["general"].get("show_screen_names", False) + post = self.compose_function(item, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe) + self.buffer.list.list.SetItem(position, 1, post[1]) + + def open_in_browser(self, *args, **kwargs): + """Open the current post in web browser.""" + item = self.get_item() + if not item: + return + + import webbrowser + + def g(obj, key, default=None): + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + uri = g(item, "uri") or g(g(item, "post"), "uri") + author = g(item, "author") or g(g(item, "post"), "author") + handle = g(author, "handle") + + if uri and handle: + if "app.bsky.feed.post" in uri: + rkey = uri.split("/")[-1] + url = f"https://bsky.app/profile/{handle}/post/{rkey}" + output.speak(_("Opening item in web browser...")) + webbrowser.open(url) + return + + # Fallback to profile + if handle: + url = f"https://bsky.app/profile/{handle}" + output.speak(_("Opening item in web browser...")) + webbrowser.open(url) + + def save_positions(self): + try: + self.session.db[self.name+"_pos"] = self.buffer.list.get_selected() + except: pass + + def clear_list(self): + dlg = commonMessageDialogs.clear_list() + if dlg == widgetUtils.YES: + self.session.db[self.name] = [] + self.buffer.list.clear() + + def remove_buffer(self, force=False): + if self.type in ("conversation", "chat_messages") or self.name.lower().startswith("conversation"): + if not force: + dlg = commonMessageDialogs.remove_buffer() + if dlg != widgetUtils.YES: + return False + try: + self.session.db.pop(self.name, None) + except Exception: + pass + return True + return False + diff --git a/src/controller/buffers/blueski/chat.py b/src/controller/buffers/blueski/chat.py new file mode 100644 index 00000000..b51ac948 --- /dev/null +++ b/src/controller/buffers/blueski/chat.py @@ -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 diff --git a/src/controller/buffers/blueski/timeline.py b/src/controller/buffers/blueski/timeline.py new file mode 100644 index 00000000..1dc87579 --- /dev/null +++ b/src/controller/buffers/blueski/timeline.py @@ -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 diff --git a/src/controller/buffers/blueski/user.py b/src/controller/buffers/blueski/user.py new file mode 100644 index 00000000..94679691 --- /dev/null +++ b/src/controller/buffers/blueski/user.py @@ -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 diff --git a/src/controller/mainController.py b/src/controller/mainController.py index fef23b5c..0bca1c15 100644 --- a/src/controller/mainController.py +++ b/src/controller/mainController.py @@ -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) \ No newline at end of file + handler.manage_filters(self, buffer) diff --git a/src/controller/mastodon/messages.py b/src/controller/mastodon/messages.py index 63c6a7da..a949cffc 100644 --- a/src/controller/mastodon/messages.py +++ b/src/controller/mastodon/messages.py @@ -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) \ No newline at end of file + widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate) diff --git a/src/controller/mastodon/templateEditor.py b/src/controller/mastodon/templateEditor.py index 330a304c..b01e590c 100644 --- a/src/controller/mastodon/templateEditor.py +++ b/src/controller/mastodon/templateEditor.py @@ -39,4 +39,4 @@ class EditTemplate(object): else: return dialog.template.GetValue() else: - return "" \ No newline at end of file + return "" diff --git a/src/keystrokeEditor/actions/__init__.py b/src/keystrokeEditor/actions/__init__.py index f18d4a6f..5d46d1a2 100644 --- a/src/keystrokeEditor/actions/__init__.py +++ b/src/keystrokeEditor/actions/__init__.py @@ -1 +1,2 @@ -from . import mastodon \ No newline at end of file +from . import mastodon +from . import blueski \ No newline at end of file diff --git a/src/keystrokeEditor/actions/blueski.py b/src/keystrokeEditor/actions/blueski.py new file mode 100644 index 00000000..ed7a0097 --- /dev/null +++ b/src/keystrokeEditor/actions/blueski.py @@ -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"), +} diff --git a/src/multiplatform_widgets/widgets.py b/src/multiplatform_widgets/widgets.py index 1a15e4ca..33b20bba 100644 --- a/src/multiplatform_widgets/widgets.py +++ b/src/multiplatform_widgets/widgets.py @@ -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) diff --git a/src/sessionmanager/sessionManager.py b/src/sessionmanager/sessionManager.py index e89f99c1..9d6168ab 100644 --- a/src/sessionmanager/sessionManager.py +++ b/src/sessionmanager/sessionManager.py @@ -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): diff --git a/src/sessionmanager/wxUI.py b/src/sessionmanager/wxUI.py index 2a23c3cf..a0efaa21 100644 --- a/src/sessionmanager/wxUI.py +++ b/src/sessionmanager/wxUI.py @@ -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) diff --git a/src/sessions/base.py b/src/sessions/base.py index 10e308fd..583456bd 100644 --- a/src/sessions/base.py +++ b/src/sessions/base.py @@ -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.""" diff --git a/src/sessions/blueski/__init__.py b/src/sessions/blueski/__init__.py new file mode 100644 index 00000000..414557f5 --- /dev/null +++ b/src/sessions/blueski/__init__.py @@ -0,0 +1,3 @@ +from .session import Session + +__all__ = ["Session"] diff --git a/src/sessions/blueski/compose.py b/src/sessions/blueski/compose.py new file mode 100644 index 00000000..a87bf630 --- /dev/null +++ b/src/sessions/blueski/compose.py @@ -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] diff --git a/src/sessions/blueski/session.py b/src/sessions/blueski/session.py new file mode 100644 index 00000000..8e5810fe --- /dev/null +++ b/src/sessions/blueski/session.py @@ -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 diff --git a/src/sessions/blueski/streaming.py b/src/sessions/blueski/streaming.py new file mode 100644 index 00000000..409b6144 --- /dev/null +++ b/src/sessions/blueski/streaming.py @@ -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) diff --git a/src/sessions/blueski/templates.py b/src/sessions/blueski/templates.py new file mode 100644 index 00000000..527fd3d5 --- /dev/null +++ b/src/sessions/blueski/templates.py @@ -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) diff --git a/src/sessions/blueski/utils.py b/src/sessions/blueski/utils.py new file mode 100644 index 00000000..8bd61682 --- /dev/null +++ b/src/sessions/blueski/utils.py @@ -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, + } diff --git a/src/sessions/mastodon/session.py b/src/sessions/mastodon/session.py index 8a205862..38afb900 100644 --- a/src/sessions/mastodon/session.py +++ b/src/sessions/mastodon/session.py @@ -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"]) \ No newline at end of file + pub.sendMessage("mastodon.new_item", session_name=self.get_name(), item=notification, _buffers=["notifications"]) diff --git a/src/test/sessions/blueski/__init__.py b/src/test/sessions/blueski/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/test/sessions/blueski/test_blueski_quotes.py b/src/test/sessions/blueski/test_blueski_quotes.py new file mode 100644 index 00000000..477165b0 --- /dev/null +++ b/src/test/sessions/blueski/test_blueski_quotes.py @@ -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() diff --git a/src/test/sessions/blueski/test_blueski_session.py b/src/test/sessions/blueski/test_blueski_session.py new file mode 100644 index 00000000..7bda6870 --- /dev/null +++ b/src/test/sessions/blueski/test_blueski_session.py @@ -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() diff --git a/src/twblue.pot b/src/twblue.pot new file mode 100644 index 00000000..e69de29b diff --git a/src/wxUI/buffers/blueski/panels.py b/src/wxUI/buffers/blueski/panels.py new file mode 100644 index 00000000..950ef910 --- /dev/null +++ b/src/wxUI/buffers/blueski/panels.py @@ -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() diff --git a/src/wxUI/commonMessageDialogs.py b/src/wxUI/commonMessageDialogs.py index 401ba8a4..ae6a0719 100644 --- a/src/wxUI/commonMessageDialogs.py +++ b/src/wxUI/commonMessageDialogs.py @@ -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() diff --git a/src/wxUI/dialogs/blueski/configuration.py b/src/wxUI/dialogs/blueski/configuration.py new file mode 100644 index 00000000..0f6fb3aa --- /dev/null +++ b/src/wxUI/dialogs/blueski/configuration.py @@ -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)) + diff --git a/src/wxUI/dialogs/blueski/menus.py b/src/wxUI/dialogs/blueski/menus.py new file mode 100644 index 00000000..109adbda --- /dev/null +++ b/src/wxUI/dialogs/blueski/menus.py @@ -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) diff --git a/src/wxUI/dialogs/blueski/postDialogs.py b/src/wxUI/dialogs/blueski/postDialogs.py new file mode 100644 index 00000000..9a295655 --- /dev/null +++ b/src/wxUI/dialogs/blueski/postDialogs.py @@ -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 + diff --git a/src/wxUI/dialogs/blueski/showUserProfile.py b/src/wxUI/dialogs/blueski/showUserProfile.py new file mode 100644 index 00000000..59723015 --- /dev/null +++ b/src/wxUI/dialogs/blueski/showUserProfile.py @@ -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}") + diff --git a/src/wxUI/dialogs/blueski/userActions.py b/src/wxUI/dialogs/blueski/userActions.py new file mode 100644 index 00000000..8459a684 --- /dev/null +++ b/src/wxUI/dialogs/blueski/userActions.py @@ -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()) + diff --git a/src/wxUI/dialogs/composeDialog.py b/src/wxUI/dialogs/composeDialog.py new file mode 100644 index 00000000..b5bd2c99 --- /dev/null +++ b/src/wxUI/dialogs/composeDialog.py @@ -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() diff --git a/src/wxUI/dialogs/userList.py b/src/wxUI/dialogs/userList.py index ff037068..143f8298 100644 --- a/src/wxUI/dialogs/userList.py +++ b/src/wxUI/dialogs/userList.py @@ -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) diff --git a/tools/twblue-documentation.pot b/tools/twblue-documentation.pot index 43d442f1..fd003dc2 100644 --- a/tools/twblue-documentation.pot +++ b/tools/twblue-documentation.pot @@ -1,1544 +1,246 @@ # Translations template for PROJECT. -# Copyright (C) 2022 MCV software +# Copyright (C) 2024 MCV software # This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2022. +# FIRST AUTHOR , 2024. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: PROJECT VERSION\n" -"Report-Msgid-Bugs-To: manuel@manuelcortez.net\n" -"POT-Creation-Date: 2022-12-20 17:18-0600\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.10.3\n" +"Project-Id-Version: TWBlue Documentation VERSION\\n" +"Report-Msgid-Bugs-To: manuel@manuelcortez.net\\n" +"POT-Creation-Date: 2024-05-26 12:00+0000\\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n" +"Last-Translator: FULL NAME \\n" +"Language-Team: LANGUAGE \\n" +"MIME-Version: 1.0\\n" +"Content-Type: text/plain; charset=utf-8\\n" +"Content-Transfer-Encoding: 8bit\\n" +"Generated-By: TWBlue Manual Process\\n" -#: ../doc/strings.py:3 -msgid "Documentation for TWBlue" +#: documentation/source/atprotosocial.rst +msgid "ATProtoSocial (Bluesky) Integration" msgstr "" -#: ../doc/strings.py:5 -msgid "## Table of contents" +#: documentation/source/atprotosocial.rst +msgid "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." msgstr "" -#: ../doc/strings.py:7 -msgid "[TOC]" +#: documentation/source/atprotosocial.rst +msgid "Adding an ATProtoSocial Account" msgstr "" -#: ../doc/strings.py:9 -msgid "## Warning!" +#: documentation/source/atprotosocial.rst +msgid "To connect your Bluesky account to TWBlue, you will need your user **handle** and an **App Password**." msgstr "" -#: ../doc/strings.py:11 -msgid "" -"You are reading documentation produced for a program still in " -"development. The object of this manual is to explain some details of the " -"operation of the program. Bear in mind that as the software is in the " -"process of active development, parts of this user guide may change in the" -" near future, so it is advisable to keep checking from time to time to " -"avoid missing important information." +#: documentation/source/atprotosocial.rst +msgid "User Handle" msgstr "" -#: ../doc/strings.py:13 -msgid "" -"If you want to see what has changed from the previous version, [read the " -"list of updates here.](changes.html)" +#: documentation/source/atprotosocial.rst +msgid "This is your unique Bluesky identifier, often in the format ``@username.bsky.social`` or a custom domain you've configured (e.g., ``@yourname.com``)." msgstr "" -#: ../doc/strings.py:15 -msgid "## Introduction" +#: documentation/source/atprotosocial.rst +msgid "App Password" msgstr "" -#: ../doc/strings.py:17 -msgid "" -"TWBlue is an application to make Twitter simple and fast, while using as " -"few resources as possible. With TWBlue, you can do things like the " -"following:" +#: documentation/source/atprotosocial.rst +msgid "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." msgstr "" -#: ../doc/strings.py:19 -msgid "* Tweet, reply to, retweet and delete tweets," +#: documentation/source/atprotosocial.rst +msgid "Go to Bluesky Settings (usually accessible from the Bluesky app or website)." msgstr "" -#: ../doc/strings.py:20 -msgid "* Like and unlike a tweet," +#: documentation/source/atprotosocial.rst +msgid "Navigate to the \"App passwords\" section (this might be under \"Advanced\" or \"Security\")." msgstr "" -#: ../doc/strings.py:21 -msgid "* Send and delete direct messages," +#: documentation/source/atprotosocial.rst +msgid "Generate a new App Password. Give it a descriptive name (e.g., \"TWBlue\")." msgstr "" -#: ../doc/strings.py:22 -msgid "* See your friends and followers," +#: documentation/source/atprotosocial.rst +msgid "Copy the generated App Password immediately. It will usually only be shown once." msgstr "" -#: ../doc/strings.py:23 -msgid "* Follow, unfollow, report and block a user," +#: documentation/source/atprotosocial.rst +msgid "Once you have your handle and the App Password:" msgstr "" -#: ../doc/strings.py:24 -msgid "* Open a user's timeline to see their tweets separately," +#: documentation/source/atprotosocial.rst +msgid "Open TWBlue and go to the Session Manager (Application Menu -> Manage accounts)." msgstr "" -#: ../doc/strings.py:25 -msgid "* Open URLs from a tweet or direct message," +#: documentation/source/atprotosocial.rst +msgid "Click on \"New account\"." msgstr "" -#: ../doc/strings.py:26 -msgid "* Play several types of audio files from addresses," +#: documentation/source/atprotosocial.rst +msgid "Select \"ATProtoSocial (Bluesky)\" from the menu." msgstr "" -#: ../doc/strings.py:27 -msgid "* And more." +#: documentation/source/atprotosocial.rst +msgid "A dialog will prompt you to confirm that you want to authorize your account. Click \"Yes\"." msgstr "" -#: ../doc/strings.py:29 -msgid "## Usage" +#: documentation/source/atprotosocial.rst +msgid "You will then be asked for your Bluesky Handle. Enter your full handle (e.g., ``@username.bsky.social`` or ``username.bsky.social``)." msgstr "" -#: ../doc/strings.py:31 -msgid "" -"Twitter is a social networking or micro-blogging tool which allows you to" -" compose short status updates of your activities in 280 characters or " -"less. Twitter is a way for friends, family and co-workers to communicate " -"and stay connected through the exchange of quick, frequent messages. You " -"can restrict delivery of updates to those in your circle of friends or, " -"by default, allow anyone to access them." +#: documentation/source/atprotosocial.rst +msgid "Next, you will be asked for the App Password you generated. Enter it carefully." msgstr "" -#: ../doc/strings.py:33 -msgid "" -"You can monitor the status of updates from your friends, family or co-" -"workers (known as following), and they in turn can read any updates you " -"create, (known as followers). The updates are referred to as Tweets. The " -"Tweets are posted to your Twitter profile or Blog and are searchable " -"using Twitter Search." +#: documentation/source/atprotosocial.rst +msgid "If the credentials are correct, TWBlue will log in to your Bluesky account, and the new session will be added to your accounts list." msgstr "" -#: ../doc/strings.py:35 -msgid "" -"In order to use TWBlue, you must first have created an account on the " -"Twitter website. The process for signing up for a Twitter account is very" -" accessible. During the account registration, you will need to choose a " -"Twitter username. This serves two purposes. This is the method through " -"which people will comunicate with you, but most importantly, your " -"username and password will be required to connect TWBlue to your Twitter " -"account. We suggest you choose a username which is memorable both to you " -"and the people you hope will follow you." +#: documentation/source/atprotosocial.rst +msgid "Key Features" msgstr "" -#: ../doc/strings.py:37 -msgid "" -"We'll start from the premise that you have a Twitter account with its " -"corresponding username and password." +#: documentation/source/atprotosocial.rst +msgid "Once your ATProtoSocial account is connected, you can use the following features in TWBlue:" msgstr "" -#: ../doc/strings.py:39 -msgid "### Authorising the application" +#: documentation/source/atprotosocial.rst +msgid "Posting" msgstr "" -#: ../doc/strings.py:41 -msgid "" -"First of all, it's necessary to authorise the program so it can access " -"your Twitter account and act on your behalf. The authorisation process is" -" quite simple, and the program never retains data such as your password. " -"In order to authorise the application, you just need to run the main " -"executable file, called TWBlue.exe (on some computers it may appear " -"simply as TWBlue if Windows Explorer is not set to display file " -"extensions). We suggest you may like to place a Windows shortcut on your " -"Desktop pointing to this executable file for quick and easy location." +#: documentation/source/atprotosocial.rst +msgid "Create new posts (often called \"skeets\") with text, images, and specify language." msgstr "" -#: ../doc/strings.py:43 -msgid "" -"You can log into several Twitter accounts simultaneously. The program " -"refers to each Twitter account you have configured as a \"Session\". If " -"this is the first time you have launched TWBlue, and if no Twitter " -"session exists, you will see the Session Manager. This dialogue box " -"allows you to authorise as many accounts as you wish. If you press the " -"Tab key to reach the \"new account\" button and activate it by pressing " -"the Space Bar, a dialogue box will advise you that your default internet " -"browser will be opened in order to authorise the application and you will" -" be asked if you would like to continue. Activate the \"yes\" Button by " -"pressing the letter \"Y\" so the process may start." +#: documentation/source/atprotosocial.rst +msgid "Timelines" msgstr "" -#: ../doc/strings.py:45 -msgid "" -"Your default browser will open on the Twitter page to request " -"authorisation. Enter your username and password into the appropriate edit" -" fields if you're not already logged in, select the authorise button, and" -" press it." +#: documentation/source/atprotosocial.rst +msgid "Home Timeline (Skyline)" msgstr "" -#: ../doc/strings.py:47 -msgid "" -"Once you've authorised your twitter account, the website will redirect " -"you to a page which will notify you that TWBlue has been authorised " -"successfully. On this page, you will be shown a code composed of several " -"numbers that you must paste in the TWBlue authorization dialogue in order" -" to allow the application to access your account. Once you have pasted " -"the code in the corresponding text field, press enter to finish the " -"account setup and go back to the session manager. On the session list, " -"you will see a new item temporarily called \"Authorised account x\" " -"-where x is a number. The session name will change once you open that " -"session." +#: documentation/source/atprotosocial.rst +msgid "View posts from users you follow." msgstr "" -#: ../doc/strings.py:49 -msgid "" -"To start running TWBlue, press the Ok button in the Session Manager " -"dialogue. By default, the program starts all the configured sessions " -"automatically, however, you can change this behavior." +#: documentation/source/atprotosocial.rst +msgid "User Timelines" msgstr "" -#: ../doc/strings.py:51 -msgid "" -"If all went well, the application will start playing sounds, indicating " -"your data is being updated." +#: documentation/source/atprotosocial.rst +msgid "View posts from specific users." msgstr "" -#: ../doc/strings.py:53 -msgid "" -"When the process is finished, by default the program will play another " -"sound, and the screen reader will say \"ready\" (this behaviour can be " -"configured)." +#: documentation/source/atprotosocial.rst +msgid "Mentions & Replies" msgstr "" -#: ../doc/strings.py:55 -msgid "## General concepts" +#: documentation/source/atprotosocial.rst +msgid "These will appear in your Notifications." msgstr "" -#: ../doc/strings.py:57 -msgid "" -"Before starting to describe TWBlue's usage, we'll explain some concepts " -"that will be used extensively throughout this manual." +#: documentation/source/atprotosocial.rst +msgid "Notifications" msgstr "" -#: ../doc/strings.py:59 -msgid "### Buffer" +#: documentation/source/atprotosocial.rst +msgid "Receive notifications for likes, reposts, follows, mentions, replies, and quotes." msgstr "" -#: ../doc/strings.py:61 -msgid "" -"A buffer is a list of items to manage the data which arrives from " -"Twitter, after being processed by the application. When you configure a " -"new session on TWBlue and start it, many buffers are created. Each of " -"them may contain some of the items which this program works with: Tweets," -" direct messages, users, trends or. According to the buffer you are " -"focusing, you will be able to do different actions with these items." +#: documentation/source/atprotosocial.rst +msgid "User Actions" msgstr "" -#: ../doc/strings.py:63 -msgid "" -"The following is a description for every one of TWBlue's buffers and the " -"kind of items they work with." +#: documentation/source/atprotosocial.rst +msgid "Follow and unfollow users." msgstr "" -#: ../doc/strings.py:65 -msgid "" -"* Home: this shows all the tweets on the main timeline. These are the " -"tweets by users you follow." +#: documentation/source/atprotosocial.rst +msgid "Mute and unmute users." msgstr "" -#: ../doc/strings.py:66 -msgid "" -"* Mentions: if a user, whether you follow them or not, mentions you on " -"Twitter, you will find it in this list." +#: documentation/source/atprotosocial.rst +msgid "Block and unblock users (blocking is done on your PDS/server)." msgstr "" -#: ../doc/strings.py:67 -msgid "" -"* Direct messages: here you will find the private direct messages you " -"exchange with users who follow you , or with any user, if you allow " -"direct messages from everyone (this setting is configurable from " -"Twitter). This list only shows received messages." +#: documentation/source/atprotosocial.rst +msgid "Quoting Posts" msgstr "" -#: ../doc/strings.py:68 -msgid "" -"* Sent direct messages: this buffer shows all the direct messages sent " -"from your account." +#: documentation/source/atprotosocial.rst +msgid "Quote other users' posts when you create a new post." msgstr "" -#: ../doc/strings.py:69 -msgid "* Sent tweets: this shows all the tweets sent from your account." +#: documentation/source/atprotosocial.rst +msgid "User Search" msgstr "" -#: ../doc/strings.py:70 -msgid "* Likes: here you will see all the tweets you have liked." +#: documentation/source/atprotosocial.rst +msgid "Search for users by their handle or display name." msgstr "" -#: ../doc/strings.py:71 -msgid "" -"* Followers: when users follow you, you'll be able to see them on this " -"buffer, with some of their account details." +#: documentation/source/atprotosocial.rst +msgid "Content Warnings" msgstr "" -#: ../doc/strings.py:72 -msgid "" -"* Friends: the same as the previous buffer, but these are the users you " -"follow." +#: documentation/source/atprotosocial.rst +msgid "Create posts with content warnings (sensitive content labels)." msgstr "" -#: ../doc/strings.py:73 -msgid "" -"* User timelines: these are buffers you may create. They contain only the" -" tweets by a specific user. They're used so you can see the tweets by a " -"single person and you don't want to look all over your timeline. You may " -"create as many as you like." +#: documentation/source/atprotosocial.rst +msgid "Basic Concepts for ATProtoSocial" msgstr "" -#: ../doc/strings.py:74 -msgid "" -"* Lists: A list is similar to a user timeline, except that you can " -"configure it to contain tweets from multiple users." +#: documentation/source/atprotosocial.rst +msgid "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." msgstr "" -#: ../doc/strings.py:75 -msgid "* Search: A search buffer contains the results of a search operation." +#: documentation/source/basic_concepts.rst +msgid "ATProtoSocial / Bluesky Specific Terms" msgstr "" -#: ../doc/strings.py:76 -msgid "" -"* User likes: You can have the program create a buffer containing tweets " -"liked by a particular user." +#: documentation/source/basic_concepts.rst +msgid "When using the ATProtoSocial (Bluesky) integration, you might encounter these terms:" msgstr "" -#: ../doc/strings.py:77 -msgid "" -"* Followers or following timeline: You can have TWBlue create a buffer " -"containing all users who follow, or are followed by a specific user." +#: documentation/source/basic_concepts.rst +msgid "Handle" msgstr "" -#: ../doc/strings.py:78 -msgid "" -"* Trending Topics: a trend buffer shows the top ten most used terms in a " -"geographical region. This region may be a country or a city. Trends are " -"updated every five minutes." +#: documentation/source/basic_concepts.rst +msgid "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." msgstr "" -#: ../doc/strings.py:80 -msgid "" -"If a tweet contains a URL, you can press enter in the GUI or Control + " -"Windows + Enter in the invisible interface to open it. If it contains " -"video or audio, including live stream content, you can press Control + " -"Enter or Control + Windows + Alt + Enter to play it, respectively. TWBlue" -" will play a sound if the tweet contains video metadata or the \\#audio " -"hashtag, but there may be tweets which contain media without this. " -"Finally, if a tweet contains geographical information, you can press " -"Control + Windows + G in the invisible interface to retrieve it." +#: documentation/source/basic_concepts.rst +msgid "App Password" msgstr "" -#: ../doc/strings.py:82 -msgid "### Username fields" +#: documentation/source/basic_concepts.rst +msgid "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." msgstr "" -#: ../doc/strings.py:84 -msgid "" -"These fields accept a Twitter username (without the at sign) as the " -"input. They are present in the send direct message, the user actions " -"dialogue and the user alias dialogue boxes, to name a few examples. Those" -" dialogues will be discussed later. The initial value of these fields " -"depends on where they were opened from. They are prepopulated with the " -"username of the sender of the focused tweet (if they were opened from the" -" home and sent timelines, from users' timelines or from lists), the " -"sender of the focused direct message (if from the received or sent direct" -" message buffers) or in the focused user (if from the followers' or " -"friends' buffer). If one of those dialogue boxes is opened from a tweet, " -"and if there are more users mentioned in it, you can use the arrow keys " -"to switch between them. Alternatively, you can also type a username." +#: documentation/source/basic_concepts.rst +msgid "DID (Decentralized Identifier)" msgstr "" -#: ../doc/strings.py:86 -msgid "## The program's interfaces" +#: documentation/source/basic_concepts.rst +msgid "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." msgstr "" -#: ../doc/strings.py:88 -msgid "### The graphical user interface (GUI)" +#: documentation/source/basic_concepts.rst +msgid "Skyline" msgstr "" -#: ../doc/strings.py:90 -msgid "The graphical user interface of TWBlue consists of a window containing:" +#: documentation/source/basic_concepts.rst +msgid "This is the term Bluesky uses for your main home timeline, showing posts from people you follow." msgstr "" -#: ../doc/strings.py:92 -msgid "" -"* a menu bar accomodating six menus (application, tweet, user, buffer, " -"audio and help);" +#: documentation/source/basic_concepts.rst +msgid "Skeet" msgstr "" -#: ../doc/strings.py:93 -msgid "* One tree view," +#: documentation/source/basic_concepts.rst +msgid "An informal term for a post on Bluesky (akin to a \"tweet\" on Twitter)." msgstr "" - -#: ../doc/strings.py:94 -msgid "* One list of items" -msgstr "" - -#: ../doc/strings.py:95 -msgid "* Four buttons in most dialogs: Tweet, retweet , reply and direct message." -msgstr "" - -#: ../doc/strings.py:97 -msgid "The actions that are available for every item will be described later." -msgstr "" - -#: ../doc/strings.py:99 -msgid "" -"In summary, the GUI contains two core components. These are the controls " -"you will find while pressing the Tab key within the program's interface, " -"and the different elements present on the menu bar." -msgstr "" - -#: ../doc/strings.py:101 -msgid "#### Buttons in the application" -msgstr "" - -#: ../doc/strings.py:103 -msgid "" -"* Tweet: this button opens up a dialogue box to write your tweet. Normal " -"tweets must not exceed 280 characters. However you can press the long " -"tweet checkbox and your tweet will be posted throught Twishort, wich will" -" allow you to write longer tweets (10000 characters). If you write past " -"this limit, a sound will play to warn you. Note that the character count " -"is displayed in the title bar. You can upload a picture, check spelling, " -"attach audio or translate your message by selecting one of the available " -"buttons in the dialogue box. In addition, you can autocomplete the " -"entering of users by pressing Alt + C or the button for that purpose if " -"you have the database of users configured. Press enter to send the tweet." -" If all goes well, you'll hear a sound confirming it. Otherwise, the " -"screen reader will speak an error message in English describing the " -"problem." -msgstr "" - -#: ../doc/strings.py:104 -msgid "" -"* Retweet: this button retweets the message you're reading. After you " -"press it, if you haven't configured the application not to do so, you'll " -"be asked if you want to add a comment or simply send it as written. If " -"you choose to add a comment, it will post a quoted tweet, that is, the " -"comment with a link to the originating tweet." -msgstr "" - -#: ../doc/strings.py:105 -msgid "" -"* Reply: when you're viewing a tweet, you can reply to the user who sent " -"it by pressing this button. A dialogue will open up similar to the one " -"for tweeting. If there are more users referred to in the tweet, you can " -"press tab and activate the mention to all checkbox, or enabling checkbox " -"for the users you want to mention separately. Note, however, that " -"sometimes -especially when replying to a retweet or quoted tweet, the " -"user who made the retweet or quote may also be mentioned. This is done by" -" Twitter automatically. When you're on the friends or followers lists, " -"the button will be called mention instead." -msgstr "" - -#: ../doc/strings.py:106 -msgid "" -"* Direct message: exactly like sending a tweet, but it's a private " -"message which can only be read by the user you send it to. Press shift-" -"tab twice to see the recipient. If there were other users mentioned in " -"the tweet you were reading, you can arrow up or down to choose which one " -"to send it to, or write the username yourself without the at sign. In " -"addition, you can autocomplete the entering of users by pressing Alt + C " -"or the button for that purpose if you have the database of users " -"configured." -msgstr "" - -#: ../doc/strings.py:108 -msgid "" -"Bear in mind that buttons will appear according to which actions are " -"possible on the list you are browsing. For example, on the home timeline," -" mentions, sent, likes and user timelines you will see the four buttons, " -"while on the direct messages list you'll only get the direct message and " -"tweet buttons, and on friends and followers lists the direct message, " -"tweet, and mention buttons will be available." -msgstr "" - -#: ../doc/strings.py:110 -msgid "#### Menus" -msgstr "" - -#: ../doc/strings.py:112 -msgid "" -"Visually, Towards the top of the main application window, can be found a " -"menu bar which contains many of the same functions as listed in the " -"previous section, together with some additional items. To access the menu" -" bar, press the alt key. You will find six menus listed: application, " -"tweet, user, buffer, audio and help. This section describes the items on " -"each one of them." -msgstr "" - -#: ../doc/strings.py:114 -msgid "##### Application menu" -msgstr "" - -#: ../doc/strings.py:116 -msgid "" -"* Manage accounts: Opens a window with all the sessions configured in " -"TWBlue, where you can add new sessions or delete the ones you've already " -"created." -msgstr "" - -#: ../doc/strings.py:117 -msgid "" -"* Update profile: opens a dialogue where you can update your information " -"on Twitter: name, location, website and bio. If you have already set this" -" up the fields will be prefilled with the existing information. Also, you" -" can upload a photo to your profile." -msgstr "" - -#: ../doc/strings.py:118 -msgid "" -"* Hide window: turns off the Graphical User Interface. Read the section " -"on the invisible interface for further details." -msgstr "" - -#: ../doc/strings.py:119 -msgid "" -"* Search: shows a dialogue box where you can search for tweets or users " -"on Twitter." -msgstr "" - -#: ../doc/strings.py:120 -msgid "" -"* Lists Manager: This dialogue box allows you to manage your Twitter " -"lists. In order to use them, you must first create them. Here, you can " -"view, edit, create, delete or, optionally, open them in buffers similar " -"to user timelines." -msgstr "" - -#: ../doc/strings.py:121 -msgid "" -"* Manage user aliases: Opens up a dialogue where you can manage user " -"aliases for the active session. In this dialog you can add new aliases, " -"as well as edit and delete existing ones." -msgstr "" - -#: ../doc/strings.py:122 -msgid "" -"* Edit keystrokes: this opens a dialogue where you can see and edit the " -"shortcuts used in the invisible interface." -msgstr "" - -#: ../doc/strings.py:123 -msgid "" -"* Account settings: Opens a dialogue box which lets you customize " -"settings for the current account." -msgstr "" - -#: ../doc/strings.py:124 -msgid "" -"* Global settings: Opens a dialogue which lets you configure settings for" -" the entire application." -msgstr "" - -#: ../doc/strings.py:125 -msgid "" -"* Exit: asks whether you want to exit the program. If the answer is yes, " -"it closes the application. If you do not want to be asked for " -"confirmation before exiting, uncheck the checkbox from the global " -"settings dialogue box." -msgstr "" - -#: ../doc/strings.py:127 -msgid "##### Tweet menu" -msgstr "" - -#: ../doc/strings.py:129 -msgid "" -"* You will first find the items to tweet, reply and retweet, which are " -"equivalent to the buttons with the same name." -msgstr "" - -#: ../doc/strings.py:130 -msgid "* Like: Adds the tweet you're viewing to your likes list." -msgstr "" - -#: ../doc/strings.py:131 -msgid "* Unlike: removes the tweet from your likes, but not from Twitter." -msgstr "" - -#: ../doc/strings.py:132 -msgid "" -"* Show tweet: opens up a dialogue box where you can read the tweet, " -"direct message, friend or follower which has focus. You can read the text" -" with the arrow keys. It's a similar dialog box as used for composing " -"tweets, without the ability to send the tweet, file attachment and " -"autocompleting capabilities. It does however include a retweets and likes" -" count. If you are in the followers or the friends list, it will only " -"contain a read-only edit box with the information in the focused item and" -" a close button." -msgstr "" - -#: ../doc/strings.py:133 -msgid "" -"* View address: If the selected tweet has geographical information, " -"TWBlue may display a dialogue box where you can read the tweet address. " -"This address is retrieved by sending the geographical coordinates of the " -"tweet to Google maps." -msgstr "" - -#: ../doc/strings.py:134 -msgid "" -"* View conversation: If you are focusing a tweet with a mention, it opens" -" a buffer where you can view the whole conversation." -msgstr "" - -#: ../doc/strings.py:135 -msgid "" -"* Read text in pictures: Attempt to apply OCR technology to the image " -"attached to the tweet. The result will be displayed in another dialog." -msgstr "" - -#: ../doc/strings.py:136 -msgid "" -"* Delete: permanently removes the tweet or direct message which has focus" -" from Twitter and from your lists. Bear in mind that Twitter only allows " -"you to delete tweets you have posted yourself." -msgstr "" - -#: ../doc/strings.py:138 -msgid "##### User menu" -msgstr "" - -#: ../doc/strings.py:140 -msgid "" -"* Actions: Opens a dialogue where you can interact with a user. This " -"dialogue box will be populated with the user who sent the tweet or direct" -" message in focus or the selected user in the friends or followers " -"buffer. You can edit it or leave it as is and choose one of the following" -" actions:" -msgstr "" - -#: ../doc/strings.py:141 -msgid "" -" * Follow: Follows a user. This means you'll see his/her tweets on your " -"home timeline, and if he/she also follows you, you'll be able to exchange" -" direct messages. You may also send / receive direct messages from each " -"other if you have configured the option to allow direct messages from " -"anyone." -msgstr "" - -#: ../doc/strings.py:142 -msgid "" -" * Unfollow: Stops following a user, which causes you not being able to " -"see his/her tweets on your main timeline neither exchanging direct " -"messages, unless they have enabled receiving direct messages from anyone." -msgstr "" - -#: ../doc/strings.py:143 -msgid "" -" * Mute: While muting someone, TWBlue won't show you nor his/her tweets " -"on your main timeline; neither will you see that person's mentions. But " -"you both will be able to exchange direct messages. The muted user is not " -"informed of this action." -msgstr "" - -#: ../doc/strings.py:144 -msgid "" -" * Unmute: this option allows TWBlue to display the user's tweets and " -"mentions again." -msgstr "" - -#: ../doc/strings.py:145 -msgid " * Block: Blocks a user. This forces the user to unfollow you ." -msgstr "" - -#: ../doc/strings.py:146 -msgid " * Unblock: Stops blocking a user." -msgstr "" - -#: ../doc/strings.py:147 -msgid "" -" * Report as spam: this option sends a message to Twitter suggesting the " -"user is performing prohibited practices on the social network." -msgstr "" - -#: ../doc/strings.py:148 -msgid "" -" * Ignore tweets from this client: Adds the client from which the focused" -" tweet was sent to the ignored clients list." -msgstr "" - -#: ../doc/strings.py:149 -msgid "" -"* View timeline: Lets you open a user's timeline by choosing the user in " -"a dialog box. It is created when you press enter. If you invoke this " -"option relative to a user that has no tweets, the operation will fail. If" -" you try creating an existing timeline the program will warn you and will" -" not create it again." -msgstr "" - -#: ../doc/strings.py:150 -msgid "* Direct message: same action as the button." -msgstr "" - -#: ../doc/strings.py:151 -msgid "" -"* Add Alias: An user alias allows you to rename user's display names on " -"Twitter, so the next time you'll read an user it will be announced as you" -" configured. This feature works only if you have set display screen names" -" unchecked, in account settings." -msgstr "" - -#: ../doc/strings.py:152 -msgid "" -"* Add to List: In order to see someone's tweets in one or more of your " -"lists, you must add them first. In the dialogue box that opens after " -"selecting the user, you will be asked to select the list you wish to add " -"the user to. Thereafter, the list will contain a new member and their " -"tweets will be displayed there." -msgstr "" - -#: ../doc/strings.py:153 -msgid "* Remove from list: lets you remove a user from a list." -msgstr "" - -#: ../doc/strings.py:154 -msgid "* View lists: Shows the lists created by a specified user." -msgstr "" - -#: ../doc/strings.py:155 -msgid "" -"* Show user profile: opens a dialogue with the profile of the specified " -"user." -msgstr "" - -#: ../doc/strings.py:156 -msgid "" -"* View likes: Opens a buffer where you can see the tweets which have been" -" liked by a particular user." -msgstr "" - -#: ../doc/strings.py:158 -msgid "##### Buffer menu" -msgstr "" - -#: ../doc/strings.py:160 -msgid "" -"* Update buffer: Retrieves the newest items for the focused buffer. " -"Normally, every buffer gets updated every couple of minutes, however you " -"can force a specific buffer to be updated inmediately. Take into account," -" however, that the usage of this option repeatedly might exceed your " -"allowed Twitter's API usage, in which case you would have to wait until " -"it gets reset, tipycally within the next 15 minutes." -msgstr "" - -#: ../doc/strings.py:161 -msgid "" -"* New trending topics buffer: This opens a buffer to get the worlwide " -"trending topics or those of a country or a city. You'll be able to select" -" from a dialogue box if you wish to retrieve countries' trends, cities' " -"trends or worldwide trends (this option is in the cities' list) and " -"choose one from the selected list. The trending topics buffer will be " -"created once the \"OK\" button has been activated within the dialogue " -"box. Remember this kind of buffer will be updated every five minutes." -msgstr "" - -#: ../doc/strings.py:162 -msgid "" -"* Load previous items: This allows more items to be loaded for the " -"specified buffer." -msgstr "" - -#: ../doc/strings.py:163 -msgid "" -"* Create filter: Creates a filter in the current buffer. Filters allow " -"loading or ignoring tweets that meet certain conditions into a buffer. " -"You can, for example, set a filter in the \"home\" buffer that loads " -"tweets that are in English language only. By default, the filter creation" -" dialog will place the focus on the field to name the filter. Currently, " -"you can filter by word, by language, or both. In the filter by word, you " -"can make TWBlue allow or ignore tweets with the desired word. In the " -"filter by language, you can make the program load tweets in the languages" -" you want, or ignore tweets written in certain languages. Once created, " -"every filter will be saved in the session config and will be kept across " -"application restarts." -msgstr "" - -#: ../doc/strings.py:164 -msgid "" -"* Manage filters: Opens up a dialogue which allows you to delete filters " -"for the current session." -msgstr "" - -#: ../doc/strings.py:165 -msgid "" -"* Find a string in the currently focused buffer: Opens a dialogue where " -"you can search for items in the current buffer." -msgstr "" - -#: ../doc/strings.py:166 -msgid "" -"* Mute: Mutes notifications of a particular buffer so you will not hear " -"when new tweets arrive." -msgstr "" - -#: ../doc/strings.py:167 -msgid "" -"* autoread: When enabled, the screen reader or SAPI 5 Text to Speech " -"voice (if enabled) will read the text of incoming tweets. Please note " -"that this could get rather chatty if there are a lot of incoming tweets." -msgstr "" - -#: ../doc/strings.py:168 -msgid "* Clear buffer: Deletes all items from the buffer." -msgstr "" - -#: ../doc/strings.py:169 -msgid "* Destroy: dismisses the list you're on." -msgstr "" - -#: ../doc/strings.py:171 -msgid "##### Audio menu" -msgstr "" - -#: ../doc/strings.py:173 -msgid "" -"* Play/pause: try to play audio for the selected item (if available), or " -"stop the currently played audio." -msgstr "" - -#: ../doc/strings.py:174 -msgid "" -"* Seek back 5 seconds: If an audio is being played, seek 5 seconds back " -"in the playback. This will work only in audio files. This feature cannot " -"be used in radio stations or other streamed files." -msgstr "" - -#: ../doc/strings.py:175 -msgid "" -"* Seek forward 5 seconds: If an audio is being played, seek 5 seconds " -"forward in the playback. This feature cannot be used in radio stations " -"or other streamed files." -msgstr "" - -#: ../doc/strings.py:177 -msgid "##### Help menu" -msgstr "" - -#: ../doc/strings.py:179 -msgid "" -"* Documentation: opens up this file, where you can read some useful " -"program concepts." -msgstr "" - -#: ../doc/strings.py:180 -msgid "" -"* Sounds tutorial: Opens a dialog box where you can familiarize yourself " -"with the different sounds of the program." -msgstr "" - -#: ../doc/strings.py:181 -msgid "" -"* What's new in this version?: opens up a document with the list of " -"changes from the current version to the earliest." -msgstr "" - -#: ../doc/strings.py:182 -msgid "" -"* Check for updates: every time you open the program it automatically " -"checks for new versions. If an update is available, it will ask you if " -"you want to download the update. If you accept, the updating process will" -" commence. When complete, TWBlue will be restarted. This item checks for " -"new updates without having to restart the application." -msgstr "" - -#: ../doc/strings.py:183 -msgid "" -"* TWBlue's website: visit our [home page](http://twblue.es) where you can" -" find all relevant information and downloads for TWBlue and become a part" -" of the community." -msgstr "" - -#: ../doc/strings.py:184 -msgid "* Get soundpacks for TWBlue: " -msgstr "" - -#: ../doc/strings.py:185 -msgid "" -"* Make a Donation: Opens a website from which you can donate to the " -"TWBlue project. Donations are made through paypal and you don't need an " -"account to donate." -msgstr "" - -#: ../doc/strings.py:186 -msgid "* About TWBlue: shows the credits of the program." -msgstr "" - -#: ../doc/strings.py:188 -msgid "### The invisible user interface" -msgstr "" - -#: ../doc/strings.py:190 -msgid "" -"The invisible interface, as its name suggests, has no graphical window " -"and works directly with screen readers such as JAWS for Windows, NVDA and" -" System Access. This interface is disabled by default, but you can enable" -" it by pressing Control + M. It works similarly to TheQube and Chicken " -"Nugget. Its shortcuts are similar to those found in these two clients. In" -" addition, the program has builtin support for the keymaps for these " -"applications, configurable through the global settings dialogue. By " -"default, you cannot use this interface's shortcuts in the GUI, but you " -"can configure this in the global settings dialogue." -msgstr "" - -#: ../doc/strings.py:192 -msgid "" -"The next section contains a list of keyboard shortcuts for both " -"interfaces. Bear in mind that we will only describe the default keymap." -msgstr "" - -#: ../doc/strings.py:194 -msgid "## Keyboard shortcuts" -msgstr "" - -#: ../doc/strings.py:196 -msgid "### Shortcuts of the graphical user interface (GUI)" -msgstr "" - -#: ../doc/strings.py:198 -msgid "* Enter: Open URL." -msgstr "" - -#: ../doc/strings.py:199 -msgid "* Control + Enter: Play audio." -msgstr "" - -#: ../doc/strings.py:200 -msgid "* Control + M: Hide the GUI." -msgstr "" - -#: ../doc/strings.py:201 -msgid "* Control + N: Compose a new tweet." -msgstr "" - -#: ../doc/strings.py:202 -msgid "* Control + R: Reply / mention." -msgstr "" - -#: ../doc/strings.py:203 -msgid "* Control + Shift + R: Retweet." -msgstr "" - -#: ../doc/strings.py:204 -msgid "* Control + D: Send a direct message." -msgstr "" - -#: ../doc/strings.py:205 -msgid "* control + F: Add tweet to likes." -msgstr "" - -#: ../doc/strings.py:206 -msgid "* Control + Shift + F: Remove a tweet from likes." -msgstr "" - -#: ../doc/strings.py:207 -msgid "* Control + S: Open the user actions dialogue." -msgstr "" - -#: ../doc/strings.py:208 -msgid "* Control + Shift + V: Show tweet." -msgstr "" - -#: ../doc/strings.py:209 -msgid "* Control + Q: Quit this program." -msgstr "" - -#: ../doc/strings.py:210 -msgid "* Control + I: Open user timeline." -msgstr "" - -#: ../doc/strings.py:211 -msgid "* Control + Shift + i: Destroy buffer." -msgstr "" - -#: ../doc/strings.py:212 -msgid "* F5: Increase volume by 5%." -msgstr "" - -#: ../doc/strings.py:213 -msgid "* F6: Decrease volume by 5%." -msgstr "" - -#: ../doc/strings.py:214 -msgid "* Control + P: Edit your profile." -msgstr "" - -#: ../doc/strings.py:215 -msgid "* Control + Delete: Delete a tweet or direct message." -msgstr "" - -#: ../doc/strings.py:216 -msgid "* Control + Shift + Delete: Empty the current buffer." -msgstr "" - -#: ../doc/strings.py:218 -msgid "### Shortcuts of the invisible interface (default keymap)" -msgstr "" - -#: ../doc/strings.py:220 -msgid "" -"The invisible interface of TWBlue can be customised by using a keymap. " -"Every keymap defines a set of keystrokes to be used along with the " -"invisible interface. You can change the keymap in the global settings " -"dialogue, under the application menu in the menu bar, and check or edit " -"keystrokes for the selected keymap in the keystroke editor, also " -"available in the application menu." -msgstr "" - -#: ../doc/strings.py:222 -msgid "* Control + Windows + Up Arrow: moves to the previous item in the buffer." -msgstr "" - -#: ../doc/strings.py:223 -msgid "* Control + Windows + Down Arrow: moves to the next item in the buffer." -msgstr "" - -#: ../doc/strings.py:224 -msgid "* Control + Windows + Left Arrow: Move to the previous buffer." -msgstr "" - -#: ../doc/strings.py:225 -msgid "* Control + Windows + Right Arrow: Move to the next buffer." -msgstr "" - -#: ../doc/strings.py:226 -msgid "* Control + Windows + Shift + Left: Focus the previous session." -msgstr "" - -#: ../doc/strings.py:227 -msgid "* Control + Windows + Shift + Right: Focus the next session." -msgstr "" - -#: ../doc/strings.py:228 -msgid "* Control + Windows + C: View conversation." -msgstr "" - -#: ../doc/strings.py:229 -msgid "* Control + Windows + Enter: Open URL." -msgstr "" - -#: ../doc/strings.py:230 -msgid "* Control + Windows + ALT + Enter: Play audio." -msgstr "" - -#: ../doc/strings.py:231 -msgid "* Control + Windows + M: Show or hide the GUI." -msgstr "" - -#: ../doc/strings.py:232 -msgid "* Control + Windows + N: New tweet." -msgstr "" - -#: ../doc/strings.py:233 -msgid "* Control + Windows + R: Reply / Mention." -msgstr "" - -#: ../doc/strings.py:234 -msgid "* Control + Windows + Shift + R: Retweet." -msgstr "" - -#: ../doc/strings.py:235 -msgid "* Control + Windows + D: Send direct message." -msgstr "" - -#: ../doc/strings.py:236 -msgid "* Windows+ Alt + F: Like a tweet." -msgstr "" - -#: ../doc/strings.py:237 -msgid "* Alt + Windows + Shift + F: Remove from likes." -msgstr "" - -#: ../doc/strings.py:238 -msgid "* Control + Windows + S: Open the user actions dialogue." -msgstr "" - -#: ../doc/strings.py:239 -msgid "* Control + Windows + Alt + N: See user details." -msgstr "" - -#: ../doc/strings.py:240 -msgid "* Control + Windows + V: Show tweet." -msgstr "" - -#: ../doc/strings.py:241 -msgid "* Control + Windows + F4: Quit TWBlue." -msgstr "" - -#: ../doc/strings.py:242 -msgid "* Control + Windows + I: Open user timeline." -msgstr "" - -#: ../doc/strings.py:243 -msgid "* Control + Windows + Shift + I: Destroy buffer." -msgstr "" - -#: ../doc/strings.py:244 -msgid "* Control + Windows + Alt + Up: Increase volume by 5%." -msgstr "" - -#: ../doc/strings.py:245 -msgid "* Control + Windows + Alt + Down: Decrease volume by 5%." -msgstr "" - -#: ../doc/strings.py:246 -msgid "" -"* Control + Windows + Home: Jump to the first element of the current " -"buffer." -msgstr "" - -#: ../doc/strings.py:247 -msgid "* Control + Windows + End: Jump to the last element of the current buffer." -msgstr "" - -#: ../doc/strings.py:248 -msgid "* Control + Windows + PageUp: Jump 20 elements up in the current buffer." -msgstr "" - -#: ../doc/strings.py:249 -msgid "" -"* Control + Windows + PageDown: Jump 20 elements down in the current " -"buffer." -msgstr "" - -#: ../doc/strings.py:250 -msgid "* Windows + Alt + P: Edit profile." -msgstr "" - -#: ../doc/strings.py:251 -msgid "* Control + Windows + Delete: Delete a tweet or direct message." -msgstr "" - -#: ../doc/strings.py:252 -msgid "* Control + Windows + Shift + Delete: Empty the current buffer." -msgstr "" - -#: ../doc/strings.py:253 -msgid "* Control + Windows + Space: Repeat last item." -msgstr "" - -#: ../doc/strings.py:254 -msgid "* Control + Windows + Shift + C: Copy to clipboard." -msgstr "" - -#: ../doc/strings.py:255 -msgid "* Control + Windows+ A: Add user to list." -msgstr "" - -#: ../doc/strings.py:256 -msgid "* Control + Windows + Shift + A: Remove user from list." -msgstr "" - -#: ../doc/strings.py:257 -msgid "* Control + Windows + Shift + M: Mute / unmute the current buffer." -msgstr "" - -#: ../doc/strings.py:258 -msgid "* Windows + Alt + M: Mute / unmute the current session." -msgstr "" - -#: ../doc/strings.py:259 -msgid "" -"* Control + Windows + E: Toggle the automatic reading of incoming tweets " -"in the current buffer." -msgstr "" - -#: ../doc/strings.py:260 -msgid "* Control + Windows + -: Search on Twitter." -msgstr "" - -#: ../doc/strings.py:261 -msgid "* Control + Windows + K: Show the keystroke editor." -msgstr "" - -#: ../doc/strings.py:262 -msgid "* Control + Windows + L: Show lists for a specified user." -msgstr "" - -#: ../doc/strings.py:263 -msgid "* Windows + Alt + PageUp: Load previous items for the current buffer." -msgstr "" - -#: ../doc/strings.py:264 -msgid "* Control + Windows + G: Get geolocation." -msgstr "" - -#: ../doc/strings.py:265 -msgid "" -"* Control + Windows + Shift + G: Display the tweet's geolocation in a " -"dialogue." -msgstr "" - -#: ../doc/strings.py:266 -msgid "* Control + Windows + T: Create a trending topics' buffer." -msgstr "" - -#: ../doc/strings.py:267 -msgid "* Control + Windows + {: Find a string in the current buffer." -msgstr "" - -#: ../doc/strings.py:268 -msgid "" -"* Alt + Windows + O: Extracts text from the picture and display the " -"result in a dialog." -msgstr "" - -#: ../doc/strings.py:270 -msgid "## Configuration" -msgstr "" - -#: ../doc/strings.py:272 -msgid "" -"As described above, this application has two configuration dialogues, the" -" global settings dialogue and the account settings dialogue." -msgstr "" - -#: ../doc/strings.py:274 -msgid "### The account settings dialogue" -msgstr "" - -#: ../doc/strings.py:276 -msgid "#### General tab" -msgstr "" - -#: ../doc/strings.py:278 -msgid "" -"* Autocompletion settings: Allows you to configure the autocompletion " -"database. You can add users manually or let TWBlue add your followers, " -"friends or both." -msgstr "" - -#: ../doc/strings.py:279 -msgid "" -"* Relative timestamps: Allows you to configure whether the application " -"will calculate the time the tweet or direct message was sent or received " -"based on the current time, or simply say the time it was received or " -"sent." -msgstr "" - -#: ../doc/strings.py:280 -msgid "" -"* API calls: Allows you to adjust the number of API calls to be made to " -"Twitter by this program." -msgstr "" - -#: ../doc/strings.py:281 -msgid "" -"* Items on each API call: Allows you to specify how many items should be " -"retrieved from Twitter for each API call (default and maximum is 200)." -msgstr "" - -#: ../doc/strings.py:282 -msgid "" -"* Inverted buffers: Allows you to specify whether the buffers should be " -"inverted, which means that the oldest items will show at the end of them " -"and the newest at the beginning." -msgstr "" - -#: ../doc/strings.py:283 -msgid "" -"* Retweet mode: Allows you to specify the behaviour when posting a " -"retweet: you can choose between retweeting with a comment, retweeting " -"without comment or being asked." -msgstr "" - -#: ../doc/strings.py:284 -msgid "" -"* Number of items per buffer to cache in database: This allows you to " -"specify how many items TWBlue should cache in a database. You can type " -"any number, 0 to cache all items, or leave blank to disable caching " -"entirely." -msgstr "" - -#: ../doc/strings.py:286 -msgid "#### buffers tab" -msgstr "" - -#: ../doc/strings.py:288 -msgid "" -"This tab displays a list for each buffer you have available in TWBlue, " -"except for searches, timelines, likes' timelines and lists. You can show," -" hide and move them." -msgstr "" - -#: ../doc/strings.py:290 -msgid "#### The ignored clients tab" -msgstr "" - -#: ../doc/strings.py:292 -msgid "In this tab, you can add and remove clients to be ignored by the program." -msgstr "" - -#: ../doc/strings.py:294 -msgid "#### Sound tab" -msgstr "" - -#: ../doc/strings.py:296 -msgid "" -"In this tab, you can adjust the sound volume, select the input and output" -" device and set the soundpack used by the program." -msgstr "" - -#: ../doc/strings.py:298 -msgid "#### Audio service tab" -msgstr "" - -#: ../doc/strings.py:300 -msgid "" -"In this tab, you can enter your SndUp API key (if you have one) to upload" -" audio to SndUp with your account. Note that if account credentials are " -"not specified you will upload anonimously." -msgstr "" - -#: ../doc/strings.py:302 -msgid "### Global settings" -msgstr "" - -#: ../doc/strings.py:304 -msgid "" -"This dialogue allows you to configure some settings which will affect the" -" entire application." -msgstr "" - -#: ../doc/strings.py:306 -msgid "#### General tab {#general-tab_1}" -msgstr "" - -#: ../doc/strings.py:308 -msgid "" -"* Language: This allows you to change the language of this program. " -"Currently supported languages are arabic, Catalan, German, English, " -"Spanish, Basque, Finnish, French, Galician, Croatian, Hungarian, Italian," -" Polish, Portuguese, Russian and Turkish." -msgstr "" - -#: ../doc/strings.py:309 -msgid "" -"* Ask before exiting TWBlue: This checkbox allows you to control " -"whetherthe program will ask for confirmation before exiting." -msgstr "" - -#: ../doc/strings.py:310 -msgid "" -"* Play a sound when TWBlue launches: This checkbox allows you to " -"configure whether the application will play a sound when it has finished " -"loading the buffers." -msgstr "" - -#: ../doc/strings.py:311 -msgid "" -"* Speak a message when TWBlue launches: This is the same as the previous " -"option, but this checkbox configures whether the screen reader will say " -"\"ready\"." -msgstr "" - -#: ../doc/strings.py:312 -msgid "" -"* Use the invisible interface's shortcuts in the GUI: As the invisible " -"interface and the Graphical User Interface have their own shortcuts, you " -"may want to use the invisible interface's keystrokes all the time. If " -"this option is checked, the invisible interface's shortcuts will be " -"usable in the GUI." -msgstr "" - -#: ../doc/strings.py:313 -msgid "" -"* Activate SAPI5 when any other screen reader is not being run: This " -"checkbox allows to activate SAPI 5 TTS when no other screen reader is " -"being run." -msgstr "" - -#: ../doc/strings.py:314 -msgid "" -"* Hide GUI on launch: This allows you to configure whether TWBlue will " -"start with the GUI or the invisible interface." -msgstr "" - -#: ../doc/strings.py:315 -msgid "" -"* Keymap: This option allows you to change the keymap used by the program" -" in the invisible interface. The shipped keymaps are Default, Qwitter, " -"Windows 10 and Chicken Nugget. The keymaps are in the \"keymaps\" folder," -" and you can create new ones. Just create a new \".keymap\" file and " -"change the keystrokes associated with the actions, as it is done in the " -"shipped keymaps." -msgstr "" - -#: ../doc/strings.py:317 -msgid "#### Proxi tab" -msgstr "" - -#: ../doc/strings.py:319 -msgid "" -"In this tab you can configure TWBlue to use a Proxy server by completing " -"the fields displayed (type, server, port, user and password)." -msgstr "" - -#: ../doc/strings.py:321 -msgid "## License, source code and donations" -msgstr "" - -#: ../doc/strings.py:323 -msgid "" -"Tw Blue is free software, licensed under the GNU GPL license, either " -"version 2 or, at your option, any later version. You can view the license" -" in the file named license.txt, or online at ." -msgstr "" - -#: ../doc/strings.py:325 -msgid "" -"The source code of the program is available on GitHub at " -"." -msgstr "" - -#: ../doc/strings.py:327 -msgid "" -"If you want to donate to the project, you can do so at " -". Thank you for your support!" -msgstr "" - -#: ../doc/strings.py:329 -msgid "## Contact" -msgstr "" - -#: ../doc/strings.py:331 -msgid "" -"If you still have questions after reading this document, if you wish to " -"collaborate to the project in some other way, or if you simply want to " -"get in touch with the application developer, follow the Twitter account " -"[@tw\\_blue2](https://twitter.com/tw_blue2) or " -"[@manuelcortez00.](https://twitter.com/manuelcortez00) You can also visit" -" [our website](https://twblue.es)" -msgstr "" - -#: ../doc/strings.py:333 -msgid "## Credits" -msgstr "" - -#: ../doc/strings.py:335 -msgid "" -"TWBlue is developed and maintained by [Manuel " -"Cortéz](https://twitter.com/manuelcortez00) and [José Manuel " -"Delicado](https://twitter.com/jmdaweb)." -msgstr "" - -#: ../doc/strings.py:337 -msgid "" -"We would also like to thank the translators of TWBlue, who have allowed " -"the spreading of the application." -msgstr "" - -#: ../doc/strings.py:339 -msgid "" -"* Arabic: [Mohammed Al Shara,](https://twitter.com/mohammed0204) [Hatoun " -"Felemban](https://twitter.com/HatounFelemban)" -msgstr "" - -#: ../doc/strings.py:340 -msgid "* Basque: [Sukil Etxenike](https://twitter.com/sukil2011)." -msgstr "" - -#: ../doc/strings.py:341 -msgid "* Catalan: [Francisco Torres](https://twitter.com/ftgalleg)" -msgstr "" - -#: ../doc/strings.py:342 -msgid "* Croatian: [Zvonimir Stanečić](https://twitter.com/zvonimirek222)." -msgstr "" - -#: ../doc/strings.py:343 -msgid "* English: [Manuel Cortéz](https://twitter.com/manuelcortez00)." -msgstr "" - -#: ../doc/strings.py:344 -msgid "* Finnish: [Jani Kinnunen](https://twitter.com/jani_kinnunen)." -msgstr "" - -#: ../doc/strings.py:345 -msgid "* French: [Rémy Ruiz](https://twitter.com/blindhelp38)." -msgstr "" - -#: ../doc/strings.py:346 -msgid "* Galician: [Juan Buño](https://twitter.com/Quetzatl_)." -msgstr "" - -#: ../doc/strings.py:347 -msgid "* German: [Steffen Schultz](https://twitter.com/schulle4u)." -msgstr "" - -#: ../doc/strings.py:348 -msgid "* Hungarian: Robert Osztolykan." -msgstr "" - -#: ../doc/strings.py:349 -msgid "* Italian: [Christian Leo Mameli](https://twitter.com/llajta2012)." -msgstr "" - -#: ../doc/strings.py:350 -msgid "* Japanese: [Riku](https://twitter.com/_riku02)" -msgstr "" - -#: ../doc/strings.py:351 -msgid "* Polish: [Pawel Masarczyk.](https://twitter.com/Piciok)" -msgstr "" - -#: ../doc/strings.py:352 -msgid "* Portuguese: [Odenilton Júnior Santos.](https://twitter.com/romaleif)" -msgstr "" - -#: ../doc/strings.py:353 -msgid "" -"* Romanian: [Florian Ionașcu](https://twitter.com/florianionascu7) and " -"[Nicușor Untilă](https://twitter.com/dj_storm2001)" -msgstr "" - -#: ../doc/strings.py:354 -msgid "" -"* Russian: [Наталья Хедлунд](https://twitter.com/Lifestar_n) and [Валерия" -" Кузнецова](https://twitter.com/ValeriaK305)." -msgstr "" - -#: ../doc/strings.py:355 -msgid "* Serbian: [Aleksandar Đurić](https://twitter.com/sokodtreshnje)" -msgstr "" - -#: ../doc/strings.py:356 -msgid "* Spanish: [Manuel Cortéz](https://twitter.com/manuelcortez00)." -msgstr "" - -#: ../doc/strings.py:357 -msgid "* Turkish: [Burak Yüksek](https://twitter.com/burakyuksek)." -msgstr "" - -#: ../doc/strings.py:359 -msgid "" -"Many thanks also to the people who worked on the documentation. " -"Initially, [Manuel Cortez](https://twitter.com/manuelcortez00) did the " -"documentation in Spanish, and translated to English by [Bryner " -"Villalobos](https://twitter.com/Bry_StarkCR), [Robert " -"Spangler](https://twitter.com/glasscity1837), [Sussan " -"Rey](https://twitter.com/sussanrey17), [Anibal " -"Hernandez](https://twitter.com/AnimalMetal), and [Holly Scott-" -"Gardner](https://twitter.com/CatchTheseWords). It was updated by [Sukil " -"Etxenike](https://twitter.com/sukil2011), with some valuable corrections " -"by [Brian Hartgen](https://twitter.com/brianhartgen) and [Bill " -"Dengler](https://twitter.com/codeofdusk)." -msgstr "" - -#: ../doc/strings.py:361 -msgid "------------------------------------------------------------------------" -msgstr "" - -#: ../doc/strings.py:363 -msgid "Copyright © 2013-2021. Manuel Cortéz" -msgstr "" - diff --git a/tools/twblue.pot b/tools/twblue.pot index a1992f85..412d1821 100644 --- a/tools/twblue.pot +++ b/tools/twblue.pot @@ -1,4422 +1,642 @@ # Translations template for PROJECT. -# Copyright (C) 2022 MCV software +# Copyright (C) 2024 MCV software # This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2022. +# FIRST AUTHOR , 2024. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: PROJECT VERSION\n" -"Report-Msgid-Bugs-To: manuel@manuelcortez.net\n" -"POT-Creation-Date: 2022-12-20 17:15-0600\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Generated-By: Babel 2.10.3\n" +"Project-Id-Version: TWBlue VERSION\\n" +"Report-Msgid-Bugs-To: manuel@manuelcortez.net\\n" +"POT-Creation-Date: 2024-05-26 12:00+0000\\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n" +"Last-Translator: FULL NAME \\n" +"Language-Team: LANGUAGE \\n" +"MIME-Version: 1.0\\n" +"Content-Type: text/plain; charset=utf-8\\n" +"Content-Transfer-Encoding: 8bit\\n" +"Generated-By: TWBlue Manual Process\\n" -#: ../src/languageHandler.py:61 -msgctxt "languageName" -msgid "Amharic" +#: src/sessions/atprotosocial/session.py +msgid "Invalid handle or app password." msgstr "" -#: ../src/languageHandler.py:62 -msgctxt "languageName" -msgid "Aragonese" +#: src/sessions/atprotosocial/session.py +msgid "Login failed: {error} - {message}" msgstr "" -#: ../src/languageHandler.py:63 -msgctxt "languageName" -msgid "Spanish" +#: src/sessions/atprotosocial/session.py +msgid "An unexpected error occurred during login: {error}" msgstr "" -#: ../src/languageHandler.py:64 -msgctxt "languageName" -msgid "Portuguese" +#: src/sessions/atprotosocial/session.py +msgid "Enter your Bluesky handle (e.g., username.bsky.social):" msgstr "" -#: ../src/languageHandler.py:65 -msgctxt "languageName" -msgid "Russian" +#: src/sessions/atprotosocial/session.py +msgid "Bluesky Login" msgstr "" -#: ../src/languageHandler.py:66 -msgctxt "languageName" -msgid "italian" +#: src/sessions/atprotosocial/session.py +msgid "Enter your Bluesky App Password (generate one in Bluesky settings):" msgstr "" -#: ../src/languageHandler.py:67 -msgctxt "languageName" -msgid "Turkey" +#: src/sessions/atprotosocial/session.py +msgid "Successfully logged into Bluesky!" msgstr "" -#: ../src/languageHandler.py:68 -msgctxt "languageName" -msgid "Galician" +#: src/sessions/atprotosocial/session.py +msgid "Login Success" msgstr "" -#: ../src/languageHandler.py:69 -msgctxt "languageName" -msgid "Catala" +#: src/sessions/atprotosocial/session.py +msgid "Login failed. Please check your handle and app password." msgstr "" -#: ../src/languageHandler.py:70 -msgctxt "languageName" -msgid "Vasque" +#: src/sessions/atprotosocial/session.py +msgid "Login Failed" msgstr "" -#: ../src/languageHandler.py:71 -msgctxt "languageName" -msgid "polish" +#: src/sessions/atprotosocial/session.py +msgid "ATProtoSocial Authentication Error" msgstr "" -#: ../src/languageHandler.py:72 -msgctxt "languageName" -msgid "Arabic" +#: src/sessions/atprotosocial/session.py +msgid "Cannot display login dialogs. Please check application logs." msgstr "" -#: ../src/languageHandler.py:73 -msgctxt "languageName" -msgid "Nepali" +#: src/sessions/atprotosocial/session.py +msgid "Bluesky handle is required." msgstr "" -#: ../src/languageHandler.py:74 -msgctxt "languageName" -msgid "Serbian (Latin)" +#: src/sessions/atprotosocial/session.py +msgid "App Password (generate one in Bluesky settings)" msgstr "" -#: ../src/languageHandler.py:75 -msgctxt "languageName" -msgid "Japanese" +#: src/sessions/atprotosocial/session.py +msgid "Handle and App Password are required." msgstr "" -#: ../src/languageHandler.py:99 -msgid "User default" +#: src/sessions/atprotosocial/session.py +msgid "Successfully connected and authenticated with Bluesky." msgstr "" -#: ../src/build/lib/main.py:109 ../src/main.py:109 -msgid "https://twblue.es/donate" +#: src/sessions/atprotosocial/session.py +msgid "Authentication succeeded but no profile data was returned." msgstr "" -#: ../src/build/lib/main.py:126 ../src/main.py:122 -msgid "" -"{0} is already running. Close the other instance before starting this " -"one. If you're sure that {0} isn't running, try deleting the file at {1}." -" If you're unsure of how to do this, contact the {0} developers." +#: src/sessions/atprotosocial/session.py +msgid "Connection failed: {error_details}" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:136 ../src/sound.py:148 -msgid "Playing..." +#: src/sessions/atprotosocial/session.py +msgid "{author_name} quoted your post" msgstr "" -#: ../src/sound.py:161 -msgid "Stopped." +#: src/sessions/atprotosocial/session.py +msgid "An unexpected error occurred while fetching notifications." msgstr "" -#: ../src/controller/mainController.py:285 -msgid "Ready" +#: src/sessions/atprotosocial/session.py +msgid "Session is not active. Please log in or check your connection." msgstr "" -#: ../src/controller/mainController.py:395 -#: ../src/controller/mainController.py:411 -#: ../src/controller/mainController.py:729 -#: ../src/controller/mainController.py:748 -#: ../src/controller/mainController.py:767 -#: ../src/controller/mainController.py:786 -msgid "" -"No session is currently in focus. Focus a session with the next or " -"previous session shortcut." +#: src/sessions/atprotosocial/session.py +msgid "An error occurred while fetching your home timeline." msgstr "" -#: ../src/controller/mainController.py:399 -msgid "Empty buffer." +#: src/sessions/atprotosocial/session.py +msgid "An error occurred while fetching the user's timeline." msgstr "" -#: ../src/controller/mainController.py:406 -msgid "{0} not found." +#: src/sessions/atprotosocial/session.py +msgid "Unsupported File Skipped" msgstr "" -#: ../src/controller/mainController.py:777 -#: ../src/controller/mainController.py:796 -#, python-format -msgid "%s, %s of %s" +#: src/sessions/atprotosocial/session.py +msgid "File {filename} has an unsupported type and was not attached." msgstr "" -#: ../src/controller/mainController.py:779 -#: ../src/controller/mainController.py:798 -#: ../src/controller/mainController.py:823 -#: ../src/controller/mainController.py:848 -#, python-format -msgid "%s. Empty" +#: src/sessions/atprotosocial/session.py +msgid "Media Upload Failed" msgstr "" -#: ../src/controller/mainController.py:811 -#: ../src/controller/mainController.py:815 -#: ../src/controller/mainController.py:836 -msgid "{0}: This account is not logged into Twitter." +#: src/sessions/atprotosocial/session.py +msgid "Failed to upload {filename}. It will not be attached." msgstr "" -#: ../src/controller/mainController.py:821 -#: ../src/controller/mainController.py:846 -#, python-format -msgid "%s. %s, %s of %s" +#: src/sessions/atprotosocial/session.py +msgid "Media Upload Error" msgstr "" -#: ../src/controller/mainController.py:840 -msgid "{0}: This account is not logged into twitter." +#: src/sessions/atprotosocial/session.py +msgid "An error occurred while uploading {filename}: {error}" msgstr "" -#: ../src/controller/mainController.py:1047 -#: ../src/controller/mainController.py:1063 -msgid "An error happened while trying to connect to the server. Please try later." +#: src/sessions/atprotosocial/session.py +msgid "Failed to send post. The server did not confirm the post creation." msgstr "" -#: ../src/controller/mainController.py:1104 -msgid "The auto-reading of new tweets is enabled for this buffer" +#: src/sessions/atprotosocial/session.py +msgid "An unexpected error occurred while sending the post: {error}" msgstr "" -#: ../src/controller/mainController.py:1107 -msgid "The auto-reading of new tweets is disabled for this buffer" +#: src/sessions/atprotosocial/utils.py +msgid "Not connected to ATProtoSocial. Please check your connection settings or log in." msgstr "" -#: ../src/controller/mainController.py:1114 -msgid "Session mute on" +#: src/sessions/atprotosocial/utils.py +msgid "User identity not found. Cannot create post." msgstr "" -#: ../src/controller/mainController.py:1117 -msgid "Session mute off" +#: src/sessions/atprotosocial/utils.py +msgid "Failed to post: {error} - {message}" msgstr "" -#: ../src/controller/mainController.py:1126 -msgid "Buffer mute on" +#: src/sessions/atprotosocial/utils.py +msgid "An unexpected error occurred while posting: {error}" msgstr "" -#: ../src/controller/mainController.py:1129 -msgid "Buffer mute off" +#: src/sessions/atprotosocial/utils.py +msgid "You have already reposted this post." msgstr "" -#: ../src/controller/mainController.py:1146 -msgid "Copied" +#: src/sessions/atprotosocial/utils.py +msgid "Failed to repost: {error}" msgstr "" -#: ../src/controller/mainController.py:1176 -msgid "Unable to update this buffer." +#: src/sessions/atprotosocial/utils.py +msgid "An unexpected error occurred while reposting." msgstr "" -#: ../src/controller/mainController.py:1178 -msgid "Updating buffer..." +#: src/sessions/atprotosocial/utils.py +msgid "You have already liked this post." msgstr "" -#: ../src/controller/mainController.py:1181 -msgid "{0} items retrieved" +#: src/sessions/atprotosocial/utils.py +msgid "Failed to like post: {error}" msgstr "" -#: ../src/controller/mainController.py:1185 -#: ../src/controller/mastodon/handler.py:192 -#: ../src/controller/twitter/handler.py:85 -#: ../src/controller/twitter/handler.py:271 -msgid "Timeline for {}" +#: src/sessions/atprotosocial/utils.py +msgid "An unexpected error occurred while liking the post." msgstr "" -#: ../src/controller/mainController.py:1187 -#: ../src/controller/twitter/handler.py:89 -#: ../src/controller/twitter/handler.py:282 -msgid "Likes for {}" +#: src/sessions/atprotosocial/utils.py +msgid "Could not find the post to repost." msgstr "" -#: ../src/controller/mainController.py:1189 -#: ../src/controller/mastodon/handler.py:93 -#: ../src/controller/mastodon/handler.py:203 -#: ../src/controller/twitter/handler.py:93 -#: ../src/controller/twitter/handler.py:293 -msgid "Followers for {}" +#: src/sessions/atprotosocial/utils.py +msgid "Could not find the post to like." msgstr "" -#: ../src/controller/mainController.py:1191 -#: ../src/controller/twitter/handler.py:97 -#: ../src/controller/twitter/handler.py:304 -msgid "Friends for {}" +#: src/controller/atprotosocial/handler.py +msgid "&New Post" msgstr "" -#: ../src/controller/mainController.py:1193 -#: ../src/controller/mastodon/handler.py:95 -#: ../src/controller/mastodon/handler.py:214 -msgid "Following for {}" +#: src/controller/atprotosocial/handler.py +msgid "&Repost" msgstr "" -#: ../src/controller/mainController.py:1195 -#: ../src/controller/twitter/handler.py:107 -#: ../src/controller/twitter/handler.py:326 -#, python-format -msgid "Trending topics for %s" -msgstr "" - -#: ../src/controller/settings.py:66 -msgid "System default" -msgstr "" - -#: ../src/controller/settings.py:66 -msgid "HTTP" -msgstr "" - -#: ../src/controller/settings.py:66 -msgid "SOCKS v4" -msgstr "" - -#: ../src/controller/settings.py:66 -msgid "SOCKS v4 with DNS support" -msgstr "" - -#: ../src/controller/settings.py:66 -msgid "SOCKS v5" -msgstr "" - -#: ../src/controller/settings.py:66 -msgid "SOCKS v5 with DNS support" -msgstr "" - -#: ../src/controller/userAlias.py:31 -msgid "Edit alias for {}" -msgstr "" - -#: ../src/controller/userSelector.py:10 -msgid "Select user" -msgstr "" - -#: ../src/controller/buffers/base/base.py:91 -#: ../src/controller/buffers/mastodon/conversations.py:226 -msgid "This action is not supported for this buffer" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/mastodon/handler.py:63 -#: ../src/controller/mastodon/settings.py:186 -#: ../src/controller/twitter/handler.py:63 -#: ../src/controller/twitter/settings.py:220 -msgid "Home" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:65 -#: ../src/controller/mastodon/settings.py:187 -msgid "Local" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:67 -#: ../src/controller/mastodon/settings.py:188 -msgid "Federated" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/mastodon/handler.py:69 -#: ../src/controller/mastodon/settings.py:189 -#: ../src/controller/twitter/handler.py:65 -#: ../src/controller/twitter/settings.py:221 -msgid "Mentions" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:77 -#: ../src/controller/mastodon/settings.py:193 -msgid "Bookmarks" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/mastodon/handler.py:71 -#: ../src/controller/twitter/handler.py:67 -msgid "Direct messages" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:73 -#: ../src/controller/mastodon/settings.py:191 -msgid "Sent" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:75 -#: ../src/controller/mastodon/settings.py:192 -msgid "Favorites" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/mastodon/handler.py:79 -#: ../src/controller/mastodon/settings.py:194 -#: ../src/controller/twitter/handler.py:75 -#: ../src/controller/twitter/settings.py:226 -msgid "Followers" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:81 -#: ../src/controller/mastodon/settings.py:195 -#: ../src/controller/twitter/handler.py:77 -msgid "Following" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/mastodon/handler.py:85 -#: ../src/controller/mastodon/settings.py:196 -#: ../src/controller/twitter/handler.py:79 -#: ../src/controller/twitter/settings.py:228 -msgid "Blocked users" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/mastodon/handler.py:83 -#: ../src/controller/mastodon/settings.py:197 -#: ../src/controller/twitter/handler.py:81 -#: ../src/controller/twitter/settings.py:229 -msgid "Muted users" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:49 -#: ../src/controller/mastodon/handler.py:87 -#: ../src/controller/mastodon/settings.py:198 -msgid "Notifications" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:55 -#: ../src/controller/buffers/twitter/base.py:71 -msgid "{username}'s timeline" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:57 -#: ../src/controller/buffers/twitter/base.py:75 -msgid "{username}'s followers" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:59 -msgid "{username}'s following" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:61 -#: ../src/controller/buffers/twitter/base.py:79 -msgid "Unknown buffer" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:64 -#: ../src/wxUI/buffers/mastodon/base.py:24 -#: ../src/wxUI/buffers/mastodon/conversationList.py:24 -#: ../src/wxUI/buffers/mastodon/notifications.py:22 -#: ../src/wxUI/buffers/mastodon/user.py:18 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:4 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:172 -msgid "Post" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:65 -msgid "Write your post here" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:126 -msgid "New post in {0}" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:129 -msgid "{0} new posts in {1}." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:162 -#: ../src/controller/buffers/mastodon/conversations.py:98 -#: ../src/controller/buffers/mastodon/mentions.py:68 -#: ../src/controller/buffers/mastodon/users.py:133 -#: ../src/controller/buffers/twitter/base.py:231 -#: ../src/controller/buffers/twitter/directMessages.py:87 -#: ../src/controller/buffers/twitter/people.py:171 -#, python-format -msgid "%s items retrieved" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:180 -#: ../src/controller/buffers/mastodon/users.py:200 -#: ../src/controller/buffers/twitter/base.py:263 -#: ../src/controller/buffers/twitter/people.py:74 -msgid "This buffer is not a timeline; it can't be deleted." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:296 -#: ../src/controller/buffers/mastodon/base.py:327 -msgid "Conversation with {}" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:297 -#: ../src/controller/buffers/mastodon/base.py:328 -#: ../src/controller/buffers/mastodon/conversations.py:174 -#: ../src/controller/buffers/mastodon/users.py:51 -msgid "Write your message here" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:299 -msgid "Reply to {}" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:300 -msgid "Write your reply here" -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:353 -msgid "This action is not supported on conversation posts." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:418 -#: ../src/controller/buffers/twitter/base.py:524 -msgid "Opening URL..." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:431 -msgid "You can delete only your own posts." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:456 -#: ../src/controller/buffers/twitter/base.py:582 -msgid "Opening item in web browser..." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:463 -#: ../src/controller/buffers/mastodon/base.py:477 -msgid "Adding to favorites..." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:469 -#: ../src/controller/buffers/mastodon/base.py:479 -msgid "Removing from favorites..." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:487 -msgid "Adding to bookmarks..." -msgstr "" - -#: ../src/controller/buffers/mastodon/base.py:489 -msgid "Removing from bookmarks..." -msgstr "" - -#: ../src/controller/buffers/mastodon/conversations.py:173 -msgid "Reply to conversation with {}" -msgstr "" - -#: ../src/controller/buffers/mastodon/notifications.py:59 -msgid "Notification dismissed." -msgstr "" - -#: ../src/controller/buffers/mastodon/users.py:50 -msgid "New conversation with {}" -msgstr "" - -#: ../src/controller/buffers/mastodon/users.py:104 -msgid "There are no more items in this buffer." -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/twitter/handler.py:69 -#: ../src/controller/twitter/settings.py:223 -msgid "Sent direct messages" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/twitter/handler.py:71 -#: ../src/controller/twitter/settings.py:224 -msgid "Sent tweets" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/twitter/handler.py:73 -#: ../src/controller/twitter/settings.py:225 -msgid "Likes" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:65 -#: ../src/controller/twitter/settings.py:227 -msgid "Friends" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:73 -msgid "{username}'s likes" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:77 -msgid "{username}'s friends" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:82 -#: ../src/controller/buffers/twitter/trends.py:37 -#: ../src/controller/buffers/twitter/trends.py:128 -#: ../src/controller/twitter/messages.py:298 -#: ../src/wxUI/buffers/twitter/base.py:25 -#: ../src/wxUI/buffers/twitter/events.py:15 -#: ../src/wxUI/buffers/twitter/trends.py:18 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:309 -#: ../src/wxUI/sysTrayIcon.py:35 -msgid "Tweet" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:83 -#: ../src/controller/buffers/twitter/trends.py:38 -#: ../src/controller/buffers/twitter/trends.py:129 -msgid "Write the tweet here" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:189 -msgid "New tweet in {0}" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:192 -msgid "{0} new tweets in {1}." -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:401 -msgid "Reply to {arg0}" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:403 -#: ../src/keystrokeEditor/actions/mastodon.py:11 -#: ../src/keystrokeEditor/actions/twitter.py:11 -#: ../src/wxUI/buffers/mastodon/base.py:26 -#: ../src/wxUI/buffers/mastodon/conversationList.py:25 -#: ../src/wxUI/buffers/twitter/base.py:27 -msgid "Reply" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:404 -#, python-format -msgid "Reply to %s" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:427 -#: ../src/controller/twitter/messages.py:270 -#, python-format -msgid "Direct message to %s" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:427 -#: ../src/controller/buffers/twitter/directMessages.py:116 -msgid "New direct message" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:443 -msgid "This action is not supported on protected accounts." -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:460 -msgid "Quote" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:460 -msgid "Add your comment to the tweet" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:561 -msgid "User details" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:606 -#: ../src/controller/buffers/twitter/directMessages.py:163 -#: ../src/controller/twitter/messages.py:329 -msgid "MMM D, YYYY. H:m" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:621 -msgid "There are no coordinates in this tweet" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:625 -msgid "Error decoding coordinates. Try again later." -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:641 -msgid "Picture {0}" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:642 -msgid "Select the picture" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:650 -msgid "Invalid buffer" -msgstr "" - -#: ../src/controller/buffers/twitter/base.py:661 -msgid "Unable to extract text" -msgstr "" - -#: ../src/controller/buffers/twitter/directMessages.py:119 -msgid "{0} new direct messages." -msgstr "" - -#: ../src/controller/buffers/twitter/directMessages.py:122 -msgid "This action is not supported in the buffer yet." -msgstr "" - -#: ../src/controller/buffers/twitter/directMessages.py:138 -msgid "" -"Getting more items cannot be done in this buffer. Use the direct messages" -" buffer instead." -msgstr "" - -#: ../src/controller/buffers/twitter/people.py:92 -#: ../src/wxUI/buffers/twitter/people.py:17 -msgid "Mention" -msgstr "" - -#: ../src/controller/buffers/twitter/people.py:92 -#, python-format -msgid "Mention to %s" -msgstr "" - -#: ../src/controller/buffers/twitter/people.py:244 -msgid "{0} new followers." -msgstr "" - -#: ../src/controller/buffers/twitter/trends.py:144 -msgid "This action is not supported in the buffer, yet." -msgstr "" - -#: ../src/controller/mastodon/handler.py:24 -#: ../src/controller/twitter/handler.py:24 -#: ../src/wxUI/dialogs/mastodon/search.py:10 ../src/wxUI/dialogs/search.py:13 -#: ../src/wxUI/view.py:19 -msgid "&Search" -msgstr "" - -#: ../src/controller/mastodon/handler.py:28 -msgid "&Post" -msgstr "" - -#: ../src/controller/mastodon/handler.py:29 -#: ../src/controller/twitter/handler.py:29 -#: ../src/wxUI/dialogs/mastodon/menus.py:9 ../src/wxUI/menus.py:10 -#: ../src/wxUI/menus.py:34 ../src/wxUI/view.py:30 -msgid "Re&ply" -msgstr "" - -#: ../src/controller/mastodon/handler.py:30 -#: ../src/wxUI/dialogs/mastodon/menus.py:7 -msgid "&Boost" -msgstr "" - -#: ../src/controller/mastodon/handler.py:31 -#: ../src/wxUI/dialogs/mastodon/menus.py:11 -msgid "&Add to favorites" -msgstr "" - -#: ../src/controller/mastodon/handler.py:32 -msgid "Remove from favorites" -msgstr "" - -#: ../src/controller/mastodon/handler.py:33 -msgid "&Show post" -msgstr "" - -#: ../src/controller/mastodon/handler.py:35 -#: ../src/controller/twitter/handler.py:35 ../src/wxUI/view.py:36 -msgid "View conversa&tion" -msgstr "" - -#: ../src/controller/mastodon/handler.py:37 -#: ../src/controller/twitter/handler.py:37 -#: ../src/wxUI/dialogs/mastodon/menus.py:25 ../src/wxUI/menus.py:26 -#: ../src/wxUI/menus.py:44 ../src/wxUI/menus.py:62 ../src/wxUI/menus.py:72 -#: ../src/wxUI/view.py:38 -msgid "&Delete" -msgstr "" - -#: ../src/controller/mastodon/handler.py:39 -#: ../src/controller/twitter/handler.py:39 ../src/wxUI/view.py:42 -msgid "&Actions..." -msgstr "" - -#: ../src/controller/mastodon/handler.py:40 -#: ../src/controller/twitter/handler.py:40 ../src/wxUI/view.py:43 -msgid "&View timeline..." -msgstr "" - -#: ../src/controller/mastodon/handler.py:41 -#: ../src/controller/twitter/handler.py:41 ../src/wxUI/view.py:44 -msgid "Direct me&ssage" -msgstr "" - -#: ../src/controller/mastodon/handler.py:88 -#: ../src/controller/twitter/handler.py:82 -msgid "Timelines" -msgstr "" - -#: ../src/controller/mastodon/handler.py:91 -msgid "Timelines for {}" -msgstr "" - -#: ../src/controller/mastodon/handler.py:100 -#: ../src/controller/twitter/handler.py:102 -msgid "Searches" -msgstr "" - -#: ../src/controller/mastodon/handler.py:124 -#: ../src/controller/twitter/handler.py:317 -msgid "Conversation with {0}" -msgstr "" - -#: ../src/controller/mastodon/handler.py:158 -#: ../src/controller/twitter/handler.py:105 -#: ../src/controller/twitter/handler.py:369 -#: ../src/controller/twitter/handler.py:374 -msgid "Search for {}" -msgstr "" - -#: ../src/controller/mastodon/messages.py:174 -msgid "Poll with {} options" -msgstr "" - -#: ../src/controller/mastodon/messages.py:193 -msgid "Post from {}" -msgstr "" - -#: ../src/controller/mastodon/messages.py:197 -#: ../src/sessions/mastodon/templates.py:85 ../src/wxUI/dialogs/lists.py:76 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:48 -msgid "Public" -msgstr "" - -#: ../src/controller/mastodon/messages.py:197 -#: ../src/sessions/mastodon/templates.py:85 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:48 -msgid "Not listed" -msgstr "" - -#: ../src/controller/mastodon/messages.py:197 -msgid "followers only" -msgstr "" - -#: ../src/controller/mastodon/messages.py:197 -#: ../src/sessions/mastodon/templates.py:85 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:48 -msgid "Direct" -msgstr "" - -#: ../src/controller/mastodon/messages.py:204 -msgid "Remote instance" -msgstr "" - -#: ../src/controller/mastodon/messages.py:227 -#: ../src/controller/twitter/messages.py:382 -msgid "Link copied to clipboard." -msgstr "" - -#: ../src/controller/mastodon/settings.py:79 -#: ../src/controller/twitter/settings.py:85 -#, python-format -msgid "Account settings for %s" -msgstr "" - -#: ../src/controller/mastodon/settings.py:89 -#: ../src/wxUI/dialogs/mastodon/configuration.py:51 -msgid "Edit template for posts. Current template: {}" -msgstr "" - -#: ../src/controller/mastodon/settings.py:98 -#: ../src/wxUI/dialogs/mastodon/configuration.py:53 -msgid "Edit template for conversations. Current template: {}" -msgstr "" - -#: ../src/controller/mastodon/settings.py:107 -#: ../src/controller/twitter/settings.py:122 -#: ../src/wxUI/dialogs/configuration.py:253 -#: ../src/wxUI/dialogs/mastodon/configuration.py:55 -msgid "Edit template for persons. Current template: {}" -msgstr "" - -#: ../src/controller/mastodon/settings.py:190 -#: ../src/controller/twitter/settings.py:222 -msgid "Direct Messages" -msgstr "" - -#: ../src/controller/twitter/handler.py:23 -#: ../src/wxUI/dialogs/update_profile.py:35 ../src/wxUI/view.py:17 -msgid "&Update profile" -msgstr "" - -#: ../src/controller/twitter/handler.py:25 ../src/wxUI/view.py:20 -msgid "&Lists manager" -msgstr "" - -#: ../src/controller/twitter/handler.py:26 ../src/wxUI/view.py:21 -msgid "Manage user aliases" -msgstr "" - -#: ../src/controller/twitter/handler.py:28 ../src/wxUI/view.py:29 -msgid "&Tweet" -msgstr "" - -#: ../src/controller/twitter/handler.py:30 ../src/wxUI/menus.py:8 -#: ../src/wxUI/view.py:31 -msgid "&Retweet" -msgstr "" - -#: ../src/controller/twitter/handler.py:31 ../src/wxUI/menus.py:12 -#: ../src/wxUI/view.py:32 -msgid "&Like" -msgstr "" - -#: ../src/controller/twitter/handler.py:32 ../src/wxUI/menus.py:14 -#: ../src/wxUI/view.py:33 +#: src/controller/atprotosocial/handler.py msgid "&Unlike" msgstr "" -#: ../src/controller/twitter/handler.py:33 -#: ../src/wxUI/dialogs/mastodon/menus.py:21 ../src/wxUI/menus.py:22 -#: ../src/wxUI/menus.py:58 ../src/wxUI/view.py:34 -msgid "&Show tweet" +#: src/controller/atprotosocial/handler.py +msgid "{label} Home" msgstr "" -#: ../src/controller/twitter/handler.py:34 ../src/wxUI/view.py:35 -msgid "View &address" +#: src/controller/atprotosocial/handler.py +msgid "{label} Notifications" msgstr "" -#: ../src/controller/twitter/handler.py:36 ../src/wxUI/view.py:37 -msgid "Read text in picture" +#: src/controller/atprotosocial/handler.py +msgid "Session not ready." msgstr "" -#: ../src/controller/twitter/handler.py:42 ../src/wxUI/view.py:45 -msgid "Add a&lias" +#: src/controller/atprotosocial/handler.py +msgid "Post reposted successfully." msgstr "" -#: ../src/controller/twitter/handler.py:43 ../src/wxUI/view.py:46 -msgid "&Add to list" +#: src/controller/atprotosocial/handler.py +msgid "Failed to repost post." msgstr "" -#: ../src/controller/twitter/handler.py:44 ../src/wxUI/view.py:47 -msgid "R&emove from list" +#: src/controller/atprotosocial/handler.py +msgid "Post liked successfully." msgstr "" -#: ../src/controller/twitter/handler.py:45 ../src/wxUI/menus.py:80 -#: ../src/wxUI/view.py:48 -msgid "&View lists" +#: src/controller/atprotosocial/handler.py +msgid "Failed to like post." msgstr "" -#: ../src/controller/twitter/handler.py:46 ../src/wxUI/menus.py:83 -#: ../src/wxUI/view.py:49 -msgid "Show user &profile" +#: src/controller/atprotosocial/handler.py +msgid "Like removed successfully." msgstr "" -#: ../src/controller/twitter/handler.py:47 -msgid "View likes" +#: src/controller/atprotosocial/handler.py +msgid "Failed to remove like." msgstr "" -#: ../src/controller/twitter/handler.py:49 ../src/wxUI/view.py:55 -msgid "New &trending topics buffer..." +#: src/controller/atprotosocial/handler.py +msgid "An unexpected error occurred while unliking." msgstr "" -#: ../src/controller/twitter/handler.py:50 ../src/wxUI/view.py:56 -msgid "Create a &filter" +#: src/controller/atprotosocial/handler.py +msgid "ATProtoSocial session is not active or authenticated." msgstr "" -#: ../src/controller/twitter/handler.py:51 ../src/wxUI/view.py:57 -msgid "&Manage filters" +#: src/controller/atprotosocial/handler.py +msgid "Target user DID not provided." msgstr "" -#: ../src/controller/twitter/handler.py:86 -msgid "Likes timelines" +#: src/controller/atprotosocial/handler.py +msgid "User followed successfully." msgstr "" -#: ../src/controller/twitter/handler.py:90 -msgid "Followers timelines" +#: src/controller/atprotosocial/handler.py +msgid "Failed to follow user." msgstr "" -#: ../src/controller/twitter/handler.py:94 -msgid "Following timelines" +#: src/controller/atprotosocial/handler.py +msgid "User unfollowed successfully." msgstr "" -#: ../src/controller/twitter/handler.py:98 ../src/wxUI/dialogs/lists.py:13 -msgid "Lists" +#: src/controller/atprotosocial/handler.py +msgid "Failed to unfollow user." msgstr "" -#: ../src/controller/twitter/handler.py:101 -#: ../src/controller/twitter/lists.py:94 -msgid "List for {}" +#: src/controller/atprotosocial/handler.py +msgid "User muted successfully." msgstr "" -#: ../src/controller/twitter/handler.py:112 -msgid "Filters cannot be applied on this buffer" +#: src/controller/atprotosocial/handler.py +msgid "Failed to mute user." msgstr "" -#: ../src/controller/twitter/handler.py:228 -msgid "Add an user alias" +#: src/controller/atprotosocial/handler.py +msgid "User unmuted successfully." msgstr "" -#: ../src/controller/twitter/handler.py:236 -msgid "Alias has been set correctly for {}." +#: src/controller/atprotosocial/handler.py +msgid "Failed to unmute user." msgstr "" -#: ../src/controller/twitter/messages.py:50 -msgid "Translated" +#: src/controller/atprotosocial/handler.py +msgid "User blocked successfully." msgstr "" -#: ../src/controller/twitter/messages.py:57 -#, python-format -msgid "%s - %s of %d characters" +#: src/controller/atprotosocial/handler.py +msgid "Failed to block user." msgstr "" -#: ../src/controller/twitter/messages.py:356 -msgid "View item" +#: src/controller/atprotosocial/handler.py +msgid "User unblocked successfully." msgstr "" -#: ../src/controller/twitter/settings.py:37 -#: ../src/controller/twitter/settings.py:151 -#: ../src/wxUI/dialogs/configuration.py:121 -msgid "Ask" +#: src/controller/atprotosocial/handler.py +msgid "Failed to unblock user, or user was not blocked." msgstr "" -#: ../src/controller/twitter/settings.py:39 -#: ../src/controller/twitter/settings.py:153 -#: ../src/wxUI/dialogs/configuration.py:121 -msgid "Retweet without comments" +#: src/controller/atprotosocial/handler.py +msgid "Unknown action: {command}" msgstr "" -#: ../src/controller/twitter/settings.py:41 -#: ../src/wxUI/dialogs/configuration.py:121 -msgid "Retweet with comments" +#: src/controller/atprotosocial/handler.py +msgid "Profile: {handle}" msgstr "" -#: ../src/controller/twitter/settings.py:95 -#: ../src/wxUI/dialogs/configuration.py:247 -msgid "Edit template for tweets. Current template: {}" +#: src/controller/atprotosocial/handler.py +msgid "Could not fetch profile for {user_ident}." msgstr "" -#: ../src/controller/twitter/settings.py:104 -#: ../src/wxUI/dialogs/configuration.py:249 -msgid "Edit template for direct messages. Current template: {}" +#: src/controller/atprotosocial/handler.py +msgid "Error displaying profile: {error}" msgstr "" -#: ../src/controller/twitter/settings.py:113 -#: ../src/wxUI/dialogs/configuration.py:251 -msgid "Edit template for sent direct messages. Current template: {}" +#: src/controller/atprotosocial/handler.py +msgid "Enter user DID or handle:" msgstr "" -#: ../src/controller/twitter/user.py:29 ../src/wxUI/commonMessageDialogs.py:39 -msgid "That user does not exist" +#: src/controller/atprotosocial/handler.py +msgid "View User Timeline" msgstr "" -#: ../src/controller/twitter/user.py:29 ../src/controller/twitter/user.py:31 -#: ../src/extra/SpellChecker/wx_ui.py:79 -#: ../src/extra/autocompletionUsers/wx_scan.py:47 -#: ../src/wxUI/commonMessageDialogs.py:39 -#: ../src/wxUI/commonMessageDialogs.py:51 -#: ../src/wxUI/commonMessageDialogs.py:58 -#: ../src/wxUI/commonMessageDialogs.py:61 -#: ../src/wxUI/commonMessageDialogs.py:64 -#: ../src/wxUI/commonMessageDialogs.py:67 -#: ../src/wxUI/commonMessageDialogs.py:77 -#: ../src/wxUI/commonMessageDialogs.py:80 -#: ../src/wxUI/commonMessageDialogs.py:83 -#: ../src/wxUI/commonMessageDialogs.py:89 -#: ../src/wxUI/commonMessageDialogs.py:92 -#: ../src/wxUI/commonMessageDialogs.py:95 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:38 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:43 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:48 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:53 -msgid "Error" +#: src/controller/atprotosocial/handler.py +msgid "User {user_ident} not found." msgstr "" -#: ../src/controller/twitter/user.py:31 -msgid "User has been suspended" +#: src/controller/atprotosocial/handler.py +msgid "Failed to open user timeline: {error}" msgstr "" -#: ../src/controller/twitter/user.py:37 -#, python-format -msgid "Information for %s" +#: src/controller/atprotosocial/handler.py +msgid "View Followers" msgstr "" -#: ../src/controller/twitter/user.py:67 -#: ../src/extra/AudioUploader/audioUploader.py:127 -msgid "Discarded" +#: src/controller/atprotosocial/handler.py +msgid "{user_handle}'s Posts" msgstr "" -#: ../src/controller/twitter/user.py:95 -#, python-format -msgid "Username: @%s\n" +#: src/controller/atprotosocial/handler.py +msgid "Followers of {user_handle}" msgstr "" -#: ../src/controller/twitter/user.py:96 -#, python-format -msgid "Name: %s\n" +#: src/controller/atprotosocial/handler.py +msgid "Failed to open followers list: {error}" msgstr "" -#: ../src/controller/twitter/user.py:98 -#, python-format -msgid "Location: %s\n" +#: src/controller/atprotosocial/handler.py +msgid "View Following" msgstr "" -#: ../src/controller/twitter/user.py:100 -#, python-format -msgid "URL: %s\n" +#: src/controller/atprotosocial/handler.py +msgid "Following by {user_handle}" msgstr "" -#: ../src/controller/twitter/user.py:104 -#, python-format -msgid "Bio: %s\n" +#: src/controller/atprotosocial/handler.py +msgid "Failed to open following list: {error}" msgstr "" -#: ../src/controller/twitter/user.py:105 ../src/controller/twitter/user.py:120 -msgid "Yes" +#: src/wxUI/dialogs/composeDialog.py +msgid "Compose Post" msgstr "" -#: ../src/controller/twitter/user.py:106 ../src/controller/twitter/user.py:121 -msgid "No" +#: src/wxUI/dialogs/composeDialog.py +msgid "Replying to: {uri_placeholder}" msgstr "" -#: ../src/controller/twitter/user.py:107 -#, python-format -msgid "Protected: %s\n" +#: src/wxUI/dialogs/composeDialog.py +msgid "Media Attachments" msgstr "" -#: ../src/controller/twitter/user.py:110 -msgid "Relationship: " +#: src/wxUI/dialogs/composeDialog.py +msgid "Max: {max_attachments}" msgstr "" -#: ../src/controller/twitter/user.py:112 -msgid "You follow {0}. " +#: src/wxUI/dialogs/composeDialog.py +msgid "Add Media..." msgstr "" -#: ../src/controller/twitter/user.py:115 -msgid "{0} is following you." +#: src/wxUI/dialogs/composeDialog.py +msgid "Quoting Post" msgstr "" -#: ../src/controller/twitter/user.py:119 -#, python-format -msgid "" -"Followers: %s\n" -" Friends: %s\n" +#: src/wxUI/dialogs/composeDialog.py +msgid "Quoting URI: " msgstr "" -#: ../src/controller/twitter/user.py:122 -#, python-format -msgid "Verified: %s\n" +#: src/wxUI/dialogs/composeDialog.py +msgid "None" msgstr "" -#: ../src/controller/twitter/user.py:123 -#, python-format -msgid "Tweets: %s\n" +#: src/wxUI/dialogs/composeDialog.py +msgid "Set/Change Quote..." msgstr "" -#: ../src/controller/twitter/user.py:124 -#, python-format -msgid "Likes: %s" +#: src/wxUI/dialogs/composeDialog.py +msgid "Remove Quote" msgstr "" -#: ../src/controller/twitter/userActions.py:80 -msgid "You can't ignore direct messages" +#: src/wxUI/dialogs/composeDialog.py +msgid "Options" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:57 -msgid "Attaching..." +#: src/wxUI/dialogs/composeDialog.py +msgid "Sensitive content (CW)" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:74 -msgid "Pause" +#: src/wxUI/dialogs/composeDialog.py +msgid "Content warning text (optional)" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:76 -msgid "&Resume" +#: src/wxUI/dialogs/composeDialog.py +msgid "Languages:" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:77 -msgid "Resume" +#: src/wxUI/dialogs/composeDialog.py +msgid "Automatic" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:79 -#: ../src/extra/AudioUploader/audioUploader.py:106 -#: ../src/extra/AudioUploader/wx_ui.py:37 -msgid "&Pause" +#: src/wxUI/dialogs/composeDialog.py +msgid "Maximum number of attachments ({max}) reached." msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:94 -#: ../src/extra/AudioUploader/audioUploader.py:140 -msgid "&Stop" +#: src/wxUI/dialogs/composeDialog.py +msgid "Attachment Limit" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:95 -msgid "Recording" +#: src/wxUI/dialogs/composeDialog.py +msgid "Select Media File" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:100 -#: ../src/extra/AudioUploader/audioUploader.py:151 -msgid "Stopped" +#: src/wxUI/dialogs/composeDialog.py +msgid "Enter accessibility description (alt text) for the image:" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:102 -#: ../src/extra/AudioUploader/wx_ui.py:39 -msgid "&Record" +#: src/wxUI/dialogs/composeDialog.py +msgid "Image Description" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:144 -#: ../src/extra/AudioUploader/audioUploader.py:154 -#: ../src/extra/AudioUploader/wx_ui.py:35 -msgid "&Play" +#: src/wxUI/dialogs/composeDialog.py +msgid "Enter the AT-URI of the Bluesky post to quote:" msgstr "" -#: ../src/extra/AudioUploader/audioUploader.py:159 -msgid "Recoding audio..." +#: src/wxUI/dialogs/composeDialog.py +msgid "You can select a maximum of {num} languages." msgstr "" -#: ../src/extra/AudioUploader/transfer.py:82 -#: ../src/extra/AudioUploader/transfer.py:88 -msgid "Error in file upload: {0}" +#: src/wxUI/dialogs/composeDialog.py +msgid "Language Selection Limit" msgstr "" -#: ../src/extra/AudioUploader/utils.py:29 ../src/update/utils.py:29 -#, python-format -msgid "%d day, " +#: src/wxUI/dialogs/composeDialog.py +msgid "Cannot send an empty post." msgstr "" -#: ../src/extra/AudioUploader/utils.py:31 ../src/update/utils.py:31 -#, python-format -msgid "%d days, " +#: src/wxUI/dialogs/composeDialog.py +msgid "Please select no more than {num} languages." msgstr "" -#: ../src/extra/AudioUploader/utils.py:33 ../src/update/utils.py:33 -#, python-format -msgid "%d hour, " +#: src/wxUI/dialogs/composeDialog.py +msgid "Language Error" msgstr "" -#: ../src/extra/AudioUploader/utils.py:35 ../src/update/utils.py:35 -#, python-format -msgid "%d hours, " +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "User Profile" msgstr "" -#: ../src/extra/AudioUploader/utils.py:37 ../src/update/utils.py:37 -#, python-format -msgid "%d minute, " +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Loading profile..." msgstr "" -#: ../src/extra/AudioUploader/utils.py:39 ../src/update/utils.py:39 -#, python-format -msgid "%d minutes, " +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Profile not found." msgstr "" -#: ../src/extra/AudioUploader/utils.py:41 ../src/update/utils.py:41 -#, python-format -msgid "%s second" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Error loading profile." msgstr "" -#: ../src/extra/AudioUploader/utils.py:43 ../src/update/utils.py:43 -#, python-format -msgid "%s seconds" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Profile loaded." msgstr "" -#: ../src/extra/AudioUploader/wx_transfer_dialogs.py:15 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:23 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:35 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:171 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:255 -msgid "File" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Action failed." msgstr "" -#: ../src/extra/AudioUploader/wx_transfer_dialogs.py:21 -msgid "Transferred" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Performing action: {action}..." msgstr "" -#: ../src/extra/AudioUploader/wx_transfer_dialogs.py:26 -msgid "Total file size" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Display Name:" msgstr "" -#: ../src/extra/AudioUploader/wx_transfer_dialogs.py:31 -msgid "Transfer rate" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Handle:" msgstr "" -#: ../src/extra/AudioUploader/wx_transfer_dialogs.py:36 -msgid "Time left" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "DID:" msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:29 -msgid "Attach audio" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Bio:" msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:41 -msgid "&Add an existing file" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Avatar URL: " msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:42 -msgid "&Discard" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Banner URL: " msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:44 -msgid "Upload to" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Follow" msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:49 -msgid "Attach" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Unfollow" msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:51 -msgid "&Cancel" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Unblock" msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:76 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:154 -msgid "Select the audio file to be uploaded" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "User DID not available for this action." msgstr "" -#: ../src/extra/AudioUploader/wx_ui.py:76 -msgid "Audio Files (*.mp3, *.ogg, *.wav)|*.mp3; *.ogg; *.wav" +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Confirm Action" msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:4 -msgid "Audio tweet." +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Are you sure you want to unfollow @{handle}?" msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:5 -msgid "User timeline buffer created." +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Are you sure you want to block @{handle}? This will prevent them from interacting with you and hide their content." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:6 -msgid "Buffer destroied." +#: src/wxUI/dialogs/atprotosocial/showUserProfile.py +msgid "Action Error" msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:7 -msgid "Direct message received." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "No posts found." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:8 -msgid "Direct message sent." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "An unexpected error occurred loading posts." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:9 -msgid "Error." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "No more posts to load." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:10 -msgid "Tweet liked." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Failed to load more posts or no more posts." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:11 -msgid "Likes buffer updated." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "An unexpected error occurred while loading more users." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:12 -msgid "Geotweet." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Home timeline is empty or failed to load." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:13 -msgid "Tweet contains one or more images" +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Error loading home timeline." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:14 -msgid "Boundary reached." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "No more posts." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:15 -msgid "List updated." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Error loading more posts." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:16 -msgid "Too many characters." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "No unread notifications or failed to load initial set." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:17 -msgid "Mention received." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Error loading notifications." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:18 -msgid "New event." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "No more older notifications." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:19 -msgid "{0} is ready." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Error loading more notifications." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:20 -msgid "Mention sent." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "No users found in this list." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:21 -msgid "Tweet retweeted." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Error loading user list." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:22 -msgid "Search buffer updated." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "This list is empty." msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:23 -msgid "Tweet received." +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Post Content" msgstr "" -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:24 -msgid "Tweet sent." -msgstr "" - -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:25 -msgid "Trending topics buffer updated." -msgstr "" - -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:26 -msgid "New tweet in user timeline buffer." -msgstr "" - -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:27 -msgid "New follower." -msgstr "" - -#: ../src/extra/SoundsTutorial/soundsTutorial_constants.py:28 -msgid "Volume changed." -msgstr "" - -#: ../src/extra/SoundsTutorial/wx_ui.py:8 -msgid "Sounds tutorial" -msgstr "" - -#: ../src/extra/SoundsTutorial/wx_ui.py:11 -msgid "Press enter to listen to the sound for the selected event" -msgstr "" - -#: ../src/extra/SpellChecker/spellchecker.py:56 -#, python-format -msgid "Misspelled word: %s" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:27 -msgid "Misspelled word" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:32 -msgid "Context" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:37 -msgid "Suggestions" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:42 -msgid "&Ignore" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:43 -msgid "I&gnore all" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:44 -msgid "&Replace" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:45 -msgid "R&eplace all" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:46 -msgid "&Add to personal dictionary" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:79 -msgid "" -"An error has occurred. There are no dictionaries available for the " -"selected language in {0}" -msgstr "" - -#: ../src/extra/SpellChecker/wx_ui.py:82 -msgid "Spell check complete." -msgstr "" - -#: ../src/extra/autocompletionUsers/completion.py:39 -#: ../src/extra/autocompletionUsers/completion.py:57 -msgid "You have to start writing" -msgstr "" - -#: ../src/extra/autocompletionUsers/completion.py:49 -#: ../src/extra/autocompletionUsers/completion.py:66 -msgid "There are no results in your users database" -msgstr "" - -#: ../src/extra/autocompletionUsers/completion.py:51 -msgid "Autocompletion only works for users." -msgstr "" - -#: ../src/extra/autocompletionUsers/scan.py:54 -msgid "" -"Updating database... You can close this window now. A message will tell " -"you when the process finishes." -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:9 -msgid "Manage Autocompletion database" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:12 -msgid "Editing {0} users database" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:13 -msgid "Username" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:13 -#: ../src/wxUI/dialogs/configuration.py:151 -msgid "Name" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:16 -msgid "Add user" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:17 -msgid "Remove user" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:38 -msgid "Twitter username" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:38 -msgid "Add user to database" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:44 -msgid "The user does not exist" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_manage.py:44 -#: ../src/wxUI/commonMessageDialogs.py:45 -msgid "Error!" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:8 -msgid "Autocomplete users' settings" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:11 -msgid "Add followers to database" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:12 -msgid "Add friends to database" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:26 -msgid "Updating autocompletion database" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:37 -msgid "" -"This process will retrieve the users you selected from Twitter, and add " -"them to the user autocomplete database. Please note that if there are " -"many users or you have tried to perform this action less than 15 minutes " -"ago, TWBlue may reach a limit in Twitter API calls when trying to load " -"the users into the database. If this happens, we will show you an error, " -"in which case you will have to try this process again in a few minutes. " -"If this process ends with no error, you will be redirected back to the " -"account settings dialog. Do you want to continue?" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:37 -#: ../src/wxUI/commonMessageDialogs.py:36 -#: ../src/wxUI/commonMessageDialogs.py:86 -msgid "Attention" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:43 -msgid "TWBlue has imported {} users successfully." -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:43 -msgid "Done" -msgstr "" - -#: ../src/extra/autocompletionUsers/wx_scan.py:47 -msgid "Error adding users from Twitter. Please try again in about 15 minutes." -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 -msgid "Detect automatically" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:41 -msgid "Danish" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:43 -msgid "Dutch" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:44 -msgid "English" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:48 -msgid "Finnish" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:49 -msgid "French" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:52 -msgid "German" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:58 -msgid "Hungarian" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:68 -msgid "Korean" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:63 -msgid "Italian" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:64 -msgid "Japanese" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:85 -msgid "Polish" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:86 -msgid "Portuguese" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:89 -msgid "Russian" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:96 -msgid "Spanish" -msgstr "" - -#: ../src/extra/ocr/OCRSpace.py:7 ../src/extra/translator/translator.py:105 -msgid "Turkish" -msgstr "" - -#: ../src/extra/translator/translator.py:22 -msgid "Afrikaans" -msgstr "" - -#: ../src/extra/translator/translator.py:23 -msgid "Albanian" -msgstr "" - -#: ../src/extra/translator/translator.py:24 -msgid "Amharic" -msgstr "" - -#: ../src/extra/translator/translator.py:25 -msgid "Arabic" -msgstr "" - -#: ../src/extra/translator/translator.py:26 -msgid "Armenian" -msgstr "" - -#: ../src/extra/translator/translator.py:27 -msgid "Azerbaijani" -msgstr "" - -#: ../src/extra/translator/translator.py:28 -msgid "Basque" -msgstr "" - -#: ../src/extra/translator/translator.py:29 -msgid "Belarusian" -msgstr "" - -#: ../src/extra/translator/translator.py:30 -msgid "Bengali" -msgstr "" - -#: ../src/extra/translator/translator.py:31 -msgid "Bihari" -msgstr "" - -#: ../src/extra/translator/translator.py:32 -msgid "Bulgarian" -msgstr "" - -#: ../src/extra/translator/translator.py:33 -msgid "Burmese" -msgstr "" - -#: ../src/extra/translator/translator.py:34 -msgid "Catalan" -msgstr "" - -#: ../src/extra/translator/translator.py:35 -msgid "Cherokee" -msgstr "" - -#: ../src/extra/translator/translator.py:36 -msgid "Chinese" -msgstr "" - -#: ../src/extra/translator/translator.py:37 -msgid "Chinese_simplified" -msgstr "" - -#: ../src/extra/translator/translator.py:38 -msgid "Chinese_traditional" -msgstr "" - -#: ../src/extra/translator/translator.py:39 -msgid "Croatian" -msgstr "" - -#: ../src/extra/translator/translator.py:40 -msgid "Czech" -msgstr "" - -#: ../src/extra/translator/translator.py:42 -msgid "Dhivehi" -msgstr "" - -#: ../src/extra/translator/translator.py:45 -msgid "Esperanto" -msgstr "" - -#: ../src/extra/translator/translator.py:46 -msgid "Estonian" -msgstr "" - -#: ../src/extra/translator/translator.py:47 -msgid "Filipino" -msgstr "" - -#: ../src/extra/translator/translator.py:50 -msgid "Galician" -msgstr "" - -#: ../src/extra/translator/translator.py:51 -msgid "Georgian" -msgstr "" - -#: ../src/extra/translator/translator.py:53 -msgid "Greek" -msgstr "" - -#: ../src/extra/translator/translator.py:54 -msgid "Guarani" -msgstr "" - -#: ../src/extra/translator/translator.py:55 -msgid "Gujarati" -msgstr "" - -#: ../src/extra/translator/translator.py:56 -msgid "Hebrew" -msgstr "" - -#: ../src/extra/translator/translator.py:57 -msgid "Hindi" -msgstr "" - -#: ../src/extra/translator/translator.py:59 -msgid "Icelandic" -msgstr "" - -#: ../src/extra/translator/translator.py:60 -msgid "Indonesian" -msgstr "" - -#: ../src/extra/translator/translator.py:61 -msgid "Inuktitut" -msgstr "" - -#: ../src/extra/translator/translator.py:62 -msgid "Irish" -msgstr "" - -#: ../src/extra/translator/translator.py:65 -msgid "Kannada" -msgstr "" - -#: ../src/extra/translator/translator.py:66 -msgid "Kazakh" -msgstr "" - -#: ../src/extra/translator/translator.py:67 -msgid "Khmer" -msgstr "" - -#: ../src/extra/translator/translator.py:69 -msgid "Kurdish" -msgstr "" - -#: ../src/extra/translator/translator.py:70 -msgid "Kyrgyz" -msgstr "" - -#: ../src/extra/translator/translator.py:71 -msgid "Laothian" -msgstr "" - -#: ../src/extra/translator/translator.py:72 -msgid "Latvian" -msgstr "" - -#: ../src/extra/translator/translator.py:73 -msgid "Lithuanian" -msgstr "" - -#: ../src/extra/translator/translator.py:74 -msgid "Macedonian" -msgstr "" - -#: ../src/extra/translator/translator.py:75 -msgid "Malay" -msgstr "" - -#: ../src/extra/translator/translator.py:76 -msgid "Malayalam" -msgstr "" - -#: ../src/extra/translator/translator.py:77 -msgid "Maltese" -msgstr "" - -#: ../src/extra/translator/translator.py:78 -msgid "Marathi" -msgstr "" - -#: ../src/extra/translator/translator.py:79 -msgid "Mongolian" -msgstr "" - -#: ../src/extra/translator/translator.py:80 -msgid "Nepali" -msgstr "" - -#: ../src/extra/translator/translator.py:81 -msgid "Norwegian" -msgstr "" - -#: ../src/extra/translator/translator.py:82 -msgid "Oriya" -msgstr "" - -#: ../src/extra/translator/translator.py:83 -msgid "Pashto" -msgstr "" - -#: ../src/extra/translator/translator.py:84 -msgid "Persian" -msgstr "" - -#: ../src/extra/translator/translator.py:87 -msgid "Punjabi" -msgstr "" - -#: ../src/extra/translator/translator.py:88 -msgid "Romanian" -msgstr "" - -#: ../src/extra/translator/translator.py:90 -msgid "Sanskrit" -msgstr "" - -#: ../src/extra/translator/translator.py:91 -msgid "Serbian" -msgstr "" - -#: ../src/extra/translator/translator.py:92 -msgid "Sindhi" -msgstr "" - -#: ../src/extra/translator/translator.py:93 -msgid "Sinhalese" -msgstr "" - -#: ../src/extra/translator/translator.py:94 -msgid "Slovak" -msgstr "" - -#: ../src/extra/translator/translator.py:95 -msgid "Slovenian" -msgstr "" - -#: ../src/extra/translator/translator.py:97 -msgid "Swahili" -msgstr "" - -#: ../src/extra/translator/translator.py:98 -msgid "Swedish" -msgstr "" - -#: ../src/extra/translator/translator.py:99 -msgid "Tajik" -msgstr "" - -#: ../src/extra/translator/translator.py:100 -msgid "Tamil" -msgstr "" - -#: ../src/extra/translator/translator.py:101 -msgid "Tagalog" -msgstr "" - -#: ../src/extra/translator/translator.py:102 -msgid "Telugu" -msgstr "" - -#: ../src/extra/translator/translator.py:103 -msgid "Thai" -msgstr "" - -#: ../src/extra/translator/translator.py:104 -msgid "Tibetan" -msgstr "" - -#: ../src/extra/translator/translator.py:106 -msgid "Ukrainian" -msgstr "" - -#: ../src/extra/translator/translator.py:107 -msgid "Urdu" -msgstr "" - -#: ../src/extra/translator/translator.py:108 -msgid "Uzbek" -msgstr "" - -#: ../src/extra/translator/translator.py:109 -msgid "Uighur" -msgstr "" - -#: ../src/extra/translator/translator.py:110 -msgid "Vietnamese" -msgstr "" - -#: ../src/extra/translator/translator.py:111 -msgid "Welsh" -msgstr "" - -#: ../src/extra/translator/translator.py:112 -msgid "Yiddish" -msgstr "" - -#: ../src/extra/translator/wx_ui.py:29 -msgid "Translate message" -msgstr "" - -#: ../src/extra/translator/wx_ui.py:32 -msgid "Target language" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:8 -msgid "Keystroke editor" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:11 -msgid "Select a keystroke to edit" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:12 -#: ../src/wxUI/dialogs/mastodon/userActions.py:9 -#: ../src/wxUI/dialogs/mastodon/userActions.py:18 -#: ../src/wxUI/dialogs/mastodon/userActions.py:19 -#: ../src/wxUI/dialogs/userActions.py:10 ../src/wxUI/dialogs/userActions.py:19 -#: ../src/wxUI/dialogs/userActions.py:20 +#: src/wxUI/buffers/atprotosocial/panels.py msgid "Action" msgstr "" -#: ../src/keystrokeEditor/wx_ui.py:12 -msgid "Keystroke" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:17 ../src/wxUI/dialogs/filterDialogs.py:135 -#: ../src/wxUI/dialogs/lists.py:20 ../src/wxUI/dialogs/userAliasDialogs.py:53 -msgid "Edit" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:19 ../src/keystrokeEditor/wx_ui.py:49 -msgid "Undefine keystroke" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:20 -msgid "Execute action" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:21 ../src/wxUI/dialogs/configuration.py:421 -#: ../src/wxUI/dialogs/mastodon/configuration.py:170 -#: ../src/wxUI/dialogs/userAliasDialogs.py:25 ../src/wxUI/dialogs/utils.py:39 -msgid "Close" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:41 -msgid "Undefined" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:49 -msgid "Are you sure you want to undefine this keystroke?" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:53 -msgid "Editing keystroke" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:56 -msgid "Control" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:57 -msgid "Alt" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:58 -msgid "Shift" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:59 -msgid "Windows" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:65 -msgid "Key" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:70 ../src/wxUI/dialogs/filterDialogs.py:80 -#: ../src/wxUI/dialogs/find.py:21 ../src/wxUI/dialogs/userAliasDialogs.py:23 -#: ../src/wxUI/dialogs/utils.py:36 -msgid "OK" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:83 -msgid "You need to use the Windows key" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:83 ../src/keystrokeEditor/wx_ui.py:86 -msgid "Invalid keystroke" -msgstr "" - -#: ../src/keystrokeEditor/wx_ui.py:86 -msgid "You must provide a character for the keystroke" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:3 -#: ../src/keystrokeEditor/actions/twitter.py:3 -msgid "Go up in the current buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:4 -#: ../src/keystrokeEditor/actions/twitter.py:4 -msgid "Go down in the current buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:5 -#: ../src/keystrokeEditor/actions/twitter.py:5 -msgid "Go to the previous buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:6 -#: ../src/keystrokeEditor/actions/twitter.py:6 -msgid "Go to the next buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:7 -#: ../src/keystrokeEditor/actions/twitter.py:7 -msgid "Focus the next session" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:8 -#: ../src/keystrokeEditor/actions/twitter.py:8 -msgid "Focus the previous session" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:9 -#: ../src/keystrokeEditor/actions/twitter.py:9 -msgid "Show or hide the GUI" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:10 -msgid "Make a new post" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:12 -#: ../src/wxUI/buffers/mastodon/base.py:25 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:7 -msgid "Boost" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:13 -#: ../src/keystrokeEditor/actions/twitter.py:13 -msgid "Send direct message" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:14 -msgid "Add post to favorites" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:15 -msgid "Remove post from favorites" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:16 -msgid "Add/remove post from favorites" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:17 -#: ../src/keystrokeEditor/actions/twitter.py:17 -msgid "Open the user actions dialogue" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:19 -msgid "Show post" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:20 -#: ../src/keystrokeEditor/actions/twitter.py:20 -msgid "Quit" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:21 -#: ../src/keystrokeEditor/actions/twitter.py:21 -msgid "Open user timeline" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:22 -#: ../src/keystrokeEditor/actions/twitter.py:22 -msgid "Destroy buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:23 -msgid "Interact with the currently focused post." -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:24 -#: ../src/keystrokeEditor/actions/twitter.py:24 -msgid "Open URL" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:25 -msgid "View in browser" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:26 -#: ../src/keystrokeEditor/actions/twitter.py:26 -msgid "Increase volume by 5%" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:27 -#: ../src/keystrokeEditor/actions/twitter.py:27 -msgid "Decrease volume by 5%" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:28 -#: ../src/keystrokeEditor/actions/twitter.py:28 -msgid "Jump to the first element of a buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:29 -#: ../src/keystrokeEditor/actions/twitter.py:29 -msgid "Jump to the last element of the current buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:30 -#: ../src/keystrokeEditor/actions/twitter.py:30 -msgid "Jump 20 elements up in the current buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:31 -#: ../src/keystrokeEditor/actions/twitter.py:31 -msgid "Jump 20 elements down in the current buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:33 -msgid "Delete post" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:34 -#: ../src/keystrokeEditor/actions/twitter.py:34 -msgid "Empty the current buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:35 -#: ../src/keystrokeEditor/actions/twitter.py:35 -msgid "Repeat last item" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:36 -#: ../src/keystrokeEditor/actions/twitter.py:36 -msgid "Copy to clipboard" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:39 -#: ../src/keystrokeEditor/actions/twitter.py:39 -msgid "Mute/unmute the active buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:40 -#: ../src/keystrokeEditor/actions/twitter.py:40 -msgid "Mute/unmute the current session" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:41 -#: ../src/keystrokeEditor/actions/twitter.py:41 -msgid "toggle the automatic reading of incoming tweets in the active buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:42 -msgid "Search on instance" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:43 -#: ../src/keystrokeEditor/actions/twitter.py:43 -msgid "Find a string in the currently focused buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:44 -#: ../src/keystrokeEditor/actions/twitter.py:44 -msgid "Show the keystroke editor" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:46 -#: ../src/keystrokeEditor/actions/twitter.py:46 -msgid "load previous items" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:48 -#: ../src/keystrokeEditor/actions/twitter.py:50 -msgid "View conversation" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:49 -#: ../src/keystrokeEditor/actions/twitter.py:51 -msgid "Check and download updates" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:50 -#: ../src/keystrokeEditor/actions/twitter.py:53 -msgid "Opens the global settings dialogue" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:52 -#: ../src/keystrokeEditor/actions/twitter.py:55 -msgid "Opens the account settings dialogue" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:53 -#: ../src/keystrokeEditor/actions/twitter.py:56 -msgid "Try to play a media file" -msgstr "" - -#: ../src/keystrokeEditor/actions/mastodon.py:54 -#: ../src/keystrokeEditor/actions/twitter.py:57 -msgid "Updates the buffer and retrieves possible lost items there." -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:10 -msgid "New tweet" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:12 -#: ../src/wxUI/buffers/twitter/base.py:26 -#: ../src/wxUI/commonMessageDialogs.py:10 -msgid "Retweet" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:14 -msgid "Like a tweet" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:15 -msgid "Like/unlike a tweet" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:16 -msgid "Unlike a tweet" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:18 -msgid "See user details" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:19 -msgid "Show tweet" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:23 -msgid "Interact with the currently focused tweet." -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:25 -msgid "View in Twitter" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:32 -msgid "Edit profile" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:33 -msgid "Delete a tweet or direct message" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:37 -msgid "Add to list" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:38 -msgid "Remove from list" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:42 -msgid "Search on twitter" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:45 -msgid "Show lists for a specified user" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:47 -msgid "Get geolocation" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:48 -msgid "Display the tweet's geolocation in a dialog" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:49 -msgid "Create a trending topics buffer" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:52 -msgid "" -"Opens the list manager, which allows you to create, edit, delete and open" -" lists in buffers." -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:54 -msgid "Opens the list manager" -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:58 -msgid "Extracts the text from a picture and displays the result in a dialog." -msgstr "" - -#: ../src/keystrokeEditor/actions/twitter.py:59 -msgid "Adds an alias to an user" -msgstr "" - -#: ../src/sessionmanager/sessionManager.py:68 -msgid "{account_name} (Twitter)" -msgstr "" - -#: ../src/sessionmanager/sessionManager.py:73 -msgid "{account_name} (Mastodon)" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:10 -msgid "Session manager" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:13 -msgid "Accounts list" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:15 -msgid "Account" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:19 -msgid "New account" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:21 ../src/sessionmanager/wxUI.py:87 -msgid "Remove account" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:23 -msgid "Global Settings" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:47 -msgid "You need to configure an account." -msgstr "" - -#: ../src/sessionmanager/wxUI.py:47 -msgid "Account Error" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:53 -msgid "Twitter" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:54 -msgid "Mastodon" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:60 -msgid "" -"You will be prompted for your Mastodon data (instance URL, email address " -"and password) so we can authorise TWBlue in your instance. Would you like" -" to authorise your account now?" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:60 ../src/sessionmanager/wxUI.py:67 -msgid "Authorization" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:67 -msgid "" -"The request to authorize your Twitter account will be opened in your " -"browser. You only need to do this once. Would you like to continue?" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:75 -#, python-format -msgid "Authorized account %d" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:81 -msgid "" -"Your access token is invalid or the authorization has failed. Please try " -"again." -msgstr "" - -#: ../src/sessionmanager/wxUI.py:81 -msgid "Invalid user token" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:87 -msgid "Do you really want to delete this account?" -msgstr "" - -#: ../src/sessionmanager/wxUI.py:111 -msgid "" -"TWBlue is unable to authenticate the account for {} in Twitter. It might " -"be due to an invalid or expired token, revoqued access to the " -"application, or after an account reactivation. Please remove the account " -"manually from your Twitter sessions in order to stop seeing this message." -msgstr "" - -#: ../src/sessionmanager/wxUI.py:111 -msgid "Authentication error for session {}" -msgstr "" - -#: ../src/sessions/base.py:125 -msgid "" -"An exception occurred while saving the {app} database. It will be deleted" -" and rebuilt automatically. If this error persists, send the error log to" -" the {app} developers." -msgstr "" - -#: ../src/sessions/base.py:165 -msgid "" -"An exception occurred while loading the {app} database. It will be " -"deleted and rebuilt automatically. If this error persists, send the error" -" log to the {app} developers." -msgstr "" - -#: ../src/sessions/mastodon/compose.py:17 -#: ../src/sessions/mastodon/compose.py:64 -msgid "dddd, MMMM D, YYYY H:m" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:19 -#: ../src/sessions/mastodon/templates.py:80 -#: ../src/sessions/mastodon/templates.py:81 -msgid "Boosted from @{}: {}" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:35 -#: ../src/sessions/mastodon/templates.py:28 -#: ../src/sessions/twitter/compose.py:22 ../src/sessions/twitter/compose.py:62 -#: ../src/sessions/twitter/compose.py:124 -#: ../src/sessions/twitter/compose.py:130 -#: ../src/sessions/twitter/templates.py:26 -msgid "dddd, MMMM D, YYYY H:m:s" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:39 -#, python-format -msgid "%s (@%s). %s followers, %s following, %s posts. Joined %s" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:50 -msgid "Last message from {}: {}" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:67 -msgid "{username} has mentionned you: {status}" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:69 -msgid "{username} has boosted: {status}" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:71 -msgid "{username} has added to favorites: {status}" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:73 -msgid "{username} has followed you." -msgstr "" - -#: ../src/sessions/mastodon/compose.py:75 -#: ../src/sessions/mastodon/templates.py:172 -msgid "A poll in which you have voted has expired: {status}" -msgstr "" - -#: ../src/sessions/mastodon/compose.py:77 -msgid "{username} wants to follow you." -msgstr "" - -#: ../src/sessions/mastodon/session.py:60 -msgid "Please enter your instance URL." -msgstr "" - -#: ../src/sessions/mastodon/session.py:60 -msgid "Mastodon instance" -msgstr "" - -#: ../src/sessions/mastodon/session.py:71 -msgid "" -"We could not connect to your mastodon instance. Please verify that the " -"domain exists and the instance is accessible via a web browser." -msgstr "" - -#: ../src/sessions/mastodon/session.py:71 -msgid "Instance error" -msgstr "" - -#: ../src/sessions/mastodon/session.py:76 -msgid "Enter the verification code" -msgstr "" - -#: ../src/sessions/mastodon/session.py:76 -msgid "PIN code authorization" -msgstr "" - -#: ../src/sessions/mastodon/session.py:85 -msgid "" -"We could not authorice your mastodon account to be used in TWBlue. This " -"might be caused due to an incorrect verification code. Please try to add " -"the session again." -msgstr "" - -#: ../src/sessions/mastodon/session.py:85 -#: ../src/sessions/twitter/session.py:171 -msgid "Authorization error" -msgstr "" - -#: ../src/sessions/mastodon/session.py:182 -#: ../src/sessions/twitter/session.py:206 -#: ../src/sessions/twitter/session.py:233 -#, python-format -msgid "%s failed. Reason: %s" -msgstr "" - -#: ../src/sessions/mastodon/session.py:188 -#: ../src/sessions/twitter/session.py:212 -#: ../src/sessions/twitter/session.py:236 -#, python-format -msgid "%s succeeded." -msgstr "" - -#: ../src/sessions/mastodon/templates.py:18 -#: ../src/sessions/twitter/templates.py:16 -msgid "$display_name, $text $image_descriptions $date. $source" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:19 -#: ../src/sessions/twitter/templates.py:18 -msgid "Dm to $recipient_display_name, $text $date" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:20 -msgid "" -"$display_name (@$screen_name). $followers followers, $following " -"following, $posts posts. Joined $created_at." -msgstr "" - -#: ../src/sessions/mastodon/templates.py:21 -msgid "$display_name $text, $date" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:34 -msgid "Content warning: {}" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:45 -msgid "Image description: {}" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:85 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:48 -msgid "Followers only" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:162 -msgid "has mentionned you: {status}" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:164 -msgid "has boosted: {status}" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:166 -msgid "has added to favorites: {status}" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:168 -msgid "has updated a status: {status}" -msgstr "" - -#: ../src/sessions/mastodon/templates.py:170 -msgid "has followed you." -msgstr "" - -#: ../src/sessions/mastodon/templates.py:174 -msgid "wants to follow you." -msgstr "" - -#: ../src/sessions/mastodon/wxUI.py:6 ../src/sessions/twitter/session.py:162 -#: ../src/sessions/twitter/wxUI.py:6 -msgid "Authorising account..." -msgstr "" - -#: ../src/sessions/mastodon/wxUI.py:9 -msgid "URL of mastodon instance:" -msgstr "" - -#: ../src/sessions/mastodon/wxUI.py:15 -msgid "Email address:" -msgstr "" - -#: ../src/sessions/mastodon/wxUI.py:21 -msgid "Password:" -msgstr "" - -#: ../src/sessions/twitter/compose.py:68 ../src/sessions/twitter/compose.py:70 -#, python-format -msgid "Dm to %s " -msgstr "" - -#: ../src/sessions/twitter/compose.py:109 -msgid "{0}. Quoted tweet from @{1}: {2}" -msgstr "" - -#: ../src/sessions/twitter/compose.py:132 -msgid "Unavailable" -msgstr "" - -#: ../src/sessions/twitter/compose.py:133 -#, python-format -msgid "" -"%s (@%s). %s followers, %s friends, %s tweets. Last tweeted %s. Joined " -"Twitter %s" -msgstr "" - -#: ../src/sessions/twitter/compose.py:137 -msgid "No description available" -msgstr "" - -#: ../src/sessions/twitter/compose.py:141 -msgid "private" -msgstr "" - -#: ../src/sessions/twitter/compose.py:142 -msgid "public" -msgstr "" - -#: ../src/sessions/twitter/session.py:162 ../src/sessions/twitter/wxUI.py:9 -msgid "Enter your PIN code here" -msgstr "" - -#: ../src/sessions/twitter/session.py:171 -msgid "" -"We could not authorice your Twitter account to be used in TWBlue. This " -"might be caused due to an incorrect verification code. Please try to add " -"the session again." -msgstr "" - -#: ../src/sessions/twitter/session.py:440 -#: ../src/sessions/twitter/session.py:523 -msgid "Deleted account" -msgstr "" - -#: ../src/sessions/twitter/templates.py:17 -msgid "$sender_display_name, $text $date" -msgstr "" - -#: ../src/sessions/twitter/templates.py:19 -msgid "" -"$display_name (@$screen_name). $followers followers, $following " -"following, $tweets tweets. Joined Twitter $created_at." -msgstr "" - -#: ../src/sessions/twitter/templates.py:54 -msgid "Image description: {}." -msgstr "" - -#: ../src/sessions/twitter/utils.py:243 -msgid "Sorry, you are not authorised to see this status." -msgstr "" - -#: ../src/sessions/twitter/utils.py:245 -msgid "No status found with that ID" -msgstr "" - -#: ../src/sessions/twitter/utils.py:247 -msgid "Error {0}" -msgstr "" - -#: ../src/sessions/twitter/utils.py:274 -msgid "{user_1}, {user_2} and {all_users} more: {text}" -msgstr "" - -#: ../src/update/wxUpdater.py:11 -#, python-format -msgid "" -"There's a new %s version available, released on %s. Would you like to " -"download it now?\n" -"\n" -" %s version: %s\n" -"\n" -"Changes:\n" -"%s" -msgstr "" - -#: ../src/update/wxUpdater.py:14 -#, python-format -msgid "" -"There's a new %s version available, released on %s. Updates are not " -"automatic in Windows 7, so you would need to visit TWBlue's download " -"website to get the latest version.\n" -"\n" -" %s version: %s\n" -"\n" -"Changes:\n" -"%s" -msgstr "" - -#: ../src/update/wxUpdater.py:16 -#, python-format -msgid "New version for %s" -msgstr "" - -#: ../src/update/wxUpdater.py:23 -msgid "Download in Progress" -msgstr "" - -#: ../src/update/wxUpdater.py:23 -msgid "Downloading the new version..." -msgstr "" - -#: ../src/update/wxUpdater.py:33 -#, python-format -msgid "Updating... %s of %s" -msgstr "" - -#: ../src/update/wxUpdater.py:36 -msgid "" -"The update has been downloaded and installed successfully. Press OK to " -"continue." -msgstr "" - -#: ../src/update/wxUpdater.py:36 -msgid "Done!" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:7 -msgid "" -"This retweet is over 140 characters. Would you like to post it as a " -"mention to the poster with your comments and a link to the original " -"tweet?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:10 -msgid "Would you like to add a comment to this tweet?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:13 -msgid "" -"Do you really want to delete this tweet? It will be deleted from Twitter " -"as well." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:13 ../src/wxUI/dialogs/lists.py:149 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:15 -msgid "Delete" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:16 -msgid "Do you really want to close {0}?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:16 -msgid "Exit" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:20 -msgid " {0} must be restarted for these changes to take effect." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:20 -msgid "Restart {0} " -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:23 -msgid "" -"Are you sure you want to delete this user from the database? This user " -"will not appear in autocomplete results anymore." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:23 -msgid "Confirm" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:26 -msgid "Enter the name of the client : " -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:26 -#: ../src/wxUI/dialogs/configuration.py:267 -msgid "Add client" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:32 -msgid "" -"Do you really want to empty this buffer? It's items will be removed from" -" the list but not from Twitter" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:32 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:31 -msgid "Empty buffer" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:36 -msgid "Do you really want to destroy this buffer?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:42 -msgid "A timeline for this user already exists. You can't open another" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:42 -msgid "Existing timeline" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:45 -msgid "This user has no tweets, so you can't open a timeline for them." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:48 -msgid "" -"This is a protected Twitter user, which means you can't open a timeline " -"using the Streaming API. The user's tweets will not update due to a " -"twitter policy. Do you want to continue?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:48 -#: ../src/wxUI/commonMessageDialogs.py:98 -msgid "Warning" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:51 -msgid "" -"This is a protected user account, you need to follow this user to view " -"their tweets or likes." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:54 -msgid "" -"If you like {0} we need your help to keep it going. Help us by donating " -"to the project. This will help us pay for the server, the domain and some" -" other things to ensure that {0} will be actively maintained. Your " -"donation will give us the means to continue the development of {0}, and " -"to keep {0} free. Would you like to donate now?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:54 -msgid "We need your help" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:58 -msgid "This user has no tweets. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:61 -msgid "This user has no favorited tweets. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:64 -msgid "This user has no followers. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:67 -msgid "This user has no friends. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:71 -msgid "Geolocation data: {0}" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:71 -msgid "Geo data for this tweet" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:74 -msgid "" -"TWBlue has detected that you're running windows 10 and has changed the " -"default keymap to the Windows 10 keymap. It means that some keyboard " -"shorcuts could be different. Please check the keystroke editor by " -"pressing Alt+Win+K to see all available keystrokes for this keymap." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:74 -msgid "Information" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:77 -msgid "You have been blocked from viewing this content" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:80 -msgid "" -"You have been blocked from viewing someone's content. In order to avoid " -"conflicts with the full session, TWBlue will remove the affected " -"timeline." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:83 -msgid "" -"TWBlue cannot load this timeline because the user has been suspended from" -" Twitter." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:86 -msgid "Do you really want to delete this filter?" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:89 -msgid "This filter already exists. Please use a different title" -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:95 -msgid "The configuration file is invalid." -msgstr "" - -#: ../src/wxUI/commonMessageDialogs.py:98 -msgid "" -"{0} quit unexpectedly the last time it was run. If the problem persists, " -"please report it to the {0} developers." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/menus.py:15 ../src/wxUI/menus.py:16 -#: ../src/wxUI/menus.py:36 ../src/wxUI/menus.py:52 -msgid "&Open URL" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/menus.py:17 ../src/wxUI/menus.py:18 -#: ../src/wxUI/menus.py:54 ../src/wxUI/menus.py:87 -msgid "&Open in Twitter" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/menus.py:19 ../src/wxUI/menus.py:20 -#: ../src/wxUI/menus.py:38 ../src/wxUI/menus.py:56 -msgid "&Play audio" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/menus.py:23 ../src/wxUI/menus.py:24 -#: ../src/wxUI/menus.py:42 ../src/wxUI/menus.py:60 ../src/wxUI/menus.py:70 -#: ../src/wxUI/menus.py:89 ../src/wxUI/menus.py:103 -msgid "&Copy to clipboard" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/menus.py:27 ../src/wxUI/menus.py:28 -#: ../src/wxUI/menus.py:46 ../src/wxUI/menus.py:91 -msgid "&User actions..." -msgstr "" - -#: ../src/wxUI/menus.py:40 -msgid "&Show direct message" -msgstr "" - -#: ../src/wxUI/menus.py:68 -msgid "&Show event" -msgstr "" - -#: ../src/wxUI/menus.py:78 -msgid "Direct &message" -msgstr "" - -#: ../src/wxUI/menus.py:85 -msgid "&Show user" -msgstr "" - -#: ../src/wxUI/buffers/twitter/trends.py:20 ../src/wxUI/menus.py:97 -msgid "Search topic" -msgstr "" - -#: ../src/wxUI/menus.py:99 -msgid "&Tweet about this trend" -msgstr "" - -#: ../src/wxUI/menus.py:101 -msgid "&Show item" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:36 ../src/wxUI/view.py:24 -msgid "&Global settings" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:37 ../src/wxUI/view.py:23 -msgid "Account se&ttings" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:38 -msgid "Update &profile" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:39 -msgid "&Show / hide" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:40 ../src/wxUI/view.py:73 -msgid "&Documentation" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:41 -msgid "Check for &updates" -msgstr "" - -#: ../src/wxUI/sysTrayIcon.py:42 -msgid "&Exit" -msgstr "" - -#: ../src/wxUI/view.py:16 -msgid "&Manage accounts" -msgstr "" - -#: ../src/wxUI/view.py:18 -msgid "&Hide window" -msgstr "" - -#: ../src/wxUI/view.py:22 -msgid "&Edit keystrokes" -msgstr "" - -#: ../src/wxUI/view.py:25 -msgid "E&xit" -msgstr "" - -#: ../src/wxUI/view.py:50 -msgid "V&iew likes" -msgstr "" - -#: ../src/wxUI/view.py:54 -msgid "&Update buffer" -msgstr "" - -#: ../src/wxUI/view.py:58 -msgid "Find a string in the currently focused buffer..." -msgstr "" - -#: ../src/wxUI/view.py:59 -msgid "&Load previous items" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:21 -#: ../src/wxUI/dialogs/userActions.py:22 ../src/wxUI/view.py:61 -msgid "&Mute" -msgstr "" - -#: ../src/wxUI/view.py:62 -msgid "&Autoread" -msgstr "" - -#: ../src/wxUI/view.py:63 -msgid "&Clear buffer" -msgstr "" - -#: ../src/wxUI/view.py:64 -msgid "&Destroy" -msgstr "" - -#: ../src/wxUI/view.py:68 -msgid "&Seek back 5 seconds" -msgstr "" - -#: ../src/wxUI/view.py:69 -msgid "&Seek forward 5 seconds" -msgstr "" - -#: ../src/wxUI/view.py:74 -msgid "Sounds &tutorial" -msgstr "" - -#: ../src/wxUI/view.py:75 -msgid "&What's new in this version?" -msgstr "" - -#: ../src/wxUI/view.py:76 -msgid "&Check for updates" -msgstr "" - -#: ../src/wxUI/view.py:77 -msgid "&Report an error" -msgstr "" - -#: ../src/wxUI/view.py:78 -msgid "{0}'s &website" -msgstr "" - -#: ../src/wxUI/view.py:79 -msgid "Get soundpacks for TWBlue" -msgstr "" - -#: ../src/wxUI/view.py:80 -msgid "About &{0}" -msgstr "" - -#: ../src/wxUI/view.py:83 -msgid "&Application" -msgstr "" - -#: ../src/wxUI/view.py:84 -msgid "&Item" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:10 -#: ../src/wxUI/dialogs/userActions.py:11 ../src/wxUI/view.py:85 -msgid "&User" -msgstr "" - -#: ../src/wxUI/view.py:86 -msgid "&Buffer" -msgstr "" - -#: ../src/wxUI/view.py:87 -msgid "&Audio" -msgstr "" - -#: ../src/wxUI/view.py:88 -msgid "&Help" -msgstr "" - -#: ../src/wxUI/view.py:174 -msgid "Address" -msgstr "" - -#: ../src/wxUI/view.py:205 -msgid "Your {0} version is up to date" -msgstr "" - -#: ../src/wxUI/view.py:205 -msgid "Update" -msgstr "" - -#: ../src/wxUI/buffers/panels.py:12 ../src/wxUI/buffers/panels.py:20 -msgid "Login" -msgstr "" - -#: ../src/wxUI/buffers/panels.py:14 -msgid "Log in automatically" -msgstr "" - -#: ../src/wxUI/buffers/panels.py:22 -msgid "Logout" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:11 -#: ../src/wxUI/buffers/mastodon/conversationList.py:11 -#: ../src/wxUI/buffers/mastodon/user.py:8 -#: ../src/wxUI/buffers/twitter/base.py:12 -#: ../src/wxUI/buffers/twitter/people.py:12 -#: ../src/wxUI/buffers/twitter/user_searches.py:11 -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:10 -#: ../src/wxUI/dialogs/userAliasDialogs.py:14 -#: ../src/wxUI/dialogs/userSelection.py:11 ../src/wxUI/dialogs/utils.py:32 -msgid "User" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:11 -#: ../src/wxUI/buffers/mastodon/conversationList.py:11 -#: ../src/wxUI/buffers/mastodon/notifications.py:11 -#: ../src/wxUI/buffers/twitter/base.py:12 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:36 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:47 -msgid "Text" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:11 -#: ../src/wxUI/buffers/mastodon/conversationList.py:11 -#: ../src/wxUI/buffers/mastodon/notifications.py:11 -#: ../src/wxUI/buffers/twitter/base.py:12 -#: ../src/wxUI/buffers/twitter/events.py:14 -msgid "Date" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:11 -#: ../src/wxUI/buffers/mastodon/conversationList.py:11 -#: ../src/wxUI/buffers/twitter/base.py:12 -msgid "Client" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:27 -msgid "Favorite" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:28 -msgid "Bookmark" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/base.py:29 -#: ../src/wxUI/buffers/twitter/base.py:28 -msgid "Direct message" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/notifications.py:23 -#: ../src/wxUI/dialogs/mastodon/dialogs.py:23 -msgid "Dismiss" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/user.py:19 -#: ../src/wxUI/dialogs/userAliasDialogs.py:48 -msgid "Actions" -msgstr "" - -#: ../src/wxUI/buffers/mastodon/user.py:20 -msgid "Message" -msgstr "" - -#: ../src/wxUI/buffers/twitter/events.py:14 -msgid "Event" -msgstr "" - -#: ../src/wxUI/buffers/twitter/events.py:16 -msgid "Remove event" -msgstr "" - -#: ../src/wxUI/buffers/twitter/trends.py:9 -msgid "Trending topic" -msgstr "" - -#: ../src/wxUI/buffers/twitter/trends.py:19 -msgid "Tweet about this trend" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:15 -msgid "Language" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:22 -msgid "Run {0} at Windows startup" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:23 -msgid "ask before exiting {0}" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:26 -msgid "Disable Streaming functions" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:29 -msgid "Buffer update interval, in minutes" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:35 -msgid "Play a sound when {0} launches" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:37 -msgid "Speak a message when {0} launches" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:39 -msgid "Use invisible interface's keyboard shortcuts while GUI is visible" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:41 -msgid "Activate Sapi5 when any other screen reader is not being run" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:43 -msgid "Hide GUI on launch" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:45 -msgid "Use Codeofdusk's longtweet handlers (may decrease client performance)" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:47 -msgid "Remember state for mention all and long tweet" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:50 -msgid "Keymap" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:55 -msgid "Check for updates when {0} launches" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:65 -msgid "Proxy type: " -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:72 -msgid "Proxy server: " -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:78 -msgid "Port: " -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:84 -msgid "User: " -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:90 -msgid "Password: " -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:102 -#: ../src/wxUI/dialogs/mastodon/configuration.py:14 -msgid "User autocompletion settings" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:103 -msgid "" -"Scan account and add friends and followers to the user autocompletion " -"database" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:104 -#: ../src/wxUI/dialogs/mastodon/configuration.py:17 -msgid "Manage autocompletion database" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:109 -#: ../src/wxUI/dialogs/mastodon/configuration.py:23 -msgid "Relative timestamps" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:112 -#: ../src/wxUI/dialogs/mastodon/configuration.py:26 -msgid "Items on each API call" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:118 -msgid "" -"Inverted buffers: The newest tweets will be shown at the beginning while " -"the oldest at the end" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:120 -msgid "Retweet mode" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:126 -#: ../src/wxUI/dialogs/mastodon/configuration.py:36 -msgid "Show screen names instead of full names" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:128 -#: ../src/wxUI/dialogs/mastodon/configuration.py:38 -msgid "hide emojis in usernames" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:130 -#: ../src/wxUI/dialogs/mastodon/configuration.py:40 -msgid "" -"Number of items per buffer to cache in database (0 to disable caching, " -"blank for unlimited)" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:134 -msgid "" -"Load cache for tweets in memory (much faster in big datasets but requires" -" more RAM)" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:141 -msgid "Enable automatic speech feedback" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:143 -msgid "Enable automatic Braille feedback" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:151 -#: ../src/wxUI/dialogs/filterDialogs.py:130 -msgid "Buffer" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:151 -msgid "Status" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:154 -msgid "Show/hide" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:155 -msgid "Move up" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:156 -msgid "Move down" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:166 -#: ../src/wxUI/dialogs/configuration.py:231 -#: ../src/wxUI/dialogs/configuration.py:234 -#: ../src/wxUI/dialogs/configuration.py:239 -msgid "Show" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:168 -#: ../src/wxUI/dialogs/configuration.py:178 -#: ../src/wxUI/dialogs/configuration.py:202 -#: ../src/wxUI/dialogs/configuration.py:232 -msgid "Hide" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:176 -#: ../src/wxUI/dialogs/configuration.py:200 -msgid "Select a buffer first." -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:179 -#: ../src/wxUI/dialogs/configuration.py:203 -msgid "The buffer is hidden, show it first." -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:182 -msgid "The buffer is already at the top of the list." -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:206 -msgid "The buffer is already at the bottom of the list." -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:261 -#: ../src/wxUI/dialogs/configuration.py:402 -msgid "Ignored clients" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:268 -msgid "Remove client" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:292 -#: ../src/wxUI/dialogs/mastodon/configuration.py:63 -msgid "Volume" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:303 -#: ../src/wxUI/dialogs/mastodon/configuration.py:74 -msgid "Session mute" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:305 -#: ../src/wxUI/dialogs/mastodon/configuration.py:76 -msgid "Output device" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:312 -#: ../src/wxUI/dialogs/mastodon/configuration.py:83 -msgid "Input device" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:320 -#: ../src/wxUI/dialogs/mastodon/configuration.py:91 -msgid "Sound pack" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:326 -msgid "Indicate audio tweets with sound" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:328 -msgid "Indicate geotweets with sound" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:330 -msgid "Indicate tweets containing images with sound" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:353 -#: ../src/wxUI/dialogs/mastodon/configuration.py:122 -msgid "Language for OCR" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:359 -msgid "API Key for SndUp" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:374 -msgid "{0} preferences" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:380 -#: ../src/wxUI/dialogs/configuration.py:389 -#: ../src/wxUI/dialogs/mastodon/configuration.py:142 -msgid "General" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:385 -msgid "Proxy" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:394 -#: ../src/wxUI/dialogs/mastodon/configuration.py:147 -msgid "Feedback" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:398 -#: ../src/wxUI/dialogs/mastodon/configuration.py:151 -msgid "Buffers" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:406 -#: ../src/wxUI/dialogs/mastodon/configuration.py:155 -msgid "Templates" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:410 -#: ../src/wxUI/dialogs/mastodon/configuration.py:159 -msgid "Sound" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:414 -#: ../src/wxUI/dialogs/mastodon/configuration.py:163 -msgid "Extras" -msgstr "" - -#: ../src/wxUI/dialogs/configuration.py:419 -#: ../src/wxUI/dialogs/mastodon/configuration.py:168 -msgid "Save" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:13 -msgid "Create a filter for this buffer" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:14 -msgid "Filter title" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:24 -#: ../src/wxUI/dialogs/filterDialogs.py:130 -msgid "Filter by word" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:25 -msgid "Ignore tweets wich contain the following word" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:26 -msgid "Ignore tweets without the following word" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:31 -msgid "word" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:36 -msgid "Allow retweets" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:37 -msgid "Allow quoted tweets" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:38 -msgid "Allow replies" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:46 -msgid "Use this term as a regular expression" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:48 -#: ../src/wxUI/dialogs/filterDialogs.py:130 -msgid "Filter by language" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:49 -msgid "Load tweets in the following languages" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:50 -msgid "Ignore tweets in the following languages" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:51 -msgid "Don't filter by language" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:62 -msgid "Supported languages" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:67 -msgid "Add selected language to filter" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:71 -msgid "Selected languages" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:73 -#: ../src/wxUI/dialogs/filterDialogs.py:137 ../src/wxUI/dialogs/lists.py:21 -#: ../src/wxUI/dialogs/lists.py:132 ../src/wxUI/dialogs/userAliasDialogs.py:57 -msgid "Remove" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:83 ../src/wxUI/dialogs/find.py:23 -msgid "Cancel" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:120 -msgid "You must define a name for the filter before creating it." -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:120 -msgid "Missing filter name" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:127 -msgid "Manage filters" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:129 -msgid "Filters" -msgstr "" - -#: ../src/wxUI/dialogs/filterDialogs.py:130 -msgid "Filter" -msgstr "" - -#: ../src/wxUI/dialogs/find.py:13 -msgid "Find in current buffer" -msgstr "" - -#: ../src/wxUI/dialogs/find.py:14 -msgid "String" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:11 -msgid "Lists manager" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:14 -msgid "List" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:14 ../src/wxUI/dialogs/lists.py:70 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:25 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:134 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:37 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:126 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:173 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:257 -msgid "Description" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:14 -msgid "Owner" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:14 -msgid "Members" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:14 -msgid "mode" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:19 ../src/wxUI/dialogs/lists.py:62 -msgid "Create a new list" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:22 -msgid "Open in buffer" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:52 -#, python-format -msgid "Viewing lists for %s" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:53 -msgid "Subscribe" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:54 -msgid "Unsubscribe" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:65 -msgid "Name (20 characters maximun)" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:75 -msgid "Mode" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:77 -msgid "Private" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:97 -#, python-format -msgid "Editing the list %s" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:108 -msgid "Select a list to add the user" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:109 -msgid "Add" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:131 -msgid "Select a list to remove the user" -msgstr "" - -#: ../src/wxUI/dialogs/lists.py:149 -msgid "Do you really want to delete this list?" -msgstr "" - -#: ../src/wxUI/dialogs/search.py:12 -msgid "Search on Twitter" -msgstr "" - -#: ../src/wxUI/dialogs/search.py:21 -msgid "Tweets" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/search.py:19 ../src/wxUI/dialogs/search.py:22 -#: ../src/wxUI/dialogs/userAliasDialogs.py:43 -msgid "Users" -msgstr "" - -#: ../src/wxUI/dialogs/search.py:29 -msgid "&Language for results: " -msgstr "" - -#: ../src/wxUI/dialogs/search.py:31 ../src/wxUI/dialogs/search.py:55 -msgid "any" -msgstr "" - -#: ../src/wxUI/dialogs/search.py:37 -msgid "Results &type: " -msgstr "" - -#: ../src/wxUI/dialogs/search.py:38 ../src/wxUI/dialogs/search.py:63 -msgid "Mixed" -msgstr "" - -#: ../src/wxUI/dialogs/search.py:38 ../src/wxUI/dialogs/search.py:64 -msgid "Recent" -msgstr "" - -#: ../src/wxUI/dialogs/search.py:38 ../src/wxUI/dialogs/search.py:65 -msgid "Popular" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/search.py:24 -#: ../src/wxUI/dialogs/mastodon/userActions.py:36 -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:30 -#: ../src/wxUI/dialogs/search.py:43 ../src/wxUI/dialogs/trends.py:25 -#: ../src/wxUI/dialogs/userActions.py:41 -#: ../src/wxUI/dialogs/userSelection.py:33 -msgid "&OK" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/search.py:26 -#: ../src/wxUI/dialogs/mastodon/userActions.py:38 -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:32 -#: ../src/wxUI/dialogs/search.py:45 ../src/wxUI/dialogs/show_user.py:19 -#: ../src/wxUI/dialogs/trends.py:27 ../src/wxUI/dialogs/update_profile.py:37 -#: ../src/wxUI/dialogs/userActions.py:43 -#: ../src/wxUI/dialogs/userSelection.py:35 -msgid "&Close" -msgstr "" - -#: ../src/wxUI/dialogs/show_user.py:12 +#: src/wxUI/buffers/atprotosocial/panels.py msgid "Details" msgstr "" -#: ../src/wxUI/dialogs/show_user.py:17 -msgid "&Go to URL" +#: src/wxUI/buffers/atprotosocial/panels.py +msgid "Refreshing recent notifications. True 'load older' for notifications is not yet fully implemented." msgstr "" -#: ../src/wxUI/dialogs/trends.py:10 -msgid "View trending topics" +#: src/sessionmanager/wxUI.py +msgid "ATProtoSocial (Bluesky)" msgstr "" -#: ../src/wxUI/dialogs/trends.py:11 -msgid "Trending topics by" +#: src/sessionmanager/wxUI.py +msgid "You will be prompted for your ATProtoSocial (Bluesky) data (user handle and App Password) to authorize TWBlue. Would you like to authorize your account now?" msgstr "" -#: ../src/wxUI/dialogs/trends.py:12 -msgid "Country" +#: src/sessionmanager/wxUI.py +msgid "ATProtoSocial Authorization" msgstr "" -#: ../src/wxUI/dialogs/trends.py:13 -msgid "City" +#: src/sessionmanager/sessionManager.py +msgid "{handle} (Bluesky)" msgstr "" - -#: ../src/wxUI/dialogs/trends.py:19 ../src/wxUI/dialogs/update_profile.py:18 -msgid "&Location" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:10 -msgid "Update your profile" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:12 -msgid "&Name (50 characters maximum)" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:23 -msgid "&Website" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:28 -msgid "&Bio (160 characters maximum)" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:34 -msgid "Upload a &picture" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:77 -msgid "Upload a picture" -msgstr "" - -#: ../src/wxUI/dialogs/update_profile.py:79 -msgid "Discard image" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:141 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:133 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:289 -#: ../src/wxUI/dialogs/update_profile.py:82 -msgid "Select the picture to be uploaded" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:141 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:133 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:289 -#: ../src/wxUI/dialogs/update_profile.py:82 -msgid "Image files (*.png, *.jpg, *.gif)|*.png; *.jpg; *.gif" -msgstr "" - -#: ../src/wxUI/dialogs/urlList.py:6 -msgid "Select URL" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:13 -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:13 -#: ../src/wxUI/dialogs/userActions.py:14 -#: ../src/wxUI/dialogs/userAliasDialogs.py:13 -#: ../src/wxUI/dialogs/userSelection.py:14 ../src/wxUI/dialogs/utils.py:31 -msgid "&Autocomplete users" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:19 -#: ../src/wxUI/dialogs/userActions.py:20 -msgid "&Follow" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:20 -#: ../src/wxUI/dialogs/userActions.py:21 -msgid "U&nfollow" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:22 -#: ../src/wxUI/dialogs/userActions.py:23 -msgid "Unmu&te" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:23 -#: ../src/wxUI/dialogs/userActions.py:24 -msgid "&Block" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userActions.py:24 -#: ../src/wxUI/dialogs/userActions.py:25 -msgid "Unbl&ock" -msgstr "" - -#: ../src/wxUI/dialogs/userActions.py:26 -msgid "&Report as spam" -msgstr "" - -#: ../src/wxUI/dialogs/userActions.py:27 -msgid "&Ignore tweets from this client" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:18 -msgid "Alias" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:41 -msgid "Edit user aliases" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:50 -msgid "Add alias" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:51 -msgid "Adds a new user alias" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:54 -msgid "Edit the currently focused user Alias." -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:58 -msgid "Remove the currently focused user alias." -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:82 -msgid "Are you sure you want to delete this user alias?" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:82 -msgid "Remove user alias" -msgstr "" - -#: ../src/wxUI/dialogs/userAliasDialogs.py:93 -msgid "User alias" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:9 -#: ../src/wxUI/dialogs/userSelection.py:10 -#, python-format -msgid "Timeline for %s" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:18 -#: ../src/wxUI/dialogs/userSelection.py:19 -msgid "Buffer type" -msgstr "" - -#: ../src/wxUI/dialogs/userSelection.py:20 -msgid "&Tweets" -msgstr "" - -#: ../src/wxUI/dialogs/userSelection.py:21 -msgid "&Likes" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:20 -#: ../src/wxUI/dialogs/userSelection.py:22 -msgid "&Followers" -msgstr "" - -#: ../src/wxUI/dialogs/userSelection.py:23 -msgid "F&riends" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/configuration.py:15 -msgid "" -"Scan account and add followers and following users to the user " -"autocompletion database" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/configuration.py:32 -msgid "" -"Inverted buffers: The newest items will be shown at the beginning while " -"the oldest at the end" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/configuration.py:34 -msgid "Ask confirmation before boosting a post" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/configuration.py:44 -msgid "" -"Load cache for items in memory (much faster in big datasets but requires " -"more RAM)" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/configuration.py:97 -msgid "Indicate audio or video in posts with sound" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/configuration.py:99 -msgid "Indicate posts containing images with sound" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:7 -msgid "Would you like to share this post?" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:15 -msgid "" -"Do you really want to delete this post? It will be deleted from the " -"instance as well." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:23 -msgid "" -"Are you sure you want to dismiss this notification? If you dismiss a " -"mention notification, it also disappears from your mentions buffer. The " -"post is not going to be deleted from the instance, though." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:31 -msgid "" -"Do you really want to empty this buffer? It's items will be removed from" -" the list but not from the instance" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:38 -msgid "This user has no posts. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:43 -msgid "This user has no favorited posts. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:48 -msgid "This user has no followers yet. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/dialogs.py:53 -msgid "This user is not following anyone. {0} can't create a timeline." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/menus.py:13 -msgid "R&emove from favorites" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:19 -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:37 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:32 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:48 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:168 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:252 -msgid "Attachments" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:24 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:36 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:172 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:256 -msgid "Type" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:27 -msgid "Remove Attachment" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:32 -msgid "Post in the thread" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:39 -msgid "Remove post" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:46 -msgid "Visibility" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:51 -msgid "A&dd" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:52 -msgid "Sensitive content" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:57 -msgid "Content warning" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:64 -msgid "Add p&ost" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:68 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:65 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:196 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:235 -msgid "Auto&complete users" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:70 -msgid "Check &spelling" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:72 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:69 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:200 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:272 -msgid "&Translate" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:99 -msgid "Post - {} characters" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:123 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:117 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:218 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:296 -msgid "Image" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:125 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:119 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:220 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:298 -msgid "Video" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:127 -msgid "Audio" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:129 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:121 -msgid "Poll" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:134 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:126 -msgid "please provide a description" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:148 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:140 -msgid "Select the video to be uploaded" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:148 -msgid "Video files (*.mp4, *.mov, *.m4v, *.webm)| *.mp4; *.m4v; *.mov; *.webm" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:154 -msgid "" -"Audio files (*.mp3, *.ogg, *.wav, *.flac, *.opus, *.aac, *.m4a, " -"*.3gp)|*.mp3; *.ogg; *.wav; *.flac; *.opus; *.aac; *.m4a; *.3gp" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:160 -msgid "" -"It is not possible to add more attachments. Please take into account that" -" You can add only a maximum of 4 images, or one audio, video or poll per" -" post. Please remove other attachments before continuing." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:160 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:146 -msgid "Error adding attachment" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:163 -msgid "" -"You can add a poll or media files. In order to add your poll, please " -"remove other attachments first." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:163 -msgid "Error adding poll" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:167 -#, python-format -msgid "Post - %i characters " -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:180 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:321 -msgid "Image description" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:187 -msgid "Privacy" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:192 -msgid "Boosts: " -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:197 -msgid "Favorites: " -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:202 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:342 -msgid "Source: " -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:207 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:347 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:425 -msgid "Date: " -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:219 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:362 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:435 -msgid "Copy link to clipboard" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:221 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:67 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:198 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:270 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:364 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:437 -msgid "Check &spelling..." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:222 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:365 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:440 -msgid "&Translate..." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:223 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:366 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:441 -msgid "C&lose" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:258 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:477 -msgid "Add a poll" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:262 -msgid "Participation time" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "5 minutes" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "30 minutes" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "1 hour" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "6 hours" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "1 day" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "2 days" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "3 days" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "4 days" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "5 days" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "6 days" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:264 -msgid "7 days" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:268 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:488 -msgid "Choices" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:272 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:492 -msgid "Option 1" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:279 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:499 -msgid "Option 2" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:286 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:506 -msgid "Option 3" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:293 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:513 -msgid "Option 4" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:298 -msgid "Allow multiple votes per user" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:301 -msgid "Hide votes count until the poll expires" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:327 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:541 -msgid "Please make sure you have provided at least two options for the poll." -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/postDialogs.py:327 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:541 -msgid "Not enough information" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/search.py:9 -msgid "Search" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/search.py:18 -msgid "Posts" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:19 -msgid "&Posts" -msgstr "" - -#: ../src/wxUI/dialogs/mastodon/userTimeline.py:21 -msgid "Fo&llowing" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:8 -msgid "Edit Template" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:13 -msgid "Edit template" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:17 -msgid "Available variables" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:29 -msgid "Restore template" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:48 -msgid "Restored template to {}." -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:52 -msgid "" -"the template you have specified include variables that do not exists for " -"the object. Please fix the template and try again. For your reference, " -"you can see a list of all available variables in the variables list while" -" editing your template." -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/templateDialogs.py:52 -msgid "Invalid template" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:39 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:175 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:259 -msgid "Delete attachment" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:44 -msgid "Added Tweets" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:51 -msgid "Delete tweet" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:56 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:190 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:264 -msgid "A&dd..." -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:58 -msgid "Add t&weet" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:61 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:192 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:266 -msgid "&Attach audio..." -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:73 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:204 -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:276 -msgid "Sen&d" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:140 -msgid "Video files (*.mp4)|*.mp4" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:146 -msgid "" -"It is not possible to add more attachments. Please make sure your tweet " -"complies with Twitter'S attachment rules. You can add only one video or " -"GIF in every tweet, and a maximum of 4 photos." -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:180 -msgid "&Mention to all" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:233 -msgid "&Recipient" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:304 -#, python-format -msgid "Tweet - %i characters " -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:332 -msgid "Retweets: " -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:337 -msgid "Likes: " -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:410 -msgid "View" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:412 -msgid "Item" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:438 -msgid "&Expand URL" -msgstr "" - -#: ../src/wxUI/dialogs/twitterDialogs/tweetDialogs.py:481 -msgid "Participation time (in days)" -msgstr "" -