diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..98c4b8e9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,342 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +TWBlue is an accessible desktop Mastodon client for Windows, built with Python 3.10 and wxPython. It provides two specialized interfaces optimized for screen reader users to interact with Mastodon instances. The application emphasizes accessibility-first design with keyboard navigation, audio feedback, and screen reader integration. + +## Development Commands + +### Running from Source +```bash +cd src +python main.py +``` + +For development from source, VLC dependencies are loaded from `../windows-dependencies/{arch}/` where arch is x86 or x64. + +### Installing Dependencies +```bash +# Install all Python dependencies +pip install -r requirements.txt + +# Initialize git submodules for Windows dependencies +git submodule init +git submodule update +``` + +### Building +```bash +# Build binary distribution (from src/ directory) +python setup.py build + +# Output will be in src/dist/ +``` + +### Testing +```bash +# Run tests using pytest +pytest + +# Tests are located in src/test/ +``` + +### Generating Documentation +```bash +cd doc +python documentation_importer.py +python generator.py +# Copy generated language folders to src/documentation/ +# Copy license.txt to src/documentation/ +``` + +### Translation Management +```bash +# Extract translation strings (from doc/ directory) +pybabel extract -o twblue.pot --msgid-bugs-address "manuel@manuelcortez.net" --copyright-holder "MCV software" --input-dirs ../src + +# Note: Translations managed via Weblate at https://weblate.mcvsoftware.com +``` + +## Architecture Overview + +TWBlue follows an MVC architecture with distinct separation between data access (Sessions), business logic (Controllers), and presentation (wxUI). + +### Core Components + +#### 1. Session Layer (`src/sessions/`) +Sessions represent authenticated connections to Mastodon instances. They manage API interactions, OAuth2 authentication, and persistent data storage. + +- **Base Session** (`sessions/base.py`): Abstract base class with configuration management, SQLiteDict persistence, and decorators for login/configuration checks +- **Mastodon Session** (`sessions/mastodon/session.py`): Implements Mastodon.py API wrapper, OAuth2 flow, and account credential management +- **Streaming** (`sessions/mastodon/streaming.py`): Real-time event listener that publishes to pub/sub system + +Key patterns: +- Sessions use `@_require_login` and `@_require_configuration` decorators +- Configuration files stored as INI format via configobj in `config/{session_id}/session.conf` +- Persistent data (caches, user lists) stored in `config/{session_id}/cache.db` using SQLiteDict +- Each session has its own sound system instance + +#### 2. Buffer System (`src/controller/buffers/`) +Buffers are the primary data structures for displaying social media content (timelines, mentions, notifications, conversations, etc.). + +**Base Buffer** (`controller/buffers/base/base.py`): +- Links buffer UI (wxPanel) with session (API access) and compose functions (data rendering) +- Handles keyboard events (F5/F6 for volume, Delete for item removal, Return for URLs) +- Manages periodic updates via `start_stream()` function +- Each buffer has a `compose_function` that formats API data for display + +**Mastodon Buffers** (`controller/buffers/mastodon/`): +- `base.py`: Mastodon-specific base buffer with timeline pagination +- `users.py`: Home timeline, mentions buffer +- `community.py`: Local/federated timelines +- `notifications.py`: System notifications +- `conversations.py`: Direct message threads +- `search.py`: Search results + +Buffer lifecycle: +1. Created by mainController when session initializes +2. Added to view (wx.Treebook) +3. Periodically updated via `start_stream()` or real-time via pub/sub events +4. Destroyed when session ends or buffer removed + +#### 3. Controller Layer (`src/controller/`) +Controllers orchestrate application logic and coordinate between sessions, buffers, and UI. + +**Main Controller** (`controller/mainController.py`): +- Manages all active buffers and sessions +- Binds keyboard shortcuts to actions +- Handles pub/sub event subscriptions +- Periodically calls `start_stream()` on visible buffers +- Provides buffer search methods: `search_buffer()`, `get_current_buffer()`, `get_best_buffer()` + +**Specialized Controllers**: +- `settings.py`: Settings dialog management +- `userAlias.py` / `userList.py`: User management features +- `mastodon/handler.py`: Mastodon-specific operations (filters, etc.) + +#### 4. GUI Layer (`src/wxUI/`) +wxPython-based interface with menu-driven navigation and list controls. + +- **Main Frame** (`wxUI/view.py`): Primary window with wx.Treebook for buffers, menu system, system tray integration +- **Buffer Panels** (`wxUI/buffers/`): Panel implementations for each buffer type +- **Dialogs** (`wxUI/dialogs/`): Post composition, settings, user profiles, filters + +#### 5. Pub/Sub Event System +Decoupled communication using PyPubSub 4.0.3. + +Key events: +- `mastodon.status_received`: New post received via streaming +- `mastodon.status_updated`: Post edited +- `mastodon.notification_received`: New notification +- `mastodon.conversation_received`: New DM + +Event flow: +1. Streaming listener receives API event +2. Publishes to topic via `pub.sendMessage()` +3. mainController subscribes to topics and routes to appropriate buffer +4. Buffer updates its display + +#### 6. Session Manager (`src/sessionmanager/`) +Manages session lifecycle (creation, configuration, activation, deletion). + +- `sessionManager.py`: UI for managing multiple accounts +- `manager.py`: Persists session list to global config +- Handles OAuth2 authorization flow for new accounts +- Loads saved sessions on startup + +#### 7. Configuration System (`src/config.py`, `src/config_utils.py`) +Hierarchical configuration with defaults and user overrides. + +- Global config: `config/app-configuration.conf` (defaults in `src/app-configuration.defaults`) +- Session configs: `config/{session_id}/session.conf` (defaults in `src/mastodon.defaults`) +- Keymaps in `src/keymaps/` +- Sound packs in `src/sounds/` + +**Path Management** (`src/paths.py`): +- Portable mode: Config/logs in application directory +- Installed mode: Config/logs in AppData +- Detects installation by presence of `Uninstall.exe` + +#### 8. Accessibility Features +Built for screen reader users from the ground up. + +- `accessible_output2`: Multi-screen reader support (NVDA, JAWS, SAPI, etc.) +- `sound_lib`: Accessible audio playback with spatial audio +- `platform_utils`: OS-specific accessibility hooks +- `output.py`: Unified interface for speech output +- `sound.py`: Sound system with volume control and sound pack management + +#### 9. Keyboard Handling (`src/keyboard_handler/`) +Cross-platform keyboard input with global hotkey support. + +- `wx_handler.py`: wxPython integration +- `global_handler.py`: System-wide hotkeys +- Platform implementations: `windows.py`, `osx.py`, `linux.py` +- `keystrokeEditor/`: UI for customizing shortcuts + +### Application Initialization Flow + +From `src/main.py`: +1. Setup logging to temp directory, then move to permanent location +2. Initialize language handler +3. Load global configuration +4. Setup sound system +5. Setup accessibility output +6. Initialize session manager +7. Load saved sessions or prompt for account creation +8. Create main controller +9. Start main event loop + +### Data Flow Patterns + +#### Real-time Update Flow +``` +Mastodon Streaming API + → sessions/mastodon/streaming.py (StreamListener) + → pub.sendMessage("mastodon.status_received", ...) + → controller/mainController.py (subscriber) + → buffer.add_new_item() + → compose_function(item) + → wxUI update +``` + +#### User Action Flow +``` +Keyboard input + → wx event handler + → buffer.get_event() + → buffer action method (e.g., open_status()) + → session.api_call() + → UI update or pub/sub event +``` + +#### Periodic Update Flow +``` +RepeatingTimer (every N seconds) + → mainController calls buffer.start_stream() + → session.get_timeline_data() + → buffer.put_items_on_list() + → compose_function for each item + → wxUI list control update +``` + +## Key Design Patterns and Conventions + +### Compose Functions +Buffers use compose functions to render API objects as user-readable strings. Located in `sessions/mastodon/compose.py`: + +```python +compose_function(item, db, relative_times, show_screen_names=False, session=None) +# Returns a string representation of the item for display +``` + +### Session Decorators +Sessions use decorators to enforce prerequisites: + +```python +@baseSession._require_login +def post_status(self, text): + # Only executes if self.logged == True + pass + +@baseSession._require_configuration +def get_timeline(self): + # Only executes if self.settings != None + pass +``` + +### Buffer Naming Convention +Buffers have both a `name` (internal identifier) and `account` (associated username): +- `name`: e.g., "home_timeline", "mentions", "notifications" +- `account`: e.g., "user@mastodon.social" +- Buffers are uniquely identified by (name, account) tuple + +### Configuration Hierarchy +1. Default values in `src/*.defaults` files +2. User overrides in `config/*.conf` files +3. Runtime modifications via settings dialogs +4. Written back to user config files on change + +## Important Caveats + +### Platform-Specific Code +- VLC paths must be set via environment variables when running from source (see `main.py`) +- Windows-specific: pywin32, win-inet-pton, winpaths dependencies +- Accessibility output works best on Windows with NVDA/JAWS + +### Threading and Event Handling +- API calls often wrapped in `call_threaded()` to avoid blocking UI +- Streaming runs in background thread and publishes to main thread via pub/sub +- wx events must be handled on main thread + +### Session Lifecycle +- Sessions must be logged in before buffer creation +- Buffers maintain references to sessions via `self.session` +- Destroying a session should destroy all associated buffers +- Session settings auto-save on write via `settings.write()` + +### Buffer Visibility +- Buffers have `invisible` flag for internal/system buffers +- Main controller distinguishes between visible buffers (shown in tree) and invisible buffers (used for data access) +- Empty buffers serve as account placeholders in tree structure + +### Logging and Debugging +- Logs written to temp directory on startup, then moved to permanent location +- Binary builds redirect stdout/stderr to `logs/` directory +- Source builds use console output +- Use `logging.getLogger("module.name")` pattern throughout + +## Build System Details + +### cx_Freeze Configuration (`src/setup.py`) +- Target: Win32GUI (suppresses console window) +- Includes: keymaps, locales, sounds, documentation, icon, config defaults +- Architecture-specific: Loads x86 or x64 dependencies from windows-dependencies submodule +- Special handling for enchant dictionaries, VLC plugins, VC++ redistributables + +### NSIS Installer (`scripts/twblue.nsi`) +- Expects binary distribution in `scripts/twblue64/` +- Creates Start Menu shortcuts, Desktop shortcut (optional) +- Registers uninstaller +- Checks for running instances before install/uninstall + +### CI/CD (`.github/workflows/release.yml`) +- Triggers on version tags (v20*) +- Builds on Windows-latest with Python 3.10 +- Creates both installer (EXE) and portable (ZIP) distributions +- Uploads to GitHub releases + +## Mastodon API Integration + +### Authentication +OAuth2 flow implemented in `sessions/mastodon/session.py`: +1. Create application credentials for instance +2. Request OAuth authorization URL +3. User authorizes in browser +4. Exchange code for access token +5. Store credentials in session config + +### API Client +Uses Mastodon.py 2.1.4 library: +- Instance created with base URL and access token +- Methods: `status_post()`, `timeline()`, `account()`, etc. +- Rate limiting handled by library +- Supports multiple instances simultaneously + +### Streaming API +Real-time updates via `sessions/mastodon/streaming.py`: +- Inherits from `Mastodon.StreamListener` +- Connects to user, public, or hashtag streams +- Runs in background thread +- Events published to main thread via pub/sub + +## Localization + +TWBlue supports 23 languages: +- Translation files in `src/locales/{lang}/LC_MESSAGES/twblue.mo` +- Uses gettext with `_()` function throughout codebase +- Language selection in settings, stored in global config +- Babel for extraction and compilation +- Weblate for translation management diff --git a/debug_config_save.py b/debug_config_save.py new file mode 100644 index 00000000..81364888 --- /dev/null +++ b/debug_config_save.py @@ -0,0 +1,104 @@ + +import sys +import os +import shutil + +# Add src to path +sys.path.insert(0, os.path.join(os.getcwd(), 'src')) + +import config_utils +from configobj import ConfigObj +import logging + +# Setup simple logging +logging.basicConfig(level=logging.DEBUG) + +def test_config_save(): + print("Beginning Config Save Test") + + # 1. Setup paths + config_dir = os.path.join(os.getcwd(), 'test_config_dir') + if os.path.exists(config_dir): + shutil.rmtree(config_dir) + os.mkdir(config_dir) + + session_id = "test_session" + session_dir = os.path.join(config_dir, session_id) + os.mkdir(session_dir) + + config_path = os.path.join(session_dir, "session.conf") + # We use the ACTUAL atproto.defaults from src + spec_path = os.path.join(os.getcwd(), 'src', 'atproto.defaults') + + print(f"Config Path: {config_path}") + print(f"Spec Path: {spec_path}") + + if not os.path.exists(spec_path): + print("ERROR: Spec file not found at", spec_path) + return + + # 2. Simulate Load & Create + print("\n--- Loading Config (create empty) ---") + try: + # Mimic session.get_configuration + config = config_utils.load_config(config_path, spec_path) + except Exception as e: + print("Error loading config:", e) + return + + # 3. Modify Values + print("\n--- Modifying Values ---") + + # Check if section exists, if not, create it + if 'atproto' not in config: + print("Section 'atproto' missing (expected for new file). Using defaults from spec?") + # ConfigObj with spec should automatically have sections if create_empty=True? + # Actually config_utils.load_config sets create_empty=True + + # Let's inspect what we have + print("Current Config Keys:", config.keys()) + + # If section is missing (it might be if file was empty and defaults didn't force creation yet?), force create + if 'atproto' not in config: + print("Creating 'atproto' section manually (simulating what might happen if defaults don't auto-create structure)") + config['atproto'] = {} + + config['atproto']['handle'] = "test_user.bsky.social" + config['atproto']['session_string'] = "fake_session_string_12345" + + print(f"Set handle: {config['atproto']['handle']}") + print(f"Set session_string: {config['atproto']['session_string']}") + + # 4. Write + print("\n--- Writing Config ---") + config.write() + print("Write called.") + + # 5. Read Back from Disk (Raw) + print("\n--- Reading Back (Raw Text) ---") + if os.path.exists(config_path): + with open(config_path, 'r') as f: + content = f.read() + print("File Content:") + print(content) + + if "session_string = fake_session_string_12345" in content: + print("SUCCESS: Session string found in file.") + else: + print("FAILURE: Session string NOT found in file.") + else: + print("FAILURE: File does not exist.") + + # 6. Read Back (using config_utils again) + print("\n--- Reading Back (config_utils) ---") + config2 = config_utils.load_config(config_path, spec_path) + val = config2['atproto']['session_string'] + print(f"Read session_string: {val}") + + if val == "fake_session_string_12345": + print("SUCCESS: Read back correct value.") + else: + print("FAILURE: Read back mismatched value.") + +if __name__ == "__main__": + test_config_save() diff --git a/documentation/source/basic_concepts.rst b/documentation/source/basic_concepts.rst index 684050e4..c51f50cf 100644 --- a/documentation/source/basic_concepts.rst +++ b/documentation/source/basic_concepts.rst @@ -30,10 +30,10 @@ 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. -ATProtoSocial / Bluesky Specific Terms +Blueski / Bluesky Specific Terms ++++++++++++++++++++++++++++++++++++++ -When using the ATProtoSocial (Bluesky) integration, you might encounter these 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. diff --git a/documentation/source/atprotosocial.rst b/documentation/source/blueski.rst similarity index 90% rename from documentation/source/atprotosocial.rst rename to documentation/source/blueski.rst index e2d463e2..e30790dc 100644 --- a/documentation/source/atprotosocial.rst +++ b/documentation/source/blueski.rst @@ -1,12 +1,12 @@ -.. _atprotosocial_bluesky: +.. _blueski_bluesky: ************************************** -ATProtoSocial (Bluesky) Integration +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 an ATProtoSocial Account +Adding a Blueski Account =============================== To connect your Bluesky account to TWBlue, you will need your user **handle** and an **App Password**. @@ -22,7 +22,7 @@ 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 "ATProtoSocial (Bluesky)" from the menu. +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. @@ -31,11 +31,12 @@ Once you have your handle and the App Password: Key Features ============ -Once your ATProtoSocial account is connected, you can use the following features in TWBlue: +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**: - * **Home Timeline (Skyline)**: View posts from users you follow. + * **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. @@ -47,7 +48,7 @@ Once your ATProtoSocial account is connected, you can use the following features * **User Search**: Search for users by their handle or display name. * **Content Warnings**: Create posts with content warnings (sensitive content labels). -Basic Concepts for ATProtoSocial +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. diff --git a/documentation/source/index.rst b/documentation/source/index.rst index e6eba196..16103f8d 100644 --- a/documentation/source/index.rst +++ b/documentation/source/index.rst @@ -16,7 +16,7 @@ This is the user guide for the latest available version of TWBlue. The purpose o system_requirements installation basic_concepts - atprotosocial + blueski usage global_settings credits diff --git a/example_atproto.py b/example_atproto.py new file mode 100644 index 00000000..afcc339e --- /dev/null +++ b/example_atproto.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +"""Simple example of using Blueski session programmatically. + +This is a minimal example showing how to use the Blueski session. +For full testing with wx dialogs, use test_atproto_session.py instead. +""" + +import sys +import os + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from sessions.blueski import session +import logging + +# Setup basic logging +logging.basicConfig(level=logging.INFO) + +def main(): + print("Blueski Session Simple Example") + print("=" * 50) + + # Create session + print("\n1. Creating session...") + s = session.Session(session_id="example_blueski") + + # Try to get configuration (will create folder if needed) + print("2. Loading configuration...") + s.get_configuration() + + # Try to login (will fail if no stored credentials) + print("3. Attempting login...") + try: + s.login() + print(f" ✓ Logged in as: {s.get_name()}") + print(f" User DID: {s.db.get('user_id', 'unknown')}") + + except Exception as e: + print(f" ✗ Login failed: {e}") + print("\n To authorize a new session:") + print(" - Run test_atproto_session.py for GUI-based auth") + print(" - Or manually call s.authorise() after importing wx") + return + + # Show session info + print("\n4. Session information:") + print(f" Logged: {s.logged}") + print(f" Handle: {s.settings['blueski']['handle']}") + print(f" Service: {s.settings['blueski'].get('service_url', '')}") + print(f" Has session_string: {bool(s.settings['blueski']['session_string'])}") + + # Test logout + print("\n5. Testing logout...") + s.logout() + print(f" Logged: {s.logged}") + print(f" Session string cleared: {not s.settings['blueski']['session_string']}") + + print("\n" + "=" * 50) + print("Example complete!") + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index 8b67e553..d74b802d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,45 +1,45 @@ accessible_output2 @ git+https://github.com/accessibleapps/accessible_output2@57bda997d98e87dd78aa049e7021cf777871619b -arrow==1.3.0 -attrs==25.3.0 +arrow==1.4.0 +attrs==25.4.0 backports.functools-lru-cache==2.0.0 -blurhash==1.1.4 -certifi==2025.4.26 +blurhash==1.1.5 +certifi==2026.1.4 chardet==5.2.0 -charset-normalizer==3.4.2 +charset-normalizer==3.4.4 colorama==0.4.6 configobj==5.0.9 -coverage==7.8.0 -cx-Freeze==8.3.0 +coverage==7.13.1 +cx-Freeze==8.5.3 cx-Logging==3.2.1 decorator==5.2.1 demoji==1.1.0 -deepl==1.22.0 +deepl==1.27.0 future==1.0.0 -idna==3.10 -importlib-metadata==8.7.0 -iniconfig==2.1.0 +idna==3.11 +importlib-metadata==8.7.1 +iniconfig==2.3.0 libloader @ git+https://github.com/accessibleapps/libloader@bc94811c095b2e57a036acd88660be9a33260267 libretranslatepy==2.1.4 lief==0.15.1 -Markdown==3.8 -Mastodon.py==2.0.1 -numpy==2.2.3 -oauthlib==3.2.2 +Markdown==3.10 +Mastodon.py==2.1.4 +numpy==2.4.0 +oauthlib==3.3.1 packaging==25.0 -pillow==11.2.1 +pillow==12.1.0 platform_utils @ git+https://github.com/accessibleapps/platform_utils@e0d79f7b399c4ea677a633d2dde9202350d62c38 pluggy==1.6.0 -psutil==7.0.0 -pyenchant==3.2.2 +psutil==7.2.1 +pyenchant==3.3.0 pypiwin32==223 -Pypubsub==4.0.3 +Pypubsub==4.0.7 PySocks==1.7.1 -pytest==8.3.5 +pytest==9.0.2 python-dateutil==2.9.0.post0 python-magic-bin==0.4.14 python-vlc==3.0.21203 -pywin32==310 -requests==2.32.3 +pywin32==311 +requests==2.32.5 requests-oauthlib==2.0.0 requests-toolbelt==1.0.0 rfc3986==2.0.0 @@ -49,11 +49,11 @@ sniffio==1.3.1 sound_lib @ git+https://github.com/accessibleapps/sound_lib@a439f0943fb95ee7b6ba24f51a686f47c4ad66b2 sqlitedict==2.1.0 twitter-text-parser==3.0.0 -types-python-dateutil==2.9.0.20250516 -urllib3==2.4.0 +types-python-dateutil==2.9.0.20251115 +urllib3==2.6.3 win-inet-pton==1.1.0 -winpaths==0.2 -wxPython==4.2.3 +winpaths==0.2 +wxPython==4.2.4 youtube-dl==2021.12.17 -zipp==3.21.0 +zipp==3.23.0 atproto>=0.0.45 diff --git a/src/atprotosocial.defaults b/src/blueski.defaults similarity index 98% rename from src/atprotosocial.defaults rename to src/blueski.defaults index b28a4240..5c1a148f 100644 --- a/src/atprotosocial.defaults +++ b/src/blueski.defaults @@ -1,4 +1,4 @@ -[atprotosocial] +[blueski] handle = string(default="") app_password = string(default="") did = string(default="") diff --git a/src/controller/atprotosocial/__init__.py b/src/controller/blueski/__init__.py similarity index 100% rename from src/controller/atprotosocial/__init__.py rename to src/controller/blueski/__init__.py diff --git a/src/controller/atprotosocial/handler.py b/src/controller/blueski/handler.py similarity index 93% rename from src/controller/atprotosocial/handler.py rename to src/controller/blueski/handler.py index eb0b79d0..86b4e788 100644 --- a/src/controller/atprotosocial/handler.py +++ b/src/controller/blueski/handler.py @@ -24,13 +24,13 @@ class Handler: from pubsub import pub pub.sendMessage("core.create_account", name=name, session_id=session.session_id, logged=True) root_position = controller.view.search(name, name) - # Home timeline only for now + # Discover/home timeline from pubsub import pub pub.sendMessage( "createBuffer", buffer_type="home_timeline", - session_type="atprotosocial", - buffer_title=_("Home"), + session_type="blueski", + buffer_title=_("Discover"), parent_tab=root_position, start=True, kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session) @@ -39,8 +39,8 @@ class Handler: pub.sendMessage( "createBuffer", buffer_type="following_timeline", - session_type="atprotosocial", - buffer_title=_("Following"), + session_type="blueski", + buffer_title=_("Following (Chronological)"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session) @@ -71,7 +71,7 @@ class Handler: current_mode = None ask_default = True if current_mode in (None, "ask") else False - from wxUI.dialogs.atprotosocial.configuration import AccountSettingsDialog + from wxUI.dialogs.blueski.configuration import AccountSettingsDialog dlg = AccountSettingsDialog(controller.view, ask_before_boost=ask_default) resp = dlg.ShowModal() if resp == wx.ID_OK: diff --git a/src/controller/atprotosocial/messages.py b/src/controller/blueski/messages.py similarity index 66% rename from src/controller/atprotosocial/messages.py rename to src/controller/blueski/messages.py index 8785afdf..ddb15184 100644 --- a/src/controller/atprotosocial/messages.py +++ b/src/controller/blueski/messages.py @@ -8,34 +8,34 @@ from typing import Any logger = logging.getLogger(__name__) # This file would typically contain functions to generate complex message bodies or -# interactive components for ATProtoSocial, similar to how it might be done for Mastodon. -# Since ATProtoSocial's interactive features (beyond basic posts) are still evolving +# 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 ATProtoSocial develops a standard for "cards" or interactive messages, +# 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 ATProtoSocial session. + 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 ATProtoSocial account")) if user_profile else _("your ATProtoSocial account") + # 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 *_: {})("atprotosocial").get("handle") + or getattr(getattr(session, "settings", {}), "get", lambda *_: {})("blueski").get("handle") or _("your Bluesky account")) return { - "text": _("Welcome to Approve for ATProtoSocial! Your account {handle} is connected.").format(handle=handle), - # "blocks": [ # If ATProtoSocial supports a block kit like Slack or Discord + "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 ATProtoSocial's equivalent - # "text": _("Welcome to Approve for ATProtoSocial! Your account *{handle}* is connected.").format(handle=handle) + # "type": "mrkdwn", # Or Blueski's equivalent + # "text": _("Welcome to Approve for Blueski! Your account *{handle}* is connected.").format(handle=handle) # } # }, # { @@ -44,7 +44,7 @@ def format_welcome_message(session: Any) -> dict[str, Any]: # { # "type": "button", # "text": {"type": "plain_text", "text": _("Post your first Skeet")}, - # "action_id": "atprotosocial_compose_new_post" # Example action ID + # "action_id": "blueski_compose_new_post" # Example action ID # } # ] # } @@ -65,16 +65,16 @@ def format_error_message(error_description: str, details: str | None = None) -> # ] return message -# More functions could be added here as ATProtoSocial's capabilities become clearer +# 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 ATProtoSocial features. +# - 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: ATProtoSocialSession, post_uri: str, post_content: dict) -> dict[str, Any]: +# def build_post_summary_message(session: BlueskiSession, post_uri: str, post_content: dict) -> dict[str, Any]: # """ -# Builds a summary message for an ATProtoSocial post. +# 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 @@ -88,4 +88,4 @@ def format_error_message(error_description: str, details: str | None = None) -> # # Potentially with "blocks" for richer formatting if the platform supports it # } -logger.info("ATProtoSocial messages module loaded (placeholders).") +logger.info("Blueski messages module loaded (placeholders).") diff --git a/src/controller/atprotosocial/settings.py b/src/controller/blueski/settings.py similarity index 64% rename from src/controller/atprotosocial/settings.py rename to src/controller/blueski/settings.py index 06bf6071..3bd00474 100644 --- a/src/controller/atprotosocial/settings.py +++ b/src/controller/blueski/settings.py @@ -8,29 +8,29 @@ fromapprove.translation import translate as _ if TYPE_CHECKING: fromapprove.config import ConfigSectionProxy - fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted + fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted logger = logging.getLogger(__name__) -# This file is for defining forms and handling for ATProtoSocial-specific settings +# 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 ATProtoSocial, initial settings might be simple (handle, app password), +# For Blueski, initial settings might be simple (handle, app password), # but this structure allows for expansion. -class ATProtoSocialSettingsForm(Form): +class BlueskiSettingsForm(Form): """ - A settings form for ATProtoSocial sessions. + 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 ATProtoSocialSession.get_settings_inputs defines - # and what ATProtoSocialSession.get_configurable_values expects for its config. + # 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 ATProtoSocial PDS instance (e.g., https://bsky.social)."), + # 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( @@ -43,19 +43,19 @@ class ATProtoSocialSettingsForm(Form): description=_("Your Bluesky App Password. Generate this in your Bluesky account settings."), validators=[], # e.g., DataRequired() ) - # Add more fields as needed for ATProtoSocial configuration. + # Add more fields as needed for Blueski configuration. # For example, if there were specific notification settings, content filters, etc. - submit = SubmitField(_("Save ATProtoSocial Settings")) + submit = SubmitField(_("Save Blueski Settings")) async def get_settings_form( user_id: str, - session: ATProtoSocialSession | None = None, - config: ConfigSectionProxy | None = None, # User-specific config for ATProtoSocial -) -> ATProtoSocialSettingsForm: + session: BlueskiSession | None = None, + config: ConfigSectionProxy | None = None, # User-specific config for Blueski +) -> BlueskiSettingsForm: """ - Creates and pre-populates the ATProtoSocial settings form. + Creates and pre-populates the Blueski settings form. """ form_data = {} if session: # If a session exists, use its current config @@ -68,31 +68,31 @@ async def get_settings_form( form_data["handle"] = config.handle.get("") form_data["app_password"] = "" - form = ATProtoSocialSettingsForm(formdata=None, **form_data) # formdata=None for initial display + form = BlueskiSettingsForm(formdata=None, **form_data) # formdata=None for initial display return form async def process_settings_form( - form: ATProtoSocialSettingsForm, + form: BlueskiSettingsForm, user_id: str, - session: ATProtoSocialSession | None = None, # Pass if update should affect live session - config: ConfigSectionProxy | None = None, # User-specific config for ATProtoSocial + session: BlueskiSession | None = None, # Pass if update should affect live session + config: ConfigSectionProxy | None = None, # User-specific config for Blueski ) -> bool: """ - Processes the submitted ATProtoSocial settings form and updates configuration. + 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"ATProtoSocial settings form validation failed for user {user_id}: {form.errors}") + 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.atprotosocial[user_id] # Example path + # 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 ATProtoSocial settings for user {user_id}: no config proxy available.") + logger.error(f"Cannot process Blueski settings for user {user_id}: no config proxy available.") return False try: @@ -101,11 +101,11 @@ async def process_settings_form( await config.handle.set(form.handle.data) await config.app_password.set(form.app_password.data) # Ensure this is stored securely - logger.info(f"ATProtoSocial settings updated for user {user_id}.") + 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 ATProtoSocial session re-initialization for user {user_id} due to settings change.") + 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 @@ -118,11 +118,11 @@ async def process_settings_form( return True except Exception as e: - logger.error(f"Error saving ATProtoSocial settings for user {user_id}: {e}", exc_info=True) + logger.error(f"Error saving Blueski settings for user {user_id}: {e}", exc_info=True) return False -# Any additional ATProtoSocial-specific settings views or handlers would go here. -# For instance, if ATProtoSocial had features like "Relays" or "Feed Generators" +# 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("ATProtoSocial settings module loaded (placeholders).") +logger.info("Blueski settings module loaded (placeholders).") diff --git a/src/controller/atprotosocial/templateEditor.py b/src/controller/blueski/templateEditor.py similarity index 74% rename from src/controller/atprotosocial/templateEditor.py rename to src/controller/blueski/templateEditor.py index bc4a6dd8..8af96f51 100644 --- a/src/controller/atprotosocial/templateEditor.py +++ b/src/controller/blueski/templateEditor.py @@ -7,29 +7,29 @@ from typing import TYPE_CHECKING, Any fromapprove.translation import translate as _ if TYPE_CHECKING: - fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted + fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted logger = logging.getLogger(__name__) -# This file would handle the logic for a template editor specific to ATProtoSocial. +# This file would handle the logic for a template editor specific to Blueski. # A template editor allows users to customize how certain information or messages -# from ATProtoSocial are displayed in Approve. +# from Blueski are displayed in Approve. -# For ATProtoSocial, this might be less relevant initially if its content structure +# For Blueski, this might be less relevant initially if its content structure # is simpler than Mastodon's, or if user-customizable templates are not a primary feature. # However, having the structure allows for future expansion. # Example: Customizing the format of a "new follower" notification, or how a "skeet" is displayed. -class ATProtoSocialTemplateEditor: - def __init__(self, session: ATProtoSocialSession) -> None: +class BlueskiTemplateEditor: + def __init__(self, session: BlueskiSession) -> None: self.session = session # self.user_id = session.user_id - # self.config_prefix = f"sessions.atprotosocial.{self.user_id}.templates." # Example config path + # self.config_prefix = f"sessions.blueski.{self.user_id}.templates." # Example config path def get_editable_templates(self) -> list[dict[str, Any]]: """ - Returns a list of templates that the user can edit for ATProtoSocial. + Returns a list of templates that the user can edit for Blueski. Each entry should describe the template, its purpose, and current value. """ # This would typically fetch template definitions from a default set @@ -40,8 +40,8 @@ class ATProtoSocialTemplateEditor: # { # "id": "new_follower_notification", # Unique ID for this template # "name": _("New Follower Notification Format"), - # "description": _("Customize how new follower notifications from ATProtoSocial are displayed."), - # "default_template": "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on ATProtoSocial!", + # "description": _("Customize how new follower notifications from Blueski are displayed."), + # "default_template": "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on Blueski!", # "current_template": self._get_template_content("new_follower_notification"), # "variables": [ # Available variables for this template # {"name": "actor.displayName", "description": _("Display name of the new follower")}, @@ -50,10 +50,10 @@ class ATProtoSocialTemplateEditor: # ], # "category": "notifications", # For grouping in UI # }, - # # Add more editable templates for ATProtoSocial here + # # Add more editable templates for Blueski here # ] # return templates - return [] # Placeholder - no editable templates defined yet for ATProtoSocial + return [] # Placeholder - no editable templates defined yet for Blueski def _get_template_content(self, template_id: str) -> str: """ @@ -70,7 +70,7 @@ class ATProtoSocialTemplateEditor: """ # This could be hardcoded or loaded from a defaults file. # if template_id == "new_follower_notification": - # return "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on ATProtoSocial!" + # return "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on Blueski!" # # ... other default templates return "" # Placeholder @@ -81,10 +81,10 @@ class ATProtoSocialTemplateEditor: # config_key = self.config_prefix + template_id # try: # await approve.config.config.set_value(config_key, content) # Example config access - # logger.info(f"ATProtoSocial template '{template_id}' saved for user {self.user_id}.") + # logger.info(f"Blueski template '{template_id}' saved for user {self.user_id}.") # return True # except Exception as e: - # logger.error(f"Error saving ATProtoSocial template '{template_id}' for user {self.user_id}: {e}") + # logger.error(f"Error saving Blueski template '{template_id}' for user {self.user_id}: {e}") # return False return False # Placeholder @@ -104,9 +104,9 @@ class ATProtoSocialTemplateEditor: # # return preview # return f"Preview for '{template_id}': {content_to_render}" # Basic placeholder # except Exception as e: - # logger.error(f"Error generating preview for ATProtoSocial template '{template_id}': {e}") + # logger.error(f"Error generating preview for Blueski template '{template_id}': {e}") # return _("Error generating preview.") - return _("Template previews not yet implemented for ATProtoSocial.") # Placeholder + return _("Template previews not yet implemented for Blueski.") # Placeholder def _get_sample_data_for_template(self, template_id: str) -> dict[str, Any]: """ @@ -125,29 +125,29 @@ class ATProtoSocialTemplateEditor: # Functions to be called by the main controller/handler for template editor actions. -async def get_editor_config(session: ATProtoSocialSession) -> dict[str, Any]: +async def get_editor_config(session: BlueskiSession) -> dict[str, Any]: """ - Get the configuration needed to display the template editor for ATProtoSocial. + Get the configuration needed to display the template editor for Blueski. """ - editor = ATProtoSocialTemplateEditor(session) + editor = BlueskiTemplateEditor(session) return { "editable_templates": editor.get_editable_templates(), - "help_text": _("Customize ATProtoSocial message formats. Use variables shown for each template."), + "help_text": _("Customize Blueski message formats. Use variables shown for each template."), } -async def save_template(session: ATProtoSocialSession, template_id: str, content: str) -> bool: +async def save_template(session: BlueskiSession, template_id: str, content: str) -> bool: """ - Save a modified template for ATProtoSocial. + Save a modified template for Blueski. """ - editor = ATProtoSocialTemplateEditor(session) + editor = BlueskiTemplateEditor(session) return await editor.save_template_content(template_id, content) -async def get_template_preview_html(session: ATProtoSocialSession, template_id: str, content: str) -> str: +async def get_template_preview_html(session: BlueskiSession, template_id: str, content: str) -> str: """ Get an HTML preview for a template with given content. """ - editor = ATProtoSocialTemplateEditor(session) + editor = BlueskiTemplateEditor(session) return editor.get_template_preview(template_id, custom_content=content) -logger.info("ATProtoSocial template editor module loaded (placeholders).") +logger.info("Blueski template editor module loaded (placeholders).") diff --git a/src/controller/atprotosocial/userActions.py b/src/controller/blueski/userActions.py similarity index 72% rename from src/controller/atprotosocial/userActions.py rename to src/controller/blueski/userActions.py index 64b4278a..e5c8a745 100644 --- a/src/controller/atprotosocial/userActions.py +++ b/src/controller/blueski/userActions.py @@ -7,32 +7,32 @@ fromapprove.translation import translate as _ # fromapprove.controller.mastodon import userActions as mastodon_user_actions # If adapting if TYPE_CHECKING: - fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted + fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted logger = logging.getLogger(__name__) -# This file defines user-specific actions that can be performed on ATProtoSocial entities, +# This file defines user-specific actions that can be performed on Blueski entities, # typically represented as buttons or links in the UI, often on user profiles or posts. -# For ATProtoSocial, actions might include: -# - Viewing a user's profile on Bluesky/ATProtoSocial instance. +# For Blueski, actions might include: +# - Viewing a user's profile on Bluesky/Blueski instance. # - Following/Unfollowing a user. # - Muting/Blocking a user. # - Reporting a user. # - Fetching a user's latest posts. # These actions are often presented in a context menu or as direct buttons. -# The `get_user_actions` method in the ATProtoSocialSession class would define these. +# The `get_user_actions` method in the BlueskiSession class would define these. # This file would contain the implementation or further handling logic if needed, # or if actions are too complex for simple lambda/method calls in the session class. # Example structure for defining an action: # (This might be more detailed if actions require forms or multi-step processes) -# def view_profile_action(session: ATProtoSocialSession, user_id: str) -> dict[str, Any]: +# def view_profile_action(session: BlueskiSession, user_id: str) -> dict[str, Any]: # """ -# Generates data for a "View Profile on ATProtoSocial" action. -# user_id here would be the ATProtoSocial DID or handle. +# Generates data for a "View Profile on Blueski" action. +# user_id here would be the Blueski DID or handle. # """ # # profile_url = f"https://bsky.app/profile/{user_id}" # Example, construct from handle or DID # # This might involve resolving DID to handle or vice-versa if only one is known. @@ -40,20 +40,20 @@ logger = logging.getLogger(__name__) # # profile_url = f"https://bsky.app/profile/{handle}" # return { -# "id": "atprotosocial_view_profile", +# "id": "blueski_view_profile", # "label": _("View Profile on Bluesky"), # "icon": "external-link-alt", # FontAwesome icon name # "action_type": "link", # "link", "modal", "api_call" # "url": profile_url, # For "link" type -# # "api_endpoint": "/api/atprotosocial/user_action", # For "api_call" +# # "api_endpoint": "/api/blueski/user_action", # For "api_call" # # "payload": {"action": "view_profile", "target_user_id": user_id}, # "confirmation_required": False, # } -# async def follow_user_action_handler(session: ATProtoSocialSession, target_user_id: str) -> dict[str, Any]: +# async def follow_user_action_handler(session: BlueskiSession, target_user_id: str) -> dict[str, Any]: # """ -# Handles the 'follow_user' action for ATProtoSocial. +# Handles the 'follow_user' action for Blueski. # target_user_id should be the DID of the user to follow. # """ # # success = await session.util.follow_user(target_user_id) @@ -65,11 +65,11 @@ logger = logging.getLogger(__name__) # The list of available actions is typically defined in the Session class, -# e.g., ATProtoSocialSession.get_user_actions(). That method would return a list +# e.g., BlueskiSession.get_user_actions(). That method would return a list # of dictionaries, and this file might provide handlers for more complex actions # if they aren't simple API calls defined directly in the session's util. # For now, this file can be a placeholder if most actions are simple enough # to be handled directly by the session.util methods or basic handler routes. -logger.info("ATProtoSocial userActions module loaded (placeholders).") +logger.info("Blueski userActions module loaded (placeholders).") diff --git a/src/controller/atprotosocial/userList.py b/src/controller/blueski/userList.py similarity index 77% rename from src/controller/atprotosocial/userList.py rename to src/controller/blueski/userList.py index 3332ba6d..2320aa24 100644 --- a/src/controller/atprotosocial/userList.py +++ b/src/controller/blueski/userList.py @@ -7,39 +7,39 @@ fromapprove.translation import translate as _ # fromapprove.controller.mastodon import userList as mastodon_user_list # If adapting if TYPE_CHECKING: - fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted - # Define a type for what a user entry in a list might look like for ATProtoSocial - ATProtoSocialUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."} + 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 ATProtoSocial. +# 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 ATProtoSocial supports user lists like Twitter/Mastodon) +# - 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 ATProtoSocial API. +# - A base class or functions for paginating through user lists from the Blueski API. # - Specific functions for each type of user list. -# - Formatting ATProtoSocial user data into a consistent structure for UI display. +# - Formatting Blueski user data into a consistent structure for UI display. async def fetch_followers( - session: ATProtoSocialSession, + session: BlueskiSession, user_id: str, # DID of the user whose followers to fetch limit: int = 20, cursor: str | None = None -) -> AsyncGenerator[ATProtoSocialUserListItem, None]: +) -> AsyncGenerator[BlueskiUserListItem, None]: """ - Asynchronously fetches a list of followers for a given ATProtoSocial user. + 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"ATProtoSocial client not available for fetching followers of {user_id}.") + # logger.warning(f"Blueski client not available for fetching followers of {user_id}.") # return # current_cursor = cursor @@ -80,7 +80,7 @@ async def fetch_followers( """ if not session.is_ready(): - logger.warning(f"Cannot fetch followers for {user_id}: ATProtoSocial session not ready.") + logger.warning(f"Cannot fetch followers for {user_id}: Blueski session not ready.") # yield {} # Stop iteration if not ready return @@ -94,22 +94,22 @@ async def fetch_followers( logger.info(f"No followers data returned for user {user_id}.") except Exception as e: - logger.error(f"Error in fetch_followers for ATProtoSocial user {user_id}: {e}", exc_info=True) + 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: ATProtoSocialSession, + session: BlueskiSession, user_id: str, # DID of the user whose followed accounts to fetch limit: int = 20, cursor: str | None = None -) -> AsyncGenerator[ATProtoSocialUserListItem, None]: +) -> AsyncGenerator[BlueskiUserListItem, None]: """ - Asynchronously fetches a list of users followed by a given ATProtoSocial user. + 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}: ATProtoSocial session not ready.") + logger.warning(f"Cannot fetch following for {user_id}: Blueski session not ready.") return try: @@ -122,21 +122,21 @@ async def fetch_following( logger.info(f"No following data returned for user {user_id}.") except Exception as e: - logger.error(f"Error in fetch_following for ATProtoSocial user {user_id}: {e}", exc_info=True) + logger.error(f"Error in fetch_following for Blueski user {user_id}: {e}", exc_info=True) async def search_users( - session: ATProtoSocialSession, + session: BlueskiSession, query: str, limit: int = 20, cursor: str | None = None -) -> AsyncGenerator[ATProtoSocialUserListItem, None]: +) -> AsyncGenerator[BlueskiUserListItem, None]: """ - Searches for users on ATProtoSocial based on a query string. + 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}': ATProtoSocial session not ready.") + logger.warning(f"Cannot search users for '{query}': Blueski session not ready.") return try: @@ -149,25 +149,25 @@ async def search_users( logger.info(f"No users found for search term '{query}'.") except Exception as e: - logger.error(f"Error in search_users for ATProtoSocial query '{query}': {e}", exc_info=True) + 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: ATProtoSocialSession, + 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[ATProtoSocialUserListItem], str | 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[ATProtoSocialUserListItem] = [] + users_list: list[BlueskiUserListItem] = [] next_cursor: str | None = None if not session.is_ready(): - logger.warning(f"Cannot fetch user list '{list_type}': ATProtoSocial session not ready.") + logger.warning(f"Cannot fetch user list '{list_type}': Blueski session not ready.") return [], None try: @@ -192,13 +192,13 @@ async def get_user_list_paginated( return users_list, next_cursor -async def get_user_profile_details(session: ATProtoSocialSession, user_ident: str) -> ATProtoSocialUserListItem | None: +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}: ATProtoSocial session not ready.") + logger.warning(f"Cannot get profile for {user_ident}: Blueski session not ready.") return None try: @@ -222,4 +222,4 @@ async def get_user_profile_details(session: ATProtoSocialSession, user_ident: st # 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("ATProtoSocial userList module loaded (placeholders).") +logger.info("Blueski userList module loaded (placeholders).") diff --git a/src/controller/buffers/mastodon/base.py b/src/controller/buffers/mastodon/base.py index 0783d9cb..2deff612 100644 --- a/src/controller/buffers/mastodon/base.py +++ b/src/controller/buffers/mastodon/base.py @@ -280,6 +280,12 @@ class BaseBuffer(base.Buffer): return menu = menus.base() widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply) + # Enable/disable edit based on whether the post belongs to the user + item = self.get_item() + if item and item.account.id == self.session.db["user_id"] and item.reblog == None: + widgetUtils.connect_event(menu, widgetUtils.MENU, self.edit_status, menuitem=menu.edit) + else: + menu.edit.Enable(False) widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions) if self.can_share() == True: widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost) @@ -501,6 +507,49 @@ class BaseBuffer(base.Buffer): log.exception("") self.session.db[self.name] = items + def edit_status(self, event=None, item=None, *args, **kwargs): + if item == None: + item = self.get_item() + # Check if the post belongs to the current user + if item.account.id != self.session.db["user_id"] or item.reblog != None: + output.speak(_("You can only edit your own posts.")) + return + # Check if post has a poll with votes - warn user before proceeding + if hasattr(item, 'poll') and item.poll is not None: + votes_count = item.poll.votes_count if hasattr(item.poll, 'votes_count') else 0 + if votes_count > 0: + # Show confirmation dialog + warning_title = _("Warning: Poll with votes") + warning_message = _("This post contains a poll with {votes} votes.\n\n" + "According to Mastodon's API, editing this post will reset ALL votes to zero, " + "even if you don't modify the poll itself.\n\n" + "Do you want to continue editing?").format(votes=votes_count) + dialog = wx.MessageDialog(self.buffer, warning_message, warning_title, + wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING) + result = dialog.ShowModal() + dialog.Destroy() + if result != wx.ID_YES: + output.speak(_("Edit cancelled")) + return + # Log item info for debugging + log.debug("Editing status: id={}, has_media_attachments={}, media_count={}".format( + item.id, + hasattr(item, 'media_attachments'), + len(item.media_attachments) if hasattr(item, 'media_attachments') else 0 + )) + # Create edit dialog with existing post data + title = _("Edit post") + caption = _("Edit your post here") + post = messages.editPost(session=self.session, item=item, title=title, caption=caption) + response = post.message.ShowModal() + if response == wx.ID_OK: + post_data = post.get_data() + # Call edit_post method in session + # Note: visibility and language cannot be changed when editing per Mastodon API + call_threaded(self.session.edit_post, post_id=post.post_id, posts=post_data) + if hasattr(post.message, "destroy"): + post.message.destroy() + def user_details(self): item = self.get_item() pass diff --git a/src/controller/buffers/mastodon/notifications.py b/src/controller/buffers/mastodon/notifications.py index 5c142950..cce39f4d 100644 --- a/src/controller/buffers/mastodon/notifications.py +++ b/src/controller/buffers/mastodon/notifications.py @@ -161,6 +161,13 @@ class NotificationsBuffer(BaseBuffer): menu = menus.notification(notification.type) if self.is_post(): widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply) + # Enable/disable edit based on whether the post belongs to the user + if hasattr(menu, 'edit'): + status = self.get_post() + if status and status.account.id == self.session.db["user_id"] and status.reblog == None: + widgetUtils.connect_event(menu, widgetUtils.MENU, self.edit_status, menuitem=menu.edit) + else: + menu.edit.Enable(False) widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions) if self.can_share() == True: widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost) diff --git a/src/controller/mainController.py b/src/controller/mainController.py index 371a1702..84b43744 100644 --- a/src/controller/mainController.py +++ b/src/controller/mainController.py @@ -25,7 +25,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.atprotosocial import handler as ATProtoSocialHandler # Added import +from controller.blueski import handler as BlueskiHandler # Added import from . import settings, userAlias log = logging.getLogger("mainController") @@ -99,8 +99,8 @@ class Controller(object): try: if type == "mastodon": return MastodonHandler.Handler() - if type == "atprotosocial": - return ATProtoSocialHandler.Handler() + if type == "blueski": + return BlueskiHandler.Handler() except Exception: log.exception("Error creating handler for type %s", type) return None @@ -207,8 +207,8 @@ class Controller(object): if handler is None: if type == "mastodon": handler = MastodonHandler.Handler() - elif type == "atprotosocial": - handler = ATProtoSocialHandler.Handler() + elif type == "blueski": + handler = BlueskiHandler.Handler() self.handlers[type] = handler return handler @@ -250,9 +250,9 @@ class Controller(object): for i in sessions.sessions: log.debug("Working on session %s" % (i,)) if sessions.sessions[i].is_logged == False: - # Try auto-login for ATProtoSocial sessions if credentials exist + # Try auto-login for Blueski sessions if credentials exist try: - if getattr(sessions.sessions[i], "type", None) == "atprotosocial": + if getattr(sessions.sessions[i], "type", None) == "blueski": sessions.sessions[i].login() except Exception: log.exception("Auto-login attempt failed for session %s", i) @@ -260,7 +260,7 @@ class Controller(object): self.create_ignored_session_buffer(sessions.sessions[i]) continue # Supported session types - valid_session_types = ["mastodon", "atprotosocial"] + valid_session_types = ["mastodon", "blueski"] if sessions.sessions[i].type in valid_session_types: try: handler = self.get_handler(type=sessions.sessions[i].type) @@ -323,15 +323,15 @@ class Controller(object): 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) and session_type != "atprotosocial": # Allow atprotosocial to be handled separately + 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)) try: buffer_panel_class = None - if session_type == "atprotosocial": - from wxUI.buffers.atprotosocial import panels as ATProtoSocialPanels # Import new panels + if session_type == "blueski": + from wxUI.buffers.blueski import panels as BlueskiPanels # Import new panels if buffer_type == "home_timeline": - buffer_panel_class = ATProtoSocialPanels.ATProtoSocialHomeTimelinePanel + buffer_panel_class = BlueskiPanels.BlueskiHomeTimelinePanel # kwargs for HomeTimelinePanel: parent, name, session # 'name' is buffer_title, 'parent' is self.view.nb # 'session' needs to be fetched based on user_id in kwargs @@ -343,38 +343,38 @@ class Controller(object): if "name" not in kwargs: kwargs["name"] = buffer_title elif buffer_type == "user_timeline": - buffer_panel_class = ATProtoSocialPanels.ATProtoSocialUserTimelinePanel + buffer_panel_class = BlueskiPanels.BlueskiUserTimelinePanel # kwargs for UserTimelinePanel: parent, name, session, target_user_did, target_user_handle if "user_id" in kwargs and "session" not in kwargs: kwargs["session"] = sessions.sessions.get(kwargs["user_id"]) kwargs.pop("user_id", None) if "name" not in kwargs: kwargs["name"] = buffer_title - # target_user_did and target_user_handle must be in kwargs from atprotosocial.Handler + # target_user_did and target_user_handle must be in kwargs from blueski.Handler elif buffer_type == "notifications": - buffer_panel_class = ATProtoSocialPanels.ATProtoSocialNotificationPanel + buffer_panel_class = BlueskiPanels.BlueskiNotificationPanel if "user_id" in kwargs and "session" not in kwargs: kwargs["session"] = sessions.sessions.get(kwargs["user_id"]) kwargs.pop("user_id", None) if "name" not in kwargs: kwargs["name"] = buffer_title - # target_user_did and target_user_handle must be in kwargs from atprotosocial.Handler + # target_user_did and target_user_handle must be in kwargs from blueski.Handler elif buffer_type == "notifications": - buffer_panel_class = ATProtoSocialPanels.ATProtoSocialNotificationPanel + buffer_panel_class = BlueskiPanels.BlueskiNotificationPanel if "user_id" in kwargs and "session" not in kwargs: kwargs["session"] = sessions.sessions.get(kwargs["user_id"]) kwargs.pop("user_id", None) if "name" not in kwargs: kwargs["name"] = buffer_title elif buffer_type == "user_list_followers" or buffer_type == "user_list_following": - buffer_panel_class = ATProtoSocialPanels.ATProtoSocialUserListPanel + buffer_panel_class = BlueskiPanels.BlueskiUserListPanel elif buffer_type == "following_timeline": - buffer_panel_class = ATProtoSocialPanels.ATProtoSocialFollowingTimelinePanel + buffer_panel_class = BlueskiPanels.BlueskiFollowingTimelinePanel # Clean stray keys that this panel doesn't accept kwargs.pop("user_id", None) kwargs.pop("list_type", None) if "name" not in kwargs: kwargs["name"] = buffer_title else: - log.warning(f"Unsupported ATProtoSocial buffer type: {buffer_type}. Falling back to generic.") + log.warning(f"Unsupported Blueski buffer type: {buffer_type}. Falling back to generic.") # Fallback to trying to find it in generic buffers or error available_buffers = getattr(buffers, "base", None) # Or some generic panel module if available_buffers and hasattr(available_buffers, buffer_type): @@ -382,7 +382,7 @@ class Controller(object): elif available_buffers and hasattr(available_buffers, "TimelinePanel"): # Example generic buffer_panel_class = getattr(available_buffers, "TimelinePanel") else: - raise AttributeError(f"ATProtoSocial buffer type {buffer_type} not found in atprotosocial.panels or base panels.") + raise AttributeError(f"Blueski buffer type {buffer_type} not found in blueski.panels or base panels.") else: # Existing logic for other session types available_buffers = getattr(buffers, session_type) if not hasattr(available_buffers, buffer_type): @@ -549,6 +549,15 @@ class Controller(object): buffer = self.search_buffer(buffer.name, buffer.account) buffer.destroy_status() + def edit_post(self, *args, **kwargs): + """ Edits a post in the current buffer. + Users can only edit their own posts.""" + buffer = self.view.get_current_buffer() + if hasattr(buffer, "account"): + buffer = self.search_buffer(buffer.name, buffer.account) + if hasattr(buffer, "edit_status"): + buffer.edit_status() + def exit(self, *args, **kwargs): if config.app["app-settings"]["ask_at_exit"] == True: answer = commonMessageDialogs.exit_dialog(self.view) @@ -598,7 +607,7 @@ class Controller(object): session = buffer.session # Compose for Bluesky (ATProto): dialog with attachments/CW/language - if getattr(session, "type", "") == "atprotosocial": + if getattr(session, "type", "") == "blueski": # In invisible interface, prefer a quick, minimal compose to avoid complex UI if self.showing == False: # Parent=None so it shows even if main window is hidden @@ -620,7 +629,7 @@ class Controller(object): else: dlg.Destroy() return - from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog + from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog dlg = ATPostDialog() if dlg.ShowModal() == wx.ID_OK: text, files, cw_text, langs = dlg.get_payload() @@ -712,7 +721,7 @@ class Controller(object): return session = buffer.session - if getattr(session, "type", "") == "atprotosocial": + if getattr(session, "type", "") == "blueski": if self.showing == False: dlg = wx.TextEntryDialog(None, _("Write your reply:"), _("Reply")) if dlg.ShowModal() == wx.ID_OK: @@ -732,7 +741,7 @@ class Controller(object): else: dlg.Destroy() return - from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog + from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog dlg = ATPostDialog(caption=_("Reply")) if dlg.ShowModal() == wx.ID_OK: text, files, cw_text, langs = dlg.get_payload() @@ -774,7 +783,7 @@ class Controller(object): session = getattr(buffer, "session", None) if not session: return - if getattr(session, "type", "") == "atprotosocial": + if getattr(session, "type", "") == "blueski": item_uri = None if hasattr(buffer, "get_selected_item_id"): item_uri = buffer.get_selected_item_id() @@ -819,7 +828,7 @@ class Controller(object): dlg.Destroy() return - from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog + 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() @@ -865,7 +874,7 @@ class Controller(object): buffer = self.get_current_buffer() if hasattr(buffer, "add_to_favorites"): # Generic buffer method return buffer.add_to_favorites() - elif buffer.session and buffer.session.KIND == "atprotosocial": + 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 like."), True) @@ -894,7 +903,7 @@ class Controller(object): buffer = self.get_current_buffer() if hasattr(buffer, "remove_from_favorites"): # Generic buffer method return buffer.remove_from_favorites() - elif buffer.session and buffer.session.KIND == "atprotosocial": + 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) @@ -1423,9 +1432,9 @@ class Controller(object): def update_buffers(self): for i in self.buffers[:]: if i.session != None and i.session.is_logged == True: - # For ATProtoSocial, initial load is in session.start() or manual. + # For Blueski, initial load is in session.start() or manual. # Periodic updates would need a separate timer or manual refresh via update_buffer. - if i.session.KIND != "atprotosocial": + if i.session.KIND != "blueski": try: i.start_stream(mandatory=True) # This is likely for streaming connections or timed polling within buffer except Exception as err: @@ -1444,7 +1453,7 @@ class Controller(object): async def do_update(): new_ids = [] try: - if session.KIND == "atprotosocial": + if session.KIND == "blueski": if bf.name == f"{session.label} Home": # Assuming buffer name indicates type # Its panel's load_initial_posts calls session.fetch_home_timeline if hasattr(bf, "load_initial_posts"): # Generic for timeline panels @@ -1462,7 +1471,7 @@ class Controller(object): await bf.load_initial_users(limit=config.app["app-settings"].get("items_per_request", 30)) new_ids = [u.get("did") for u in getattr(bf, "user_list_data", []) if isinstance(u,dict)] else: - if hasattr(bf, "start_stream"): # Fallback for non-ATProtoSocial panels or unhandled types + if hasattr(bf, "start_stream"): # Fallback for non-Blueski panels or unhandled types count = bf.start_stream(mandatory=True, avoid_autoreading=True) if count is not None: new_ids = [str(x) for x in range(count)] # Dummy IDs for count else: @@ -1506,14 +1515,14 @@ class Controller(object): # 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 ATProtoSocial, session methods like fetch_home_timeline store their own cursor (e.g., session.home_timeline_cursor) + # 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 session.KIND == "atprotosocial": - if hasattr(bf, "load_more_posts"): # For ATProtoSocialUserTimelinePanel & ATProtoSocialHomeTimelinePanel + if session.KIND == "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 @@ -1530,7 +1539,7 @@ class Controller(object): else: output.speak(_(u"This buffer does not support loading more items in this way."), True) return - else: # For other non-ATProtoSocial session types + else: # For other non-Blueski session types if hasattr(bf, "get_more_items"): return bf.get_more_items() else: @@ -1541,7 +1550,7 @@ class Controller(object): async def do_load_more(): try: - if session.KIND == "atprotosocial": + 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"): @@ -1664,7 +1673,7 @@ class Controller(object): if handler and hasattr(handler, 'user_details'): # The handler's user_details method is responsible for extracting context # (e.g., selected user) from the buffer and displaying the profile. - # For ATProtoSocial, handler.user_details calls the ShowUserProfileDialog. + # For Blueski, handler.user_details calls the ShowUserProfileDialog. # It's an async method, so needs to be called appropriately. async def _show_details(): await handler.user_details(buffer) diff --git a/src/controller/mastodon/handler.py b/src/controller/mastodon/handler.py index d6335fab..a6e1e301 100644 --- a/src/controller/mastodon/handler.py +++ b/src/controller/mastodon/handler.py @@ -48,7 +48,7 @@ class Handler(object): addAlias=_("Add a&lias"), addToList=None, removeFromList=None, - details=_("Show user profile"), + details=_("S&how user profile"), favs=None, # In buffer Menu. community_timeline =_("Create c&ommunity timeline"), diff --git a/src/controller/mastodon/messages.py b/src/controller/mastodon/messages.py index 9e61ab70..2500759a 100644 --- a/src/controller/mastodon/messages.py +++ b/src/controller/mastodon/messages.py @@ -2,6 +2,7 @@ import os import re import wx +import logging import widgetUtils import config import output @@ -14,6 +15,8 @@ from wxUI.dialogs.mastodon import postDialogs from extra.autocompletionUsers import completion from . import userList +log = logging.getLogger("controller.mastodon.messages") + def character_count(post_text, post_cw, character_limit=500): # We will use text for counting character limit only. full_text = post_text+post_cw @@ -262,6 +265,108 @@ class post(messages.basicMessage): visibility_setting = visibility_settings.index(setting) self.message.visibility.SetSelection(setting) +class editPost(post): + def __init__(self, session, item, title, caption, *args, **kwargs): + """ Initialize edit dialog with existing post data. + + Note: Per Mastodon API, visibility and language cannot be changed when editing. + These fields will be displayed but disabled in the UI. + """ + # Extract text from post + if item.reblog != None: + item = item.reblog + text = item.content + # Remove HTML tags from content + import re + text = re.sub('<[^<]+?>', '', text) + # Initialize parent class + super(editPost, self).__init__(session, title, caption, text=text, *args, **kwargs) + # Store the post ID for editing + self.post_id = item.id + # Set visibility (read-only, cannot be changed) + visibility_settings = dict(public=0, unlisted=1, private=2, direct=3) + self.message.visibility.SetSelection(visibility_settings.get(item.visibility, 0)) + self.message.visibility.Enable(False) # Disable as it cannot be edited + # Set language (read-only, cannot be changed) + if item.language: + self.set_language(item.language) + self.message.language.Enable(False) # Disable as it cannot be edited + # Set sensitive content and spoiler + if item.sensitive: + self.message.sensitive.SetValue(True) + if item.spoiler_text: + self.message.spoiler.ChangeValue(item.spoiler_text) + self.message.on_sensitivity_changed() + # Load existing poll (if any) + # Note: You cannot have both media and a poll, so check poll first + if hasattr(item, 'poll') and item.poll is not None: + log.debug("Loading existing poll for post {}".format(self.post_id)) + poll = item.poll + # Extract poll options (just the text, not the votes) + poll_options = [option.title for option in poll.options] + # Calculate expires_in based on current time and expires_at + # For editing, we need to provide a new expiration time + # Since we can't get the original expires_in, use a default or let user configure + # For now, use 1 day (86400 seconds) as default + expires_in = 86400 + if hasattr(poll, 'expires_at') and poll.expires_at and not poll.expired: + # Calculate remaining time if poll hasn't expired + from dateutil import parser as date_parser + import datetime + try: + expires_at = poll.expires_at + if isinstance(expires_at, str): + expires_at = date_parser.parse(expires_at) + now = datetime.datetime.now(datetime.timezone.utc) + remaining = (expires_at - now).total_seconds() + if remaining > 0: + expires_in = int(remaining) + except Exception as e: + log.warning("Could not calculate poll expiration: {}".format(e)) + + poll_info = { + "type": "poll", + "file": "", + "description": _("Poll with {} options").format(len(poll_options)), + "options": poll_options, + "expires_in": expires_in, + "multiple": poll.multiple if hasattr(poll, 'multiple') else False, + "hide_totals": poll.voters_count == 0 if hasattr(poll, 'voters_count') else False + } + self.attachments.append(poll_info) + self.message.add_item(item=[poll_info["file"], poll_info["type"], poll_info["description"]]) + log.debug("Loaded poll with {} options. WARNING: Editing will reset all votes!".format(len(poll_options))) + # Load existing media attachments (only if no poll) + elif hasattr(item, 'media_attachments'): + log.debug("Loading existing media attachments for post {}".format(self.post_id)) + log.debug("Item has media_attachments attribute, count: {}".format(len(item.media_attachments))) + if len(item.media_attachments) > 0: + for media in item.media_attachments: + log.debug("Processing media: id={}, type={}, url={}".format(media.id, media.type, media.url)) + media_info = { + "id": media.id, # Keep the existing media ID + "type": media.type, + "file": media.url, # URL of existing media + "description": media.description or "" + } + # Include focus point if available + if hasattr(media, 'meta') and media.meta and 'focus' in media.meta: + focus = media.meta['focus'] + media_info["focus"] = (focus.get('x'), focus.get('y')) + log.debug("Added focus point: {}".format(media_info["focus"])) + self.attachments.append(media_info) + # Display in the attachment list + display_name = media.url.split('/')[-1] + log.debug("Adding item to UI: name={}, type={}, desc={}".format(display_name, media.type, media.description or "")) + self.message.add_item(item=[display_name, media.type, media.description or ""]) + log.debug("Total attachments loaded: {}".format(len(self.attachments))) + else: + log.debug("media_attachments list is empty") + else: + log.debug("Item has no poll or media attachments") + # Update text processor to reflect the loaded content + self.text_processor() + class viewPost(post): def __init__(self, session, post, offset_hours=0, date="", item_url=""): self.session = session diff --git a/src/extra/autocompletionUsers/wx_manage.py b/src/extra/autocompletionUsers/wx_manage.py index 6f34a681..85455ab4 100644 --- a/src/extra/autocompletionUsers/wx_manage.py +++ b/src/extra/autocompletionUsers/wx_manage.py @@ -13,8 +13,8 @@ class autocompletionManageDialog(widgetUtils.BaseDialog): self.users = widgets.list(panel, _(u"Username"), _(u"Name"), style=wx.LC_REPORT) sizer.Add(label, 0, wx.ALL, 5) sizer.Add(self.users.list, 0, wx.ALL, 5) - self.add = wx.Button(panel, -1, _(u"Add user")) - self.remove = wx.Button(panel, -1, _(u"Remove user")) + self.add = wx.Button(panel, -1, _(u"&Add user")) + self.remove = wx.Button(panel, -1, _(u"&Remove user")) optionsBox = wx.BoxSizer(wx.HORIZONTAL) optionsBox.Add(self.add, 0, wx.ALL, 5) optionsBox.Add(self.remove, 0, wx.ALL, 5) diff --git a/src/keymaps/Chicken Nugget.keymap b/src/keymaps/Chicken Nugget.keymap index aad998a9..9d95a47f 100644 --- a/src/keymaps/Chicken Nugget.keymap +++ b/src/keymaps/Chicken Nugget.keymap @@ -23,6 +23,7 @@ url = string(default="control+win+b") go_home = string(default="control+win+home") go_end = string(default="control+win+end") delete = string(default="control+win+delete") +edit_post = string(default="") clear_buffer = string(default="control+win+shift+delete") repeat_item = string(default="control+win+space") copy_to_clipboard = string(default="control+win+shift+c") diff --git a/src/keymaps/Qwitter.keymap b/src/keymaps/Qwitter.keymap index 3a73baea..7a8bc30a 100644 --- a/src/keymaps/Qwitter.keymap +++ b/src/keymaps/Qwitter.keymap @@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup") go_page_down = string(default="control+win+pagedown") update_profile = string(default="control+win+shift+p") delete = string(default="control+win+delete") +edit_post = string(default="") clear_buffer = string(default="control+win+shift+delete") repeat_item = string(default="control+win+space") copy_to_clipboard = string(default="control+win+shift+c") diff --git a/src/keymaps/Windows 10.keymap b/src/keymaps/Windows 10.keymap index 3e21c51b..426c598f 100644 --- a/src/keymaps/Windows 10.keymap +++ b/src/keymaps/Windows 10.keymap @@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup") go_page_down = string(default="control+win+pagedown") update_profile = string(default="alt+win+p") delete = string(default="alt+win+delete") +edit_post = string(default="") clear_buffer = string(default="alt+win+shift+delete") repeat_item = string(default="alt+win+space") copy_to_clipboard = string(default="alt+win+shift+c") diff --git a/src/keymaps/Windows11.keymap b/src/keymaps/Windows11.keymap index 518118f8..9ed24488 100644 --- a/src/keymaps/Windows11.keymap +++ b/src/keymaps/Windows11.keymap @@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup") go_page_down = string(default="control+win+pagedown") update_profile = string(default="alt+win+p") delete = string(default="alt+win+delete") +edit_post = string(default="") clear_buffer = string(default="alt+win+shift+delete") repeat_item = string(default="control+alt+win+space") copy_to_clipboard = string(default="alt+win+shift+c") diff --git a/src/keymaps/default.keymap b/src/keymaps/default.keymap index 19d9ebc0..8e4bf9b2 100644 --- a/src/keymaps/default.keymap +++ b/src/keymaps/default.keymap @@ -34,6 +34,7 @@ go_page_up = string(default="control+win+pageup") go_page_down = string(default="control+win+pagedown") update_profile = string(default="alt+win+p") delete = string(default="control+win+delete") +edit_post = string(default="") clear_buffer = string(default="control+win+shift+delete") repeat_item = string(default="control+win+space") copy_to_clipboard = string(default="control+win+shift+c") diff --git a/src/locales/pl/LC_MESSAGES/twblue.mo b/src/locales/pl/LC_MESSAGES/twblue.mo index cbe269df..2292b7a9 100644 Binary files a/src/locales/pl/LC_MESSAGES/twblue.mo and b/src/locales/pl/LC_MESSAGES/twblue.mo differ diff --git a/src/locales/pl/LC_MESSAGES/twblue.po b/src/locales/pl/LC_MESSAGES/twblue.po index 6b754dae..b30bc1dc 100644 --- a/src/locales/pl/LC_MESSAGES/twblue.po +++ b/src/locales/pl/LC_MESSAGES/twblue.po @@ -1,22 +1,23 @@ # SOME DESCRIPTIVE TITLE. # Copyright (C) 2019 ORGANIZATION # FIRST AUTHOR , 2019. -# zvonimir stanecic , 2023. +# zvonimir stanecic , 2023, 2025. msgid "" msgstr "" "Project-Id-Version: Tw Blue 0.80\n" "Report-Msgid-Bugs-To: manuel@manuelcortez.net\n" "POT-Creation-Date: 2025-04-13 01:18+0000\n" -"PO-Revision-Date: 2023-04-21 07:45+0000\n" +"PO-Revision-Date: 2025-08-10 16:08+0000\n" "Last-Translator: zvonimir stanecic \n" +"Language-Team: Polish \n" "Language: pl\n" -"Language-Team: Polish " -"\n" -"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && " -"(n%100<10 || n%100>=20) ? 1 : 2;\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " +"|| n%100>=20) ? 1 : 2;\n" +"X-Generator: Weblate 5.10.4\n" "Generated-By: Babel 2.17.0\n" #: languageHandler.py:61 @@ -101,7 +102,7 @@ msgstr "Domyślne dla użytkownika" #: main.py:105 #, fuzzy msgid "https://twblue.mcvsoftware.com/donate" -msgstr "https://twblue.es/donate" +msgstr "https://twblue.mcvsoftware.com/donate" #: main.py:118 #, python-brace-format @@ -246,9 +247,8 @@ msgid "Following for {}" msgstr "Śledzący użytkownika {}" #: controller/messages.py:18 -#, fuzzy msgid "Translated" -msgstr "&Przetłumacz" +msgstr "Przetłumaczono" #: controller/settings.py:60 msgid "System default" @@ -540,9 +540,8 @@ msgid "There are no more items in this buffer." msgstr "W tym buforze nie ma więcej elementów." #: controller/mastodon/handler.py:30 wxUI/dialogs/mastodon/updateProfile.py:35 -#, fuzzy msgid "Update Profile" -msgstr "&Edytuj profil" +msgstr "Zaktualizuj profil" #: controller/mastodon/handler.py:31 wxUI/dialogs/mastodon/search.py:10 #: wxUI/view.py:19 @@ -615,13 +614,12 @@ msgid "Add a&lias" msgstr "Dodaj a&lias" #: controller/mastodon/handler.py:51 -#, fuzzy msgid "Show user profile" -msgstr "&Pokaż profil użytkownika" +msgstr "Pokaż profil użytkownika" #: controller/mastodon/handler.py:54 msgid "Create c&ommunity timeline" -msgstr "" +msgstr "Stwórz &oś czasu społeczności" #: controller/mastodon/handler.py:55 wxUI/view.py:57 msgid "Create a &filter" @@ -647,10 +645,9 @@ msgstr "Wyszukiwanie {}" #: controller/mastodon/handler.py:111 msgid "Communities" -msgstr "" +msgstr "Społeczności" #: controller/mastodon/handler.py:114 -#, fuzzy msgid "federated" msgstr "federowana" @@ -4864,4 +4861,3 @@ msgstr "Dodatki" #~ msgid "DeepL API Key: " #~ msgstr "" - diff --git a/src/sessionmanager/sessionManager.py b/src/sessionmanager/sessionManager.py index 9a74b002..072f6cd1 100644 --- a/src/sessionmanager/sessionManager.py +++ b/src/sessionmanager/sessionManager.py @@ -17,7 +17,7 @@ from pubsub import pub from controller import settings from sessions.mastodon import session as MastodonSession from sessions.gotosocial import session as GotosocialSession -from sessions.atprotosocial import session as ATProtoSocialSession # Import ATProtoSocial session +from sessions.blueski import session as BlueskiSession # Import Blueski session from . import manager from . import wxUI as view @@ -74,21 +74,37 @@ class sessionManagerController(object): 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)) - elif config_test.get("atprotosocial") != None: # Check for ATProtoSocial config - handle = config_test["atprotosocial"].get("handle") - did = config_test["atprotosocial"].get("did") # DID confirms it was authorized + 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="atprotosocial", id=i)) + self.sessions.append(dict(type="blueski", id=i)) else: # Incomplete config, might be an old attempt or error - log.warning(f"Incomplete ATProtoSocial session config found for {i}, skipping.") + log.warning(f"Incomplete Blueski session config found for {i}, skipping.") # Optionally delete malformed config here too try: - log.debug("Deleting incomplete ATProtoSocial session %s" % (i,)) + 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 ATProtoSocial session {i}: {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: @@ -117,14 +133,14 @@ class sessionManagerController(object): s = MastodonSession.Session(i.get("id")) elif i.get("type") == "gotosocial": s = GotosocialSession.Session(i.get("id")) - elif i.get("type") == "atprotosocial": # Handle ATProtoSocial session type - s = ATProtoSocialSession.Session(i.get("id")) + 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 ATProtoSocial, this loads from its specific config file. + # For Blueski, this loads from its specific config file. # Login is now primarily handled by session.start() via mainController, # which calls _ensure_dependencies_ready(). @@ -132,19 +148,19 @@ class sessionManagerController(object): # 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 ATProtoSocial, login is async and handled by session.start() + # # 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 ATProtoSocial so the app starts with buffers ready + # Try to auto-login for Blueski so the app starts with buffers ready try: - if i.get("type") == "atprotosocial": + if i.get("type") == "blueski": s.login() except Exception: - log.exception("Auto-login failed for ATProtoSocial session %s", i.get("id")) + 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 @@ -162,8 +178,8 @@ class sessionManagerController(object): if type == "mastodon": s = MastodonSession.Session(location) - elif type == "atprotosocial": - s = ATProtoSocialSession.Session(location) + elif type == "blueski": + s = BlueskiSession.Session(location) # Add other session types here if needed (e.g., gotosocial) # elif type == "gotosocial": # s = GotosocialSession.Session(location) diff --git a/src/sessionmanager/wxUI.py b/src/sessionmanager/wxUI.py index 178355f8..6075d9eb 100644 --- a/src/sessionmanager/wxUI.py +++ b/src/sessionmanager/wxUI.py @@ -54,8 +54,8 @@ class sessionManagerWindow(wx.Dialog): mastodon = menu.Append(wx.ID_ANY, _("Mastodon")) menu.Bind(wx.EVT_MENU, self.on_new_mastodon_account, mastodon) - atprotosocial = menu.Append(wx.ID_ANY, _("ATProtoSocial (Bluesky)")) - menu.Bind(wx.EVT_MENU, self.on_new_atprotosocial_account, atprotosocial) + blueski = menu.Append(wx.ID_ANY, _("Blueski (Bluesky)")) + menu.Bind(wx.EVT_MENU, self.on_new_blueski_account, blueski) self.PopupMenu(menu, self.new.GetPosition()) @@ -66,12 +66,12 @@ class sessionManagerWindow(wx.Dialog): if response == wx.ID_YES: pub.sendMessage("sessionmanager.new_account", type="mastodon") - def on_new_atprotosocial_account(self, *args, **kwargs): - dlg = wx.MessageDialog(self, _("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?"), _(u"ATProtoSocial Authorization"), wx.YES_NO) + def on_new_blueski_account(self, *args, **kwargs): + dlg = wx.MessageDialog(self, _("You will be prompted for your Blueski (Bluesky) data (user handle and App Password) to authorize TWBlue. Would you like to authorize your account now?"), _(u"Blueski Authorization"), wx.YES_NO) response = dlg.ShowModal() dlg.Destroy() if response == wx.ID_YES: - pub.sendMessage("sessionmanager.new_account", type="atprotosocial") + pub.sendMessage("sessionmanager.new_account", type="blueski") def add_new_session_to_list(self): total = self.list.get_count() diff --git a/src/sessions/atprotosocial/__init__.py b/src/sessions/blueski/__init__.py similarity index 100% rename from src/sessions/atprotosocial/__init__.py rename to src/sessions/blueski/__init__.py diff --git a/src/sessions/atprotosocial/compose.py b/src/sessions/blueski/compose.py similarity index 97% rename from src/sessions/atprotosocial/compose.py rename to src/sessions/blueski/compose.py index 2ef6ee23..4b530eae 100644 --- a/src/sessions/atprotosocial/compose.py +++ b/src/sessions/blueski/compose.py @@ -9,7 +9,7 @@ from approve.translation import translate as _ from approve.util import parse_iso_datetime # For parsing ISO timestamps if TYPE_CHECKING: - from approve.sessions.atprotosocial.session import Session as ATProtoSocialSession + from approve.sessions.blueski.session import Session as BlueskiSession from atproto.xrpc_client import models # For type hinting ATProto models logger = logging.getLogger(__name__) @@ -21,19 +21,19 @@ SUPPORTED_LANG_CHOICES_COMPOSE = { } -class ATProtoSocialCompose: +class BlueskiCompose: MAX_CHARS = 300 MAX_MEDIA_ATTACHMENTS = 4 MAX_LANGUAGES = 3 MAX_IMAGE_SIZE_BYTES = 1_000_000 - def __init__(self, session: ATProtoSocialSession) -> None: + def __init__(self, session: BlueskiSession) -> None: self.session = session self.supported_media_types: list[str] = ["image/jpeg", "image/png"] self.max_image_size_bytes: int = self.MAX_IMAGE_SIZE_BYTES def get_panel_configuration(self) -> dict[str, Any]: - """Returns configuration for the compose panel specific to ATProtoSocial.""" + """Returns configuration for the compose panel specific to Blueski.""" return { "max_chars": self.MAX_CHARS, "max_media_attachments": self.MAX_MEDIA_ATTACHMENTS, @@ -206,7 +206,7 @@ class ATProtoSocialCompose: Args: notif_data: A dictionary representing the notification, - typically from ATProtoSocialSession._handle_*_notification methods + typically from BlueskiSession._handle_*_notification methods which create an approve.notifications.Notification object and then convert it to dict or pass relevant parts. Expected keys: 'title', 'body', 'author_name', 'timestamp_dt', 'kind'. diff --git a/src/sessions/atprotosocial/session.py b/src/sessions/blueski/session.py similarity index 89% rename from src/sessions/atprotosocial/session.py rename to src/sessions/blueski/session.py index e643537d..5475d413 100644 --- a/src/sessions/atprotosocial/session.py +++ b/src/sessions/blueski/session.py @@ -10,7 +10,7 @@ from sessions import session_exceptions as Exceptions import output import application -log = logging.getLogger("sessions.atprotosocialSession") +log = logging.getLogger("sessions.blueskiSession") # Optional import of atproto. Code handles absence gracefully. try: @@ -27,26 +27,45 @@ class Session(base.baseSession): """ name = "Bluesky" - KIND = "atprotosocial" + KIND = "blueski" def __init__(self, *args, **kwargs): super(Session, self).__init__(*args, **kwargs) - self.config_spec = "atprotosocial.defaults" - self.type = "atprotosocial" + self.config_spec = "blueski.defaults" + self.type = "blueski" self.char_limit = 300 self.api = None + 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("atprotosocial", {}).get("handle")) + or (self.settings and self.settings.get("blueski", {}).get("handle")) or (getattr(getattr(self, "api", None), "me", None) and self.api.me.handle) ) if handle: @@ -65,11 +84,12 @@ class Session(base.baseSession): return self.api def login(self, verify_credentials=True): - if self.settings.get("atprotosocial") is None: + self._ensure_settings_namespace() + if self.settings.get("blueski") is None: raise Exceptions.RequireCredentialsSessionError - handle = self.settings["atprotosocial"].get("handle") - app_password = self.settings["atprotosocial"].get("app_password") - session_string = self.settings["atprotosocial"].get("session_string") + 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 @@ -100,10 +120,10 @@ class Session(base.baseSession): self.db["user_name"] = api.me.handle self.db["user_id"] = api.me.did # Persist DID in settings for session manager display - self.settings["atprotosocial"]["did"] = api.me.did + self.settings["blueski"]["did"] = api.me.did # Export session for future reuse try: - self.settings["atprotosocial"]["session_string"] = api.export_session_string() + self.settings["blueski"]["session_string"] = api.export_session_string() except Exception: pass self.settings.write() @@ -114,6 +134,7 @@ class Session(base.baseSession): self.logged = False def authorise(self): + self._ensure_settings_namespace() if self.logged: raise Exceptions.AlreadyAuthorisedError("Already authorised.") # Ask for handle @@ -141,8 +162,8 @@ class Session(base.baseSession): # Create session folder and config, then attempt login self.create_session_folder() self.get_configuration() - self.settings["atprotosocial"]["handle"] = handle - self.settings["atprotosocial"]["app_password"] = app_password + self.settings["blueski"]["handle"] = handle + self.settings["blueski"]["app_password"] = app_password self.settings.write() try: self.login() @@ -159,7 +180,8 @@ class Session(base.baseSession): def get_message_url(self, message_id, context=None): # message_id may be full at:// URI or rkey - handle = self.db.get("user_name") or self.settings["atprotosocial"].get("handle", "") + 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("/") @@ -169,6 +191,7 @@ class Session(base.baseSession): 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. @@ -273,8 +296,8 @@ class Session(base.baseSession): # 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["atprotosocial"].get("handle") - did = self.db.get("user_id") or self.settings["atprotosocial"].get("did") + 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}" diff --git a/src/sessions/atprotosocial/streaming.py b/src/sessions/blueski/streaming.py similarity index 82% rename from src/sessions/atprotosocial/streaming.py rename to src/sessions/blueski/streaming.py index 5237cb74..ebb97920 100644 --- a/src/sessions/atprotosocial/streaming.py +++ b/src/sessions/blueski/streaming.py @@ -5,17 +5,17 @@ import logging from typing import TYPE_CHECKING, Any, Callable, Coroutine if TYPE_CHECKING: - fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + fromapprove.sessions.blueski.session import Session as BlueskiSession logger = logging.getLogger(__name__) -# ATProtoSocial (Bluesky) uses a Firehose model for streaming. +# Blueski (Bluesky) uses a Firehose model for streaming. # This typically involves connecting to a WebSocket endpoint and receiving events. # The atproto SDK provides tools for this. -class ATProtoSocialStreaming: - def __init__(self, session: ATProtoSocialSession, stream_type: str, params: dict[str, Any] | None = None) -> None: +class BlueskiStreaming: + def __init__(self, session: BlueskiSession, stream_type: str, params: dict[str, Any] | None = None) -> None: self.session = session self.stream_type = stream_type # e.g., 'user', 'public', 'hashtag' - will need mapping to Firehose concepts self.params = params or {} @@ -30,19 +30,19 @@ class ATProtoSocialStreaming: # or using a more specific subscription if available for user-level events. async def _connect(self) -> None: - """Internal method to connect to the ATProtoSocial Firehose.""" + """Internal method to connect to the Blueski Firehose.""" # from atproto import AsyncClient # from atproto.firehose import FirehoseSubscribeReposClient, parse_subscribe_repos_message # from atproto.xrpc_client.models import get_or_create, ids, models - logger.info(f"ATProtoSocial streaming: Connecting to Firehose for user {self.session.user_id}, stream type {self.stream_type}") + logger.info(f"Blueski streaming: Connecting to Firehose for user {self.session.user_id}, stream type {self.stream_type}") self._should_stop = False try: # TODO: Replace with actual atproto SDK usage # client = self.session.util.get_client() # Get authenticated client from session utils # if not client or not client.me: # Check if client is authenticated - # logger.error("ATProtoSocial client not authenticated. Cannot start Firehose.") + # logger.error("Blueski client not authenticated. Cannot start Firehose.") # return # self._firehose_client = FirehoseSubscribeReposClient(params=None, base_uri=self.session.api_base_url) # Adjust base_uri if needed @@ -77,7 +77,7 @@ class ATProtoSocialStreaming: # # # await self._handle_event("mention", event_data) # # For now, we'll just log that a message was received - # logger.debug(f"ATProtoSocial Firehose message received: {message.__class__.__name__}") + # logger.debug(f"Blueski Firehose message received: {message.__class__.__name__}") # await self._firehose_client.start(on_message_handler) @@ -91,13 +91,13 @@ class ATProtoSocialStreaming: # mock_event = {"type": "placeholder_event", "data": {"text": "Hello from mock stream"}} # await self._handler(mock_event) # Call the registered handler - logger.info(f"ATProtoSocial streaming: Placeholder loop for {self.session.user_id} stopped.") + logger.info(f"Blueski streaming: Placeholder loop for {self.session.user_id} stopped.") except asyncio.CancelledError: - logger.info(f"ATProtoSocial streaming task for user {self.session.user_id} was cancelled.") + logger.info(f"Blueski streaming task for user {self.session.user_id} was cancelled.") except Exception as e: - logger.error(f"ATProtoSocial streaming error for user {self.session.user_id}: {e}", exc_info=True) + logger.error(f"Blueski streaming error for user {self.session.user_id}: {e}", exc_info=True) # Optional: implement retry logic here or in the start_streaming method if not self._should_stop: await asyncio.sleep(30) # Wait before trying to reconnect (if auto-reconnect is desired) @@ -108,7 +108,7 @@ class ATProtoSocialStreaming: finally: # if self._firehose_client: # await self._firehose_client.stop() - logger.info(f"ATProtoSocial streaming connection closed for user {self.session.user_id}.") + logger.info(f"Blueski streaming connection closed for user {self.session.user_id}.") async def _handle_event(self, event_type: str, data: dict[str, Any]) -> None: @@ -118,31 +118,31 @@ class ATProtoSocialStreaming: if self._handler: try: # The data should be transformed into a common format expected by session.handle_streaming_event - # This is where ATProtoSocial-specific event data is mapped to Approve's internal event structure. - # For example, an ATProtoSocial 'mention' event needs to be structured similarly to + # This is where Blueski-specific event data is mapped to Approve's internal event structure. + # For example, an Blueski 'mention' event needs to be structured similarly to # how a Mastodon 'mention' event would be. await self.session.handle_streaming_event(event_type, data) except Exception as e: - logger.error(f"Error handling ATProtoSocial streaming event type {event_type}: {e}", exc_info=True) + logger.error(f"Error handling Blueski streaming event type {event_type}: {e}", exc_info=True) else: - logger.warning(f"ATProtoSocial streaming: No handler registered for session {self.session.user_id}, event: {event_type}") + logger.warning(f"Blueski streaming: No handler registered for session {self.session.user_id}, event: {event_type}") def start_streaming(self, handler: Callable[[dict[str, Any]], Coroutine[Any, Any, None]]) -> None: """Starts the streaming connection.""" if self._connection_task and not self._connection_task.done(): - logger.warning(f"ATProtoSocial streaming already active for user {self.session.user_id}.") + logger.warning(f"Blueski streaming already active for user {self.session.user_id}.") return self._handler = handler # This handler is what session.py's handle_streaming_event calls self._should_stop = False - logger.info(f"ATProtoSocial streaming: Starting for user {self.session.user_id}, type: {self.stream_type}") + logger.info(f"Blueski streaming: Starting for user {self.session.user_id}, type: {self.stream_type}") self._connection_task = asyncio.create_task(self._connect()) async def stop_streaming(self) -> None: """Stops the streaming connection.""" - logger.info(f"ATProtoSocial streaming: Stopping for user {self.session.user_id}") + logger.info(f"Blueski streaming: Stopping for user {self.session.user_id}") self._should_stop = True # if self._firehose_client: # Assuming the SDK has a stop method # await self._firehose_client.stop() @@ -153,10 +153,10 @@ class ATProtoSocialStreaming: try: await self._connection_task except asyncio.CancelledError: - logger.info(f"ATProtoSocial streaming task successfully cancelled for {self.session.user_id}.") + logger.info(f"Blueski streaming task successfully cancelled for {self.session.user_id}.") self._connection_task = None self._handler = None - logger.info(f"ATProtoSocial streaming stopped for user {self.session.user_id}.") + logger.info(f"Blueski streaming stopped for user {self.session.user_id}.") def is_alive(self) -> bool: """Checks if the streaming connection is currently active.""" @@ -169,7 +169,7 @@ class ATProtoSocialStreaming: def get_params(self) -> dict[str, Any]: return self.params - # TODO: Add methods specific to ATProtoSocial streaming if necessary, + # TODO: Add methods specific to Blueski streaming if necessary, # e.g., methods to modify subscription details on the fly if the API supports it. # For Bluesky Firehose, this might not be applicable as you usually connect and filter client-side. # However, if there were different Firehose endpoints (e.g., one for public posts, one for user-specific events), diff --git a/src/sessions/atprotosocial/templates.py b/src/sessions/blueski/templates.py similarity index 64% rename from src/sessions/atprotosocial/templates.py rename to src/sessions/blueski/templates.py index be441d9e..ce4f81c8 100644 --- a/src/sessions/atprotosocial/templates.py +++ b/src/sessions/blueski/templates.py @@ -6,30 +6,30 @@ from typing import TYPE_CHECKING, Any fromapprove.translation import translate as _ if TYPE_CHECKING: - fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + fromapprove.sessions.blueski.session import Session as BlueskiSession logger = logging.getLogger(__name__) -class ATProtoSocialTemplates: - def __init__(self, session: ATProtoSocialSession) -> None: +class BlueskiTemplates: + def __init__(self, session: BlueskiSession) -> None: self.session = session def get_template_data(self, template_name: str, context: dict[str, Any] | None = None) -> dict[str, Any]: """ - Returns data required for rendering a specific template for ATProtoSocial. + Returns data required for rendering a specific template for Blueski. This method would populate template variables based on the template name and context. """ base_data = { "session_kind": self.session.kind, "session_label": self.session.label, "user_id": self.session.user_id, - # Add any other common data needed by ATProtoSocial templates + # Add any other common data needed by Blueski templates } if context: base_data.update(context) - # TODO: Implement specific data fetching for different ATProtoSocial templates + # TODO: Implement specific data fetching for different Blueski templates # Example: # if template_name == "profile_summary.html": # # profile_info = await self.session.util.get_my_profile_info() # Assuming such a method exists @@ -44,27 +44,27 @@ class ATProtoSocialTemplates: return base_data def get_message_card_template(self) -> str: - """Returns the path to the message card template for ATProtoSocial.""" - # This template would define how a single ATProtoSocial post (or other message type) + """Returns the path to the message card template for Blueski.""" + # This template would define how a single Blueski post (or other message type) # is rendered in a list (e.g., in a timeline or search results). - # return "sessions/atprotosocial/cards/message.html" # Example path + # return "sessions/blueski/cards/message.html" # Example path return "sessions/generic/cards/message_generic.html" # Placeholder, use generic if no specific yet def get_notification_template_map(self) -> dict[str, str]: """ - Returns a map of ATProtoSocial notification types to their respective template paths. + Returns a map of Blueski notification types to their respective template paths. """ - # TODO: Define templates for different ATProtoSocial notification types + # TODO: Define templates for different Blueski notification types # (e.g., mention, reply, new follower, like, repost). # The keys should match the notification types used internally by Approve - # when processing ATProtoSocial events. + # when processing Blueski events. # Example: # return { - # "mention": "sessions/atprotosocial/notifications/mention.html", - # "reply": "sessions/atprotosocial/notifications/reply.html", - # "follow": "sessions/atprotosocial/notifications/follow.html", - # "like": "sessions/atprotosocial/notifications/like.html", # Bluesky uses 'like' - # "repost": "sessions/atprotosocial/notifications/repost.html", # Bluesky uses 'repost' + # "mention": "sessions/blueski/notifications/mention.html", + # "reply": "sessions/blueski/notifications/reply.html", + # "follow": "sessions/blueski/notifications/follow.html", + # "like": "sessions/blueski/notifications/like.html", # Bluesky uses 'like' + # "repost": "sessions/blueski/notifications/repost.html", # Bluesky uses 'repost' # # ... other notification types # } # Using generic templates as placeholders: @@ -77,37 +77,37 @@ class ATProtoSocialTemplates: } def get_settings_template(self) -> str | None: - """Returns the path to the settings template for ATProtoSocial, if any.""" - # This template would be used to render ATProtoSocial-specific settings in the UI. - # return "sessions/atprotosocial/settings.html" + """Returns the path to the settings template for Blueski, if any.""" + # This template would be used to render Blueski-specific settings in the UI. + # return "sessions/blueski/settings.html" return "sessions/generic/settings_auth_password.html" # If using simple handle/password auth def get_user_action_templates(self) -> dict[str, str] | None: """ - Returns a map of user action identifiers to their template paths for ATProtoSocial. + Returns a map of user action identifiers to their template paths for Blueski. User actions are typically buttons or forms displayed on a user's profile. """ - # TODO: Define templates for ATProtoSocial user actions + # TODO: Define templates for Blueski user actions # Example: # return { - # "view_profile_on_bsky": "sessions/atprotosocial/actions/view_profile_button.html", - # "send_direct_message": "sessions/atprotosocial/actions/send_dm_form.html", # If DMs are supported + # "view_profile_on_bsky": "sessions/blueski/actions/view_profile_button.html", + # "send_direct_message": "sessions/blueski/actions/send_dm_form.html", # If DMs are supported # } return None # Placeholder def get_user_list_action_templates(self) -> dict[str, str] | None: """ - Returns a map of user list action identifiers to their template paths for ATProtoSocial. + Returns a map of user list action identifiers to their template paths for Blueski. These actions might appear on lists of users (e.g., followers, following). """ - # TODO: Define templates for ATProtoSocial user list actions + # TODO: Define templates for Blueski user list actions # Example: # return { - # "follow_all_visible": "sessions/atprotosocial/list_actions/follow_all_button.html", + # "follow_all_visible": "sessions/blueski/list_actions/follow_all_button.html", # } return None # Placeholder - # Add any other template-related helper methods specific to ATProtoSocial. + # Add any other template-related helper methods specific to Blueski. # For example, methods to get templates for specific types of content (images, polls) # if they need special rendering. @@ -116,8 +116,8 @@ class ATProtoSocialTemplates: Returns a specific template path for a given message type (e.g., post, reply, quote). This can be useful if different types of messages need distinct rendering beyond the standard card. """ - # TODO: Define specific templates if ATProtoSocial messages have varied structures + # TODO: Define specific templates if Blueski messages have varied structures # that require different display logic. # if message_type == "quote_post": - # return "sessions/atprotosocial/cards/quote_post.html" + # return "sessions/blueski/cards/quote_post.html" return None # Default to standard message card if not specified diff --git a/src/sessions/atprotosocial/utils.py b/src/sessions/blueski/utils.py similarity index 97% rename from src/sessions/atprotosocial/utils.py rename to src/sessions/blueski/utils.py index f3abce6f..b7c0e92c 100644 --- a/src/sessions/atprotosocial/utils.py +++ b/src/sessions/blueski/utils.py @@ -16,7 +16,7 @@ fromapprove.notifications import NotificationError if TYPE_CHECKING: - fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession + fromapprove.sessions.blueski.session import Session as BlueskiSession # Define common type aliases if needed ATUserProfile = models.AppBskyActorDefs.ProfileViewDetailed ATPost = models.AppBskyFeedDefs.PostView @@ -27,8 +27,8 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -class ATProtoSocialUtils: - def __init__(self, session: ATProtoSocialSession) -> None: +class BlueskiUtils: + def __init__(self, session: BlueskiSession) -> None: self.session = session # _own_did and _own_handle are now set by Session.login upon successful authentication # and directly on the util instance. @@ -47,7 +47,7 @@ class ATProtoSocialUtils: self._own_handle = self.session.client.me.handle return self.session.client - logger.warning("ATProtoSocialUtils: Client not available or not authenticated.") + logger.warning("BlueskiUtils: Client not available or not authenticated.") # Optionally, try to trigger re-authentication if appropriate, # but generally, the caller should ensure session is ready. # For example, by calling session.start() or session.authorise() @@ -59,7 +59,7 @@ class ATProtoSocialUtils: """Retrieves the authenticated user's profile information.""" client = await self._get_client() if not client or not self.get_own_did(): # Use getter for _own_did - logger.warning("ATProtoSocial client not available or user DID not known.") + logger.warning("Blueski client not available or user DID not known.") return None try: # client.me should be populated after login by the SDK @@ -78,7 +78,7 @@ class ATProtoSocialUtils: return response return None except AtProtocolError as e: - logger.error(f"Error fetching own ATProtoSocial profile: {e}") + logger.error(f"Error fetching own Blueski profile: {e}") return None def get_own_did(self) -> str | None: @@ -116,13 +116,13 @@ class ATProtoSocialUtils: **kwargs: Any ) -> str | None: # Returns the ATURI of the new post, or None on failure """ - Posts a status (skeet) to ATProtoSocial. + Posts a status (skeet) to Blueski. Handles text, images, replies, quotes, and content warnings (labels). """ client = await self._get_client() if not client: - logger.error("ATProtoSocial client not available for posting.") - raise NotificationError(_("Not connected to ATProtoSocial. Please check your connection settings or log in.")) + logger.error("Blueski client not available for posting.") + raise NotificationError(_("Not connected to Blueski. Please check your connection settings or log in.")) if not self.get_own_did(): logger.error("Cannot post status: User DID not available.") @@ -130,7 +130,7 @@ class ATProtoSocialUtils: try: # Prepare core post record - post_record_data = {'text': text, 'created_at': client.get_current_time_iso()} # SDK handles datetime format + post_record_data = {'text': text, 'createdAt': client.get_current_time_iso()} # SDK handles datetime format if langs: post_record_data['langs'] = langs @@ -227,13 +227,13 @@ class ATProtoSocialUtils: record=final_post_record, ) ) - logger.info(f"Successfully posted to ATProtoSocial. URI: {response.uri}") + logger.info(f"Successfully posted to Blueski. URI: {response.uri}") return response.uri except AtProtocolError as e: - logger.error(f"Error posting status to ATProtoSocial: {e.error} - {e.message}", exc_info=True) + logger.error(f"Error posting status to Blueski: {e.error} - {e.message}", exc_info=True) raise NotificationError(_("Failed to post: {error} - {message}").format(error=e.error or "Error", message=e.message or "Protocol error")) from e except Exception as e: # Catch any other unexpected errors - logger.error(f"Unexpected error posting status to ATProtoSocial: {e}", exc_info=True) + logger.error(f"Unexpected error posting status to Blueski: {e}", exc_info=True) raise NotificationError(_("An unexpected error occurred while posting: {error}").format(error=str(e))) from e @@ -241,7 +241,7 @@ class ATProtoSocialUtils: """Deletes a status (post) given its AT URI.""" client = await self._get_client() if not client: - logger.error("ATProtoSocial client not available for deleting post.") + logger.error("Blueski client not available for deleting post.") return False if not self.get_own_did(): logger.error("Cannot delete status: User DID not available.") @@ -268,10 +268,10 @@ class ATProtoSocialUtils: rkey=rkey, ) ) - logger.info(f"Successfully deleted post {post_uri} from ATProtoSocial.") + logger.info(f"Successfully deleted post {post_uri} from Blueski.") return True except AtProtocolError as e: - logger.error(f"Error deleting post {post_uri} from ATProtoSocial: {e.error} - {e.message}") + logger.error(f"Error deleting post {post_uri} from Blueski: {e.error} - {e.message}") except Exception as e: logger.error(f"Unexpected error deleting post {post_uri}: {e}", exc_info=True) return False @@ -279,12 +279,12 @@ class ATProtoSocialUtils: async def upload_media(self, file_path: str, mime_type: str, alt_text: str | None = None) -> dict[str, Any] | None: """ - Uploads media (image) to ATProtoSocial. + Uploads media (image) to Blueski. Returns a dictionary containing the SDK's BlobRef object and alt_text, or None on failure. """ client = await self._get_client() if not client: - logger.error("ATProtoSocial client not available for media upload.") + logger.error("Blueski client not available for media upload.") return None try: with open(file_path, "rb") as f: @@ -303,7 +303,7 @@ class ATProtoSocialUtils: logger.error(f"Media upload failed for {file_path}, no blob in response.") return None except AtProtocolError as e: - logger.error(f"Error uploading media {file_path} to ATProtoSocial: {e.error} - {e.message}", exc_info=True) + logger.error(f"Error uploading media {file_path} to Blueski: {e.error} - {e.message}", exc_info=True) except Exception as e: logger.error(f"Unexpected error uploading media {file_path}: {e}", exc_info=True) return None @@ -341,7 +341,7 @@ class ATProtoSocialUtils: models.ComAtprotoRepoCreateRecord.Input( repo=self.get_own_did(), collection=ids.AppBskyGraphFollow, # "app.bsky.graph.follow" - record=models.AppBskyGraphFollow.Main(subject=user_did, created_at=client.get_current_time_iso()), + record=models.AppBskyGraphFollow.Main(subject=user_did, createdAt=client.get_current_time_iso()), ) ) logger.info(f"Successfully followed user {user_did}.") @@ -596,7 +596,7 @@ class ATProtoSocialUtils: models.ComAtprotoRepoCreateRecord.Input( repo=self.get_own_did(), collection=ids.AppBskyGraphBlock, # "app.bsky.graph.block" - record=models.AppBskyGraphBlock.Main(subject=user_did, created_at=client.get_current_time_iso()), + record=models.AppBskyGraphBlock.Main(subject=user_did, createdAt=client.get_current_time_iso()), ) ) logger.info(f"Successfully blocked user {user_did}. Block record URI: {response.uri}") @@ -1098,7 +1098,7 @@ class ATProtoSocialUtils: """ client = await self._get_client() if not client: - logger.error("ATProtoSocial client not available for reporting.") + logger.error("Blueski client not available for reporting.") return False try: diff --git a/src/sessions/mastodon/compose.py b/src/sessions/mastodon/compose.py index d95cc6c4..b9d9534f 100644 --- a/src/sessions/mastodon/compose.py +++ b/src/sessions/mastodon/compose.py @@ -17,6 +17,11 @@ def compose_post(post, db, settings, relative_times, show_screen_names, safe=Tru text = _("Boosted from @{}: {}").format(post.reblog.account.acct, templates.process_text(post.reblog, safe=safe)) else: text = templates.process_text(post, safe=safe) + # Handle quoted posts + if hasattr(post, 'quote') and post.quote != None and hasattr(post.quote, 'quoted_status') and post.quote.quoted_status != None: + quoted_user = post.quote.quoted_status.account.acct + quoted_text = templates.process_text(post.quote.quoted_status, safe=safe) + text = text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_text) filtered = utils.evaluate_filters(post=post, current_context="home") if filtered != None: text = _("hidden by filter {}").format(filtered) diff --git a/src/sessions/mastodon/session.py b/src/sessions/mastodon/session.py index 57e153ed..47cc47aa 100644 --- a/src/sessions/mastodon/session.py +++ b/src/sessions/mastodon/session.py @@ -248,6 +248,106 @@ class Session(base.baseSession): pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, lang=language) return + def edit_post(self, post_id, posts=[]): + """ Convenience function to edit a post. Only the first item in posts list is used as threads cannot be edited. + + Note: According to Mastodon API, not all fields can be edited. Visibility, language, and reply context cannot be changed. + + Args: + post_id: ID of the status to edit + posts: List with post data. Only first item is used. + + Returns: + Updated status object or None on failure + """ + if len(posts) == 0: + log.warning("edit_post called with empty posts list") + return None + + obj = posts[0] + text = obj.get("text") + + if not text: + log.warning("edit_post called without text content") + return None + + media_ids = [] + media_attributes = [] + + try: + poll = None + # Handle poll attachments + if len(obj["attachments"]) == 1 and obj["attachments"][0]["type"] == "poll": + poll = self.api.make_poll( + options=obj["attachments"][0]["options"], + expires_in=obj["attachments"][0]["expires_in"], + multiple=obj["attachments"][0]["multiple"], + hide_totals=obj["attachments"][0]["hide_totals"] + ) + log.debug("Editing post with poll (this will reset votes)") + # Handle media attachments + elif len(obj["attachments"]) > 0: + for i in obj["attachments"]: + # If attachment has an 'id', it's an existing media that we keep + if "id" in i: + media_ids.append(i["id"]) + # If existing media has metadata to update, use generate_media_edit_attributes + if "description" in i or "focus" in i: + media_attr = self.api.generate_media_edit_attributes( + id=i["id"], + description=i.get("description"), + focus=i.get("focus") + ) + media_attributes.append(media_attr) + # Otherwise it's a new file to upload + elif "file" in i: + description = i.get("description", "") + focus = i.get("focus", None) + media = self.api_call( + "media_post", + media_file=i["file"], + description=description, + focus=focus, + synchronous=True + ) + media_ids.append(media.id) + log.debug("Uploaded new media with id: {}".format(media.id)) + + # Prepare parameters for status_update + update_params = { + "id": post_id, + "status": text, + "_sound": "tweet_send.ogg", + "sensitive": obj.get("sensitive", False), + "spoiler_text": obj.get("spoiler_text", None), + } + + # Add optional parameters only if provided + if media_ids: + update_params["media_ids"] = media_ids + if media_attributes: + update_params["media_attributes"] = media_attributes + if poll: + update_params["poll"] = poll + + # Call status_update API + log.debug("Editing post {} with params: {}".format(post_id, {k: v for k, v in update_params.items() if k not in ["_sound"]})) + item = self.api_call(call_name="status_update", **update_params) + + if item: + log.info("Successfully edited post {}".format(post_id)) + return item + + except MastodonAPIError as e: + log.exception("Mastodon API error updating post {}: {}".format(post_id, str(e))) + output.speak(_("Error editing post: {}").format(str(e))) + pub.sendMessage("mastodon.error_edit", name=self.get_name(), post_id=post_id, error=str(e)) + return None + except Exception as e: + log.exception("Unexpected error updating post {}: {}".format(post_id, str(e))) + output.speak(_("Error editing post: {}").format(str(e))) + return None + def get_name(self): instance = self.settings["mastodon"]["instance"] instance = instance.replace("https://", "") diff --git a/src/sessions/mastodon/templates.py b/src/sessions/mastodon/templates.py index 0c0083f6..2674bea3 100644 --- a/src/sessions/mastodon/templates.py +++ b/src/sessions/mastodon/templates.py @@ -76,6 +76,13 @@ def render_post(post, template, settings, relative_times=False, offset_hours=0): else: text = process_text(post, safe=False) safe_text = process_text(post) + # Handle quoted posts + if hasattr(post, 'quote') and post.quote != None and hasattr(post.quote, 'quoted_status') and post.quote.quoted_status != None: + quoted_user = post.quote.quoted_status.account.acct + quoted_text = process_text(post.quote.quoted_status, safe=False) + quoted_safe_text = process_text(post.quote.quoted_status, safe=True) + text = text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_text) + safe_text = safe_text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_safe_text) filtered = utils.evaluate_filters(post=post, current_context="home") if filtered != None: text = _("hidden by filter {}").format(filtered) diff --git a/src/sessions/mastodon/utils.py b/src/sessions/mastodon/utils.py index 12a8c8fa..05a6303f 100644 --- a/src/sessions/mastodon/utils.py +++ b/src/sessions/mastodon/utils.py @@ -3,23 +3,47 @@ import demoji from html.parser import HTMLParser from datetime import datetime, timezone -url_re = re.compile('') +url_re = re.compile(r'') class HTMLFilter(HTMLParser): + # Classes to ignore when parsing HTML + IGNORED_CLASSES = ["quote-inline"] + text = "" first_paragraph = True + skip_depth = 0 # Track nesting depth of ignored elements def handle_data(self, data): - self.text += data + # Only add data if we're not inside an ignored element + if self.skip_depth == 0: + self.text += data def handle_starttag(self, tag, attrs): - if tag == "br": - self.text = self.text+"\n" - elif tag == "p": - if self.first_paragraph: - self.first_paragraph = False - else: - self.text = self.text+"\n\n" + # Check if this tag has a class that should be ignored + attrs_dict = dict(attrs) + tag_class = attrs_dict.get("class", "") + + # Check if any ignored class is present in this tag + should_skip = any(ignored_class in tag_class for ignored_class in self.IGNORED_CLASSES) + + if should_skip: + self.skip_depth += 1 + elif self.skip_depth == 0: # Only process tags if we're not skipping + if tag == "br": + self.text = self.text+"\n" + elif tag == "p": + if self.first_paragraph: + self.first_paragraph = False + else: + self.text = self.text+"\n\n" + else: + # We're inside a skipped element, increment depth for nested tags + self.skip_depth += 1 + + def handle_endtag(self, tag): + # Decrement skip depth when closing any tag while skipping + if self.skip_depth > 0: + self.skip_depth -= 1 def html_filter(data): f = HTMLFilter() diff --git a/src/test/sessions/atprotosocial/__init__.py b/src/test/sessions/blueski/__init__.py similarity index 100% rename from src/test/sessions/atprotosocial/__init__.py rename to src/test/sessions/blueski/__init__.py diff --git a/src/test/sessions/atprotosocial/test_atprotosocial_session.py b/src/test/sessions/blueski/test_blueski_session.py similarity index 92% rename from src/test/sessions/atprotosocial/test_atprotosocial_session.py rename to src/test/sessions/blueski/test_blueski_session.py index e3c9b2e3..7bda6870 100644 --- a/src/test/sessions/atprotosocial/test_atprotosocial_session.py +++ b/src/test/sessions/blueski/test_blueski_session.py @@ -4,7 +4,7 @@ import unittest from unittest.mock import patch, AsyncMock, MagicMock, PropertyMock # Assuming paths are set up correctly for test environment to find these -from sessions.atprotosocial.session import Session as ATProtoSocialSession +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 @@ -48,7 +48,7 @@ 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.atprotosocial[user_id].handle +# e.g., config.sessions.blueski[user_id].handle class MockConfigNode: def __init__(self, initial_value=None): self._value = initial_value @@ -60,9 +60,9 @@ class MockUserSessionConfig: self.handle = MockConfigNode("") self.app_password = MockConfigNode("") self.did = MockConfigNode("") - # Add other config values if session.py uses them for atprotosocial + # Add other config values if session.py uses them for blueski -class MockATProtoSocialConfig: +class MockBlueskiConfig: def __init__(self): self._user_configs = {"test_user": MockUserSessionConfig()} def __getitem__(self, key): @@ -70,31 +70,31 @@ class MockATProtoSocialConfig: class MockSessionsConfig: def __init__(self): - self.atprotosocial = MockATProtoSocialConfig() + self.blueski = MockBlueskiConfig() mock_config_global = MagicMock() mock_config_global.sessions = MockSessionsConfig() -class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase): +class TestBlueskiSession(unittest.IsolatedAsyncioTestCase): - @patch('sessions.atprotosocial.session.wx', mock_wx) - @patch('sessions.atprotosocial.session.config', mock_config_global) + @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.atprotosocial.__getitem__.return_value = self.mock_user_config_instance + mock_config_global.sessions.blueski.__getitem__.return_value = self.mock_user_config_instance - self.session = ATProtoSocialSession(approval_api=self.mock_approval_api, user_id="test_user", channel_id="test_channel") + 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 ATProtoSocialUtils + # 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 @@ -104,12 +104,12 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase): self.session.util # Call property to ensure _util is set if it's lazy loaded def test_session_initialization(self): - self.assertIsInstance(self.session, ATProtoSocialSession) - self.assertEqual(self.session.KIND, "atprotosocial") + 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.atprotosocial.session.AsyncClient') + @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 @@ -142,7 +142,7 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase): self.session.notify_session_ready.assert_called_once() - @patch('sessions.atprotosocial.session.AsyncClient') + @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")) @@ -155,8 +155,8 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase): self.assertIsNone(self.session.client) self.session.notify_session_ready.assert_not_called() - @patch('sessions.atprotosocial.session.wx', new=mock_wx) - @patch.object(ATProtoSocialSession, 'login', new_callable=AsyncMock) + @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 @@ -174,8 +174,8 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase): # Further check if wx.MessageBox was called with success # This requires more complex mocking or inspection of calls to mock_wx.MessageBox - @patch('sessions.atprotosocial.session.wx', new=mock_wx) - @patch.object(ATProtoSocialSession, 'login', new_callable=AsyncMock) + @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.") @@ -220,7 +220,7 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase): cw_text=None, is_sensitive=False, langs=["en", "es"], tags=None ) - @patch('sessions.atprotosocial.session.os.path.basename', return_value="image.png") # Mock os.path.basename + @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"} @@ -360,4 +360,3 @@ if 'wx' not in sys.modules: # type: ignore mock_wx_module.MessageBox = MockWxMessageBox mock_wx_module.CallAfter = MagicMock() mock_wx_module.GetApp = MagicMock() ->>>>>>> REPLACE diff --git a/src/wxUI/buffers/atprotosocial/panels.py b/src/wxUI/buffers/blueski/panels.py similarity index 91% rename from src/wxUI/buffers/atprotosocial/panels.py rename to src/wxUI/buffers/blueski/panels.py index 59e66002..cf6dcabb 100644 --- a/src/wxUI/buffers/atprotosocial/panels.py +++ b/src/wxUI/buffers/blueski/panels.py @@ -11,10 +11,10 @@ from datetime import datetime from multiplatform_widgets import widgets -log = logging.getLogger("wxUI.buffers.atprotosocial.panels") +log = logging.getLogger("wxUI.buffers.blueski.panels") -class ATProtoSocialHomeTimelinePanel(object): +class BlueskiHomeTimelinePanel(object): """Minimal Home timeline buffer for Bluesky. Exposes a .buffer wx.Panel with a List control and provides @@ -27,6 +27,7 @@ class ATProtoSocialHomeTimelinePanel(object): self.account = session.get_name() self.name = name self.type = "home_timeline" + self.timeline_algorithm = None self.invisible = True self.needs_init = True self.buffer = _HomePanel(parent, name) @@ -49,17 +50,16 @@ class ATProtoSocialHomeTimelinePanel(object): # The atproto SDK expects params, not raw kwargs try: from atproto import models as at_models # type: ignore - # Home: algorithmic/default timeline - try: - params = at_models.AppBskyFeedGetTimeline.Params(limit=count) - res = api.app.bsky.feed.get_timeline(params) - except Exception: - # Some SDKs may require explicit algorithm for home; try behavioral - params = at_models.AppBskyFeedGetTimeline.Params(limit=count, algorithm="behavioral") - res = api.app.bsky.feed.get_timeline(params) + params = at_models.AppBskyFeedGetTimeline.Params( + limit=count, + algorithm=self.timeline_algorithm + ) + res = api.app.bsky.feed.get_timeline(params) except Exception: - # Fallback to plain dict params if typed models unavailable - res = api.app.bsky.feed.get_timeline({"limit": count}) + payload = {"limit": count} + if self.timeline_algorithm: + payload["algorithm"] = self.timeline_algorithm + res = api.app.bsky.feed.get_timeline(payload) feed = getattr(res, "feed", []) self.cursor = getattr(res, "cursor", None) self.items = [] @@ -103,10 +103,17 @@ class ATProtoSocialHomeTimelinePanel(object): api = self.session._ensure_client() try: from atproto import models as at_models # type: ignore - params = at_models.AppBskyFeedGetTimeline.Params(limit=40, cursor=self.cursor) + params = at_models.AppBskyFeedGetTimeline.Params( + limit=40, + cursor=self.cursor, + algorithm=self.timeline_algorithm + ) res = api.app.bsky.feed.get_timeline(params) except Exception: - res = api.app.bsky.feed.get_timeline({"limit": 40, "cursor": self.cursor}) + payload = {"limit": 40, "cursor": self.cursor} + if self.timeline_algorithm: + payload["algorithm"] = self.timeline_algorithm + res = api.app.bsky.feed.get_timeline(payload) feed = getattr(res, "feed", []) self.cursor = getattr(res, "cursor", None) new_items = [] @@ -144,7 +151,7 @@ class ATProtoSocialHomeTimelinePanel(object): log.exception("Failed to load more Bluesky timeline items") return 0 - # Alias to integrate with mainController expectations for ATProto + # Alias to integrate with mainController expectations for Blueski def load_more_posts(self, *args, **kwargs): return self.get_more_items() @@ -281,12 +288,13 @@ class _HomePanel(wx.Panel): self.SetSizer(sizer) -class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel): +class BlueskiFollowingTimelinePanel(BlueskiHomeTimelinePanel): """Following-only timeline (reverse-chronological).""" def __init__(self, parent, name: str, session): super().__init__(parent, name, session) self.type = "following_timeline" + self.timeline_algorithm = "reverse-chronological" # Make sure the underlying wx panel also reflects this type try: self.buffer.type = "following_timeline" @@ -302,7 +310,7 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel): api = self.session._ensure_client() # Following timeline via reverse-chronological algorithm on get_timeline # Use plain dict to avoid typed-model mismatches across SDK versions - res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"}) + res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": self.timeline_algorithm}) feed = getattr(res, "feed", []) self.cursor = getattr(res, "cursor", None) self.items = [] @@ -343,7 +351,11 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel): try: api = self.session._ensure_client() # Pagination via reverse-chronological algorithm on get_timeline - res = api.app.bsky.feed.get_timeline({"limit": 40, "cursor": self.cursor, "algorithm": "reverse-chronological"}) + res = api.app.bsky.feed.get_timeline({ + "limit": 40, + "cursor": self.cursor, + "algorithm": self.timeline_algorithm + }) feed = getattr(res, "feed", []) self.cursor = getattr(res, "cursor", None) new_items = [] diff --git a/src/wxUI/dialogs/atprotosocial/configuration.py b/src/wxUI/dialogs/blueski/configuration.py similarity index 100% rename from src/wxUI/dialogs/atprotosocial/configuration.py rename to src/wxUI/dialogs/blueski/configuration.py diff --git a/src/wxUI/dialogs/atprotosocial/postDialogs.py b/src/wxUI/dialogs/blueski/postDialogs.py similarity index 100% rename from src/wxUI/dialogs/atprotosocial/postDialogs.py rename to src/wxUI/dialogs/blueski/postDialogs.py diff --git a/src/wxUI/dialogs/atprotosocial/showUserProfile.py b/src/wxUI/dialogs/blueski/showUserProfile.py similarity index 98% rename from src/wxUI/dialogs/atprotosocial/showUserProfile.py rename to src/wxUI/dialogs/blueski/showUserProfile.py index 68dd8ac7..3200cf8c 100644 --- a/src/wxUI/dialogs/atprotosocial/showUserProfile.py +++ b/src/wxUI/dialogs/blueski/showUserProfile.py @@ -6,9 +6,9 @@ from pubsub import pub from approve.translation import translate as _ from approve.notifications import NotificationError -# Assuming controller.atprotosocial.userList.get_user_profile_details and session.util._format_profile_data exist +# Assuming controller.blueski.userList.get_user_profile_details and session.util._format_profile_data exist # For direct call to util: -# from sessions.atprotosocial import utils as ATProtoSocialUtils +# from sessions.blueski import utils as BlueskiUtils logger = logging.getLogger(__name__) @@ -272,7 +272,7 @@ class ShowUserProfileDialog(wx.Dialog): self.SetTitle(f"{_('User Profile')} - {text}") ```python -# Example of how this dialog might be called from atprotosocial.Handler.user_details: +# Example of how this dialog might be called from blueski.Handler.user_details: # (This is conceptual, actual integration in handler.py will use the dialog) # # async def user_details(self, buffer_panel_or_user_ident): diff --git a/src/wxUI/dialogs/mastodon/menus.py b/src/wxUI/dialogs/mastodon/menus.py index 63a9cb56..7947d942 100644 --- a/src/wxUI/dialogs/mastodon/menus.py +++ b/src/wxUI/dialogs/mastodon/menus.py @@ -8,6 +8,8 @@ class base(wx.Menu): self.Append(self.boost) self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply")) self.Append(self.reply) + self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit")) + self.Append(self.edit) self.fav = wx.MenuItem(self, wx.ID_ANY, _(u"&Add to favorites")) self.Append(self.fav) self.unfav = wx.MenuItem(self, wx.ID_ANY, _(u"R&emove from favorites")) @@ -36,6 +38,8 @@ class notification(wx.Menu): self.Append(self.boost) self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply")) self.Append(self.reply) + self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit")) + self.Append(self.edit) self.fav = wx.MenuItem(self, wx.ID_ANY, _(u"&Add to favorites")) self.Append(self.fav) self.unfav = wx.MenuItem(self, wx.ID_ANY, _(u"R&emove from favorites")) diff --git a/src/wxUI/dialogs/mastodon/postDialogs.py b/src/wxUI/dialogs/mastodon/postDialogs.py index 646872f9..c9051705 100644 --- a/src/wxUI/dialogs/mastodon/postDialogs.py +++ b/src/wxUI/dialogs/mastodon/postDialogs.py @@ -51,7 +51,7 @@ class Post(wx.Dialog): visibility_sizer.Add(self.visibility, 0, 0, 0) language_sizer = wx.BoxSizer(wx.HORIZONTAL) post_actions_sizer.Add(language_sizer, 0, wx.RIGHT, 20) - lang_label = wx.StaticText(self, wx.ID_ANY, _("Language")) + lang_label = wx.StaticText(self, wx.ID_ANY, _("&Language")) language_sizer.Add(lang_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5) self.language = wx.ComboBox(self, wx.ID_ANY, choices=languages, style=wx.CB_DROPDOWN | wx.CB_READONLY) language_sizer.Add(self.language, 0, wx.ALIGN_CENTER_VERTICAL, 0) @@ -234,9 +234,9 @@ class viewPost(wx.Dialog): def create_buttons_section(self, panel): sizer = wx.BoxSizer(wx.HORIZONTAL) - self.mute = wx.Button(panel, wx.ID_ANY, _("Mute conversation")) + self.mute = wx.Button(panel, wx.ID_ANY, _("&Mute conversation")) self.mute.Enable(False) - self.share = wx.Button(panel, wx.ID_ANY, _("Copy link to clipboard")) + 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...")) @@ -295,7 +295,7 @@ class poll(wx.Dialog): sizer_1 = wx.BoxSizer(wx.VERTICAL) period_sizer = wx.BoxSizer(wx.HORIZONTAL) sizer_1.Add(period_sizer, 1, wx.EXPAND, 0) - label_period = wx.StaticText(self, wx.ID_ANY, _("Participation time")) + label_period = wx.StaticText(self, wx.ID_ANY, _("&Participation time")) period_sizer.Add(label_period, 0, 0, 0) self.period = wx.ComboBox(self, wx.ID_ANY, choices=[_("5 minutes"), _("30 minutes"), _("1 hour"), _("6 hours"), _("1 day"), _("2 days"), _("3 days"), _("4 days"), _("5 days"), _("6 days"), _("7 days")], style=wx.CB_DROPDOWN | wx.CB_READONLY | wx.CB_SIMPLE) self.period.SetFocus() @@ -305,36 +305,36 @@ class poll(wx.Dialog): sizer_1.Add(sizer_2, 1, wx.EXPAND, 0) option1_sizer = wx.BoxSizer(wx.HORIZONTAL) sizer_2.Add(option1_sizer, 1, wx.EXPAND, 0) - label_2 = wx.StaticText(self, wx.ID_ANY, _("Option 1")) + label_2 = wx.StaticText(self, wx.ID_ANY, _("Option &1")) option1_sizer.Add(label_2, 0, 0, 0) self.option1 = wx.TextCtrl(self, wx.ID_ANY, "") self.option1.SetMaxLength(25) option1_sizer.Add(self.option1, 0, 0, 0) option2_sizer = wx.BoxSizer(wx.HORIZONTAL) sizer_2.Add(option2_sizer, 1, wx.EXPAND, 0) - label_3 = wx.StaticText(self, wx.ID_ANY, _("Option 2")) + label_3 = wx.StaticText(self, wx.ID_ANY, _("Option &2")) option2_sizer.Add(label_3, 0, 0, 0) self.option2 = wx.TextCtrl(self, wx.ID_ANY, "") self.option2.SetMaxLength(25) option2_sizer.Add(self.option2, 0, 0, 0) option3_sizer = wx.BoxSizer(wx.HORIZONTAL) sizer_2.Add(option3_sizer, 1, wx.EXPAND, 0) - label_4 = wx.StaticText(self, wx.ID_ANY, _("Option 3")) + label_4 = wx.StaticText(self, wx.ID_ANY, _("Option &3")) option3_sizer.Add(label_4, 0, 0, 0) self.option3 = wx.TextCtrl(self, wx.ID_ANY, "") self.option3.SetMaxLength(25) option3_sizer.Add(self.option3, 0, 0, 0) option4_sizer = wx.BoxSizer(wx.HORIZONTAL) sizer_2.Add(option4_sizer, 1, wx.EXPAND, 0) - label_5 = wx.StaticText(self, wx.ID_ANY, _("Option 4")) + label_5 = wx.StaticText(self, wx.ID_ANY, _("Option &4")) option4_sizer.Add(label_5, 0, 0, 0) self.option4 = wx.TextCtrl(self, wx.ID_ANY, "") self.option4.SetMaxLength(25) option4_sizer.Add(self.option4, 0, 0, 0) - self.multiple = wx.CheckBox(self, wx.ID_ANY, _("Allow multiple choices per user")) + self.multiple = wx.CheckBox(self, wx.ID_ANY, _("&Allow multiple choices per user")) self.multiple.SetValue(False) sizer_1.Add(self.multiple, 0, wx.ALL, 5) - self.hide_votes = wx.CheckBox(self, wx.ID_ANY, _("Hide votes count until the poll expires")) + self.hide_votes = wx.CheckBox(self, wx.ID_ANY, _("&Hide votes count until the poll expires")) self.hide_votes.SetValue(False) sizer_1.Add(self.hide_votes, 0, wx.ALL, 5) btn_sizer = wx.StdDialogButtonSizer() diff --git a/src/wxUI/dialogs/mastodon/showUserProfile.py b/src/wxUI/dialogs/mastodon/showUserProfile.py index c40f8035..b79887c8 100644 --- a/src/wxUI/dialogs/mastodon/showUserProfile.py +++ b/src/wxUI/dialogs/mastodon/showUserProfile.py @@ -141,7 +141,7 @@ class ShowUserProfile(wx.Dialog): mainSizer.Add(privateSizer, 0, wx.ALL | wx.CENTER) botSizer = wx.BoxSizer(wx.HORIZONTAL) - botLabel = wx.StaticText(self.panel, label=_("&Bot account: ")) + botLabel = wx.StaticText(self.panel, label=_("B&ot account: ")) botText = self.createTextCtrl(bullSwitch[user.bot], (30, 30)) botSizer.Add(botLabel, wx.SizerFlags().Center()) botSizer.Add(botText, wx.SizerFlags().Center()) @@ -154,7 +154,7 @@ class ShowUserProfile(wx.Dialog): discoverSizer.Add(discoverText, wx.SizerFlags().Center()) mainSizer.Add(discoverSizer, 0, wx.ALL | wx.CENTER) - posts = wx.Button(self.panel, label=_("{} p&osts. Click to open posts timeline").format(user.statuses_count)) + posts = wx.Button(self.panel, label=_("{} pos&ts. Click to open posts timeline").format(user.statuses_count)) # posts.SetToolTip(_("Click to open {}'s posts").format(user.display_name)) posts.Bind(wx.EVT_BUTTON, self.onPost) mainSizer.Add(posts, wx.SizerFlags().Center()) diff --git a/src/wxUI/dialogs/mastodon/updateProfile.py b/src/wxUI/dialogs/mastodon/updateProfile.py index 57852f63..9bbd46ef 100644 --- a/src/wxUI/dialogs/mastodon/updateProfile.py +++ b/src/wxUI/dialogs/mastodon/updateProfile.py @@ -119,7 +119,7 @@ class UpdateProfileDialog(wx.Dialog): self.locked = wx.CheckBox(panel, label=_("&Private account")) self.locked.SetValue(locked) - self.bot = wx.CheckBox(panel, label=_("&Bot account")) + self.bot = wx.CheckBox(panel, label=_("B&ot account")) self.bot.SetValue(bot) self.discoverable = wx.CheckBox(panel, label=_("&Discoverable account")) self.discoverable.SetValue(discoverable) diff --git a/src/wxUI/dialogs/templateDialogs.py b/src/wxUI/dialogs/templateDialogs.py index 9efa96aa..af1c708b 100644 --- a/src/wxUI/dialogs/templateDialogs.py +++ b/src/wxUI/dialogs/templateDialogs.py @@ -26,7 +26,7 @@ class EditTemplateDialog(wx.Dialog): sizer_3.AddButton(self.button_SAVE) self.button_CANCEL = wx.Button(self, wx.ID_CANCEL) sizer_3.AddButton(self.button_CANCEL) - self.button_RESTORE = wx.Button(self, wx.ID_ANY, _("Restore template")) + self.button_RESTORE = wx.Button(self, wx.ID_ANY, _("&Restore template")) self.button_RESTORE.Bind(wx.EVT_BUTTON, self.on_restore) sizer_3.AddButton(self.button_CANCEL) sizer_3.Realize() diff --git a/src/wxUI/dialogs/userList.py b/src/wxUI/dialogs/userList.py index 59252ea8..ff037068 100644 --- a/src/wxUI/dialogs/userList.py +++ b/src/wxUI/dialogs/userList.py @@ -22,11 +22,11 @@ class UserListDialog(wx.Dialog): user_list_sizer.Add(self.user_list, 1, wx.EXPAND | wx.ALL, 10) main_sizer.Add(user_list_sizer, 1, wx.EXPAND | wx.ALL, 15) buttons_sizer = wx.BoxSizer(wx.HORIZONTAL) - self.actions_button = wx.Button(panel, wx.ID_ANY, "Actions") + self.actions_button = wx.Button(panel, wx.ID_ANY, "&Actions") buttons_sizer.Add(self.actions_button, 0, wx.RIGHT, 10) - self.details_button = wx.Button(panel, wx.ID_ANY, _("View profile")) + self.details_button = wx.Button(panel, wx.ID_ANY, _("&View profile")) buttons_sizer.Add(self.details_button, 0, wx.RIGHT, 10) - close_button = wx.Button(panel, wx.ID_CANCEL, "Close") + 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) diff --git a/src/wxUI/view.py b/src/wxUI/view.py index fd76c891..25daa1a2 100644 --- a/src/wxUI/view.py +++ b/src/wxUI/view.py @@ -19,7 +19,7 @@ class mainFrame(wx.Frame): self.menuitem_search = self.menubar_application.Append(wx.ID_ANY, _(u"&Search")) self.lists = self.menubar_application.Append(wx.ID_ANY, _(u"&Lists manager")) self.lists.Enable(False) - self.manageAliases = self.menubar_application.Append(wx.ID_ANY, _("Manage user aliases")) + self.manageAliases = self.menubar_application.Append(wx.ID_ANY, _("M&anage user aliases")) self.keystroke_editor = self.menubar_application.Append(wx.ID_ANY, _(u"&Edit keystrokes")) self.account_settings = self.menubar_application.Append(wx.ID_ANY, _(u"Account se&ttings")) self.prefs = self.menubar_application.Append(wx.ID_PREFERENCES, _(u"&Global settings")) @@ -56,7 +56,7 @@ class mainFrame(wx.Frame): self.trends = self.menubar_buffer.Append(wx.ID_ANY, _(u"New &trending topics buffer...")) self.filter = self.menubar_buffer.Append(wx.ID_ANY, _(u"Create a &filter")) self.manage_filters = self.menubar_buffer.Append(wx.ID_ANY, _(u"&Manage filters")) - self.find = self.menubar_buffer.Append(wx.ID_ANY, _(u"Find a string in the currently focused buffer...")) + self.find = self.menubar_buffer.Append(wx.ID_ANY, _(u"F&ind a string in the currently focused buffer...")) self.load_previous_items = self.menubar_buffer.Append(wx.ID_ANY, _(u"&Load previous items")) self.menubar_buffer.AppendSeparator() self.mute_buffer = self.menubar_buffer.AppendCheckItem(wx.ID_ANY, _(u"&Mute")) @@ -66,8 +66,8 @@ class mainFrame(wx.Frame): # audio menu self.menubar_audio = wx.Menu() - self.seekLeft = self.menubar_audio.Append(wx.ID_ANY, _(u"&Seek back 5 seconds")) - self.seekRight = self.menubar_audio.Append(wx.ID_ANY, _(u"&Seek forward 5 seconds")) + self.seekLeft = self.menubar_audio.Append(wx.ID_ANY, _(u"Seek &back 5 seconds")) + self.seekRight = self.menubar_audio.Append(wx.ID_ANY, _(u"Seek &forward 5 seconds")) # Help Menu self.menubar_help = wx.Menu() diff --git a/test_atproto_session.py b/test_atproto_session.py new file mode 100644 index 00000000..789ab93a --- /dev/null +++ b/test_atproto_session.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +"""Test script for Blueski session functionality. + +This script demonstrates how to use the Blueski session implementation. +It can be used to verify that the authentication flow works correctly. + +Usage: + python test_atproto_session.py +""" + +import sys +import os + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +# Import required modules +from sessions.blueski import session +import paths +import logging + +# Setup logging +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +def test_authorization(): + """Test the authorization flow.""" + print("\n" + "="*60) + print("Blueski Session Authorization Test") + print("="*60) + print("\nThis test will:") + print("1. Create a new Blueski session") + print("2. Walk you through the authorization process") + print("3. Verify the session was created correctly") + print("\nNOTE: You'll need:") + print(" - Your Blueski handle (e.g., user.bsky.social)") + print(" - An app password (NOT your main password)") + print("\n" + "="*60 + "\n") + + # Create a test session + test_session_id = "test_blueski_001" + print(f"Creating session with ID: {test_session_id}") + + s = session.Session(session_id=test_session_id) + + try: + # Try to login first (should fail for new session) + print("\nAttempting to login with existing credentials...") + s.get_configuration() + s.login() + print("✓ Login successful! Session already exists.") + print(f" Session name: {s.get_name()}") + print(f" Logged in: {s.logged}") + + except Exception as e: + # Expected for first run + print(f"✗ Login failed (expected): {type(e).__name__}") + print("\nStarting authorization process...") + print("(GUI dialogs will open for handle and password)") + + try: + result = s.authorise() + + if result: + print("\n✓ Authorization successful!") + print(f" Session name: {s.get_name()}") + print(f" Logged in: {s.logged}") + print(f" User DID: {s.settings['blueski']['did']}") + print(f" Handle: {s.settings['blueski']['handle']}") + + # Check that session_string was saved + if s.settings['blueski']['session_string']: + print(" ✓ Session string saved") + else: + print(" ✗ WARNING: Session string not saved!") + + # Check that app_password was cleared + if not s.settings['blueski']['app_password']: + print(" ✓ App password cleared (secure)") + else: + print(" ✗ WARNING: App password still in config!") + + print(f"\nSession configuration saved to:") + print(f" {os.path.join(paths.config_path(), test_session_id, 'session.conf')}") + + return s + else: + print("\n✗ Authorization cancelled or failed") + return None + + except Exception as e: + print(f"\n✗ Authorization error: {e}") + import traceback + traceback.print_exc() + return None + +def test_logout(s): + """Test the logout functionality.""" + if not s: + print("\nSkipping logout test (no active session)") + return + + print("\n" + "="*60) + print("Testing logout functionality") + print("="*60) + + print(f"\nCurrent state: logged={s.logged}") + + s.logout() + + print(f"After logout: logged={s.logged}") + + # Check that session_string was cleared + if not s.settings['blueski']['session_string']: + print("✓ Session string cleared") + else: + print("✗ WARNING: Session string not cleared!") + + print("\nLogout test complete") + +def test_session_restoration(): + """Test restoring a session from saved credentials.""" + print("\n" + "="*60) + print("Testing session restoration") + print("="*60) + + test_session_id = "test_blueski_001" + print(f"\nCreating new session object with ID: {test_session_id}") + + s = session.Session(session_id=test_session_id) + + try: + s.get_configuration() + s.login() + print("✓ Session restored successfully!") + print(f" Session name: {s.get_name()}") + print(f" Logged in: {s.logged}") + return s + except Exception as e: + print(f"✗ Failed to restore session: {e}") + import traceback + traceback.print_exc() + return None + +def main(): + """Main test function.""" + print("\nBlueski Session Test Suite") + print("Ensure you have wxPython installed for GUI dialogs\n") + + # Test 1: Authorization + session_obj = test_authorization() + + if not session_obj: + print("\nTests aborted (authorization failed)") + return + + input("\nPress Enter to test session restoration...") + + # Test 2: Logout + test_logout(session_obj) + + input("\nPress Enter to test session restoration after logout...") + + # Test 3: Try to restore (should fail after logout) + restored_session = test_session_restoration() + + if not restored_session: + print("\n✓ Session restoration correctly failed after logout") + else: + print("\n✗ WARNING: Session was restored after logout (unexpected)") + + print("\n" + "="*60) + print("All tests complete!") + print("="*60) + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\nTest interrupted by user") + except Exception as e: + print(f"\n\nUnexpected error: {e}") + import traceback + traceback.print_exc() diff --git a/test_config_dir/test_session/session.conf b/test_config_dir/test_session/session.conf new file mode 100644 index 00000000..4081a2f1 --- /dev/null +++ b/test_config_dir/test_session/session.conf @@ -0,0 +1,40 @@ +[atproto] +handle = test_user.bsky.social +app_password = "" +service_url = https://bsky.social +session_string = fake_session_string_12345 +access_jwt = "" +refresh_jwt = "" +did = "" + +[general] +relative_times = True +max_posts_per_call = 40 +reverse_timelines = False +persist_size = 0 +load_cache_in_memory = True +show_screen_names = False +buffer_order = home, notifications + +[sound] +volume = 1.0 +input_device = Default +output_device = Default +session_mute = False +current_soundpack = FreakyBlue +indicate_audio = True +indicate_img = True + +[other_buffers] +timelines = , + +[mysc] +spelling_language = "" + +[reporting] +braille_reporting = True +speech_reporting = True + +[filters] + +[user-aliases]