mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-03-06 01:17:32 +01:00
Commit
This commit is contained in:
342
CLAUDE.md
Normal file
342
CLAUDE.md
Normal file
@@ -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
|
||||||
104
debug_config_save.py
Normal file
104
debug_config_save.py
Normal file
@@ -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()
|
||||||
@@ -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.
|
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.
|
* **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.
|
* **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.
|
||||||
|
|||||||
@@ -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.
|
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**.
|
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).
|
1. Open TWBlue and go to the Session Manager (Application Menu -> Manage accounts).
|
||||||
2. Click on "New account".
|
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".
|
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``).
|
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.
|
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
|
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.
|
* **Posting**: Create new posts (often called "skeets") with text, images, and specify language.
|
||||||
* **Timelines**:
|
* **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.
|
* **User Timelines**: View posts from specific users.
|
||||||
* **Mentions & Replies**: These will appear in your Notifications.
|
* **Mentions & Replies**: These will appear in your Notifications.
|
||||||
* **Notifications**: Receive notifications for likes, reposts, follows, mentions, replies, and quotes.
|
* **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.
|
* **User Search**: Search for users by their handle or display name.
|
||||||
* **Content Warnings**: Create posts with content warnings (sensitive content labels).
|
* **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.
|
* **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.
|
||||||
@@ -16,7 +16,7 @@ This is the user guide for the latest available version of TWBlue. The purpose o
|
|||||||
system_requirements
|
system_requirements
|
||||||
installation
|
installation
|
||||||
basic_concepts
|
basic_concepts
|
||||||
atprotosocial
|
blueski
|
||||||
usage
|
usage
|
||||||
global_settings
|
global_settings
|
||||||
credits
|
credits
|
||||||
|
|||||||
63
example_atproto.py
Normal file
63
example_atproto.py
Normal file
@@ -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()
|
||||||
@@ -1,45 +1,45 @@
|
|||||||
accessible_output2 @ git+https://github.com/accessibleapps/accessible_output2@57bda997d98e87dd78aa049e7021cf777871619b
|
accessible_output2 @ git+https://github.com/accessibleapps/accessible_output2@57bda997d98e87dd78aa049e7021cf777871619b
|
||||||
arrow==1.3.0
|
arrow==1.4.0
|
||||||
attrs==25.3.0
|
attrs==25.4.0
|
||||||
backports.functools-lru-cache==2.0.0
|
backports.functools-lru-cache==2.0.0
|
||||||
blurhash==1.1.4
|
blurhash==1.1.5
|
||||||
certifi==2025.4.26
|
certifi==2026.1.4
|
||||||
chardet==5.2.0
|
chardet==5.2.0
|
||||||
charset-normalizer==3.4.2
|
charset-normalizer==3.4.4
|
||||||
colorama==0.4.6
|
colorama==0.4.6
|
||||||
configobj==5.0.9
|
configobj==5.0.9
|
||||||
coverage==7.8.0
|
coverage==7.13.1
|
||||||
cx-Freeze==8.3.0
|
cx-Freeze==8.5.3
|
||||||
cx-Logging==3.2.1
|
cx-Logging==3.2.1
|
||||||
decorator==5.2.1
|
decorator==5.2.1
|
||||||
demoji==1.1.0
|
demoji==1.1.0
|
||||||
deepl==1.22.0
|
deepl==1.27.0
|
||||||
future==1.0.0
|
future==1.0.0
|
||||||
idna==3.10
|
idna==3.11
|
||||||
importlib-metadata==8.7.0
|
importlib-metadata==8.7.1
|
||||||
iniconfig==2.1.0
|
iniconfig==2.3.0
|
||||||
libloader @ git+https://github.com/accessibleapps/libloader@bc94811c095b2e57a036acd88660be9a33260267
|
libloader @ git+https://github.com/accessibleapps/libloader@bc94811c095b2e57a036acd88660be9a33260267
|
||||||
libretranslatepy==2.1.4
|
libretranslatepy==2.1.4
|
||||||
lief==0.15.1
|
lief==0.15.1
|
||||||
Markdown==3.8
|
Markdown==3.10
|
||||||
Mastodon.py==2.0.1
|
Mastodon.py==2.1.4
|
||||||
numpy==2.2.3
|
numpy==2.4.0
|
||||||
oauthlib==3.2.2
|
oauthlib==3.3.1
|
||||||
packaging==25.0
|
packaging==25.0
|
||||||
pillow==11.2.1
|
pillow==12.1.0
|
||||||
platform_utils @ git+https://github.com/accessibleapps/platform_utils@e0d79f7b399c4ea677a633d2dde9202350d62c38
|
platform_utils @ git+https://github.com/accessibleapps/platform_utils@e0d79f7b399c4ea677a633d2dde9202350d62c38
|
||||||
pluggy==1.6.0
|
pluggy==1.6.0
|
||||||
psutil==7.0.0
|
psutil==7.2.1
|
||||||
pyenchant==3.2.2
|
pyenchant==3.3.0
|
||||||
pypiwin32==223
|
pypiwin32==223
|
||||||
Pypubsub==4.0.3
|
Pypubsub==4.0.7
|
||||||
PySocks==1.7.1
|
PySocks==1.7.1
|
||||||
pytest==8.3.5
|
pytest==9.0.2
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
python-magic-bin==0.4.14
|
python-magic-bin==0.4.14
|
||||||
python-vlc==3.0.21203
|
python-vlc==3.0.21203
|
||||||
pywin32==310
|
pywin32==311
|
||||||
requests==2.32.3
|
requests==2.32.5
|
||||||
requests-oauthlib==2.0.0
|
requests-oauthlib==2.0.0
|
||||||
requests-toolbelt==1.0.0
|
requests-toolbelt==1.0.0
|
||||||
rfc3986==2.0.0
|
rfc3986==2.0.0
|
||||||
@@ -49,11 +49,11 @@ sniffio==1.3.1
|
|||||||
sound_lib @ git+https://github.com/accessibleapps/sound_lib@a439f0943fb95ee7b6ba24f51a686f47c4ad66b2
|
sound_lib @ git+https://github.com/accessibleapps/sound_lib@a439f0943fb95ee7b6ba24f51a686f47c4ad66b2
|
||||||
sqlitedict==2.1.0
|
sqlitedict==2.1.0
|
||||||
twitter-text-parser==3.0.0
|
twitter-text-parser==3.0.0
|
||||||
types-python-dateutil==2.9.0.20250516
|
types-python-dateutil==2.9.0.20251115
|
||||||
urllib3==2.4.0
|
urllib3==2.6.3
|
||||||
win-inet-pton==1.1.0
|
win-inet-pton==1.1.0
|
||||||
winpaths==0.2
|
winpaths==0.2
|
||||||
wxPython==4.2.3
|
wxPython==4.2.4
|
||||||
youtube-dl==2021.12.17
|
youtube-dl==2021.12.17
|
||||||
zipp==3.21.0
|
zipp==3.23.0
|
||||||
atproto>=0.0.45
|
atproto>=0.0.45
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[atprotosocial]
|
[blueski]
|
||||||
handle = string(default="")
|
handle = string(default="")
|
||||||
app_password = string(default="")
|
app_password = string(default="")
|
||||||
did = string(default="")
|
did = string(default="")
|
||||||
@@ -24,13 +24,13 @@ class Handler:
|
|||||||
from pubsub import pub
|
from pubsub import pub
|
||||||
pub.sendMessage("core.create_account", name=name, session_id=session.session_id, logged=True)
|
pub.sendMessage("core.create_account", name=name, session_id=session.session_id, logged=True)
|
||||||
root_position = controller.view.search(name, name)
|
root_position = controller.view.search(name, name)
|
||||||
# Home timeline only for now
|
# Discover/home timeline
|
||||||
from pubsub import pub
|
from pubsub import pub
|
||||||
pub.sendMessage(
|
pub.sendMessage(
|
||||||
"createBuffer",
|
"createBuffer",
|
||||||
buffer_type="home_timeline",
|
buffer_type="home_timeline",
|
||||||
session_type="atprotosocial",
|
session_type="blueski",
|
||||||
buffer_title=_("Home"),
|
buffer_title=_("Discover"),
|
||||||
parent_tab=root_position,
|
parent_tab=root_position,
|
||||||
start=True,
|
start=True,
|
||||||
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session)
|
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session)
|
||||||
@@ -39,8 +39,8 @@ class Handler:
|
|||||||
pub.sendMessage(
|
pub.sendMessage(
|
||||||
"createBuffer",
|
"createBuffer",
|
||||||
buffer_type="following_timeline",
|
buffer_type="following_timeline",
|
||||||
session_type="atprotosocial",
|
session_type="blueski",
|
||||||
buffer_title=_("Following"),
|
buffer_title=_("Following (Chronological)"),
|
||||||
parent_tab=root_position,
|
parent_tab=root_position,
|
||||||
start=False,
|
start=False,
|
||||||
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session)
|
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session)
|
||||||
@@ -71,7 +71,7 @@ class Handler:
|
|||||||
current_mode = None
|
current_mode = None
|
||||||
ask_default = True if current_mode in (None, "ask") else False
|
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)
|
dlg = AccountSettingsDialog(controller.view, ask_before_boost=ask_default)
|
||||||
resp = dlg.ShowModal()
|
resp = dlg.ShowModal()
|
||||||
if resp == wx.ID_OK:
|
if resp == wx.ID_OK:
|
||||||
@@ -8,34 +8,34 @@ from typing import Any
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# This file would typically contain functions to generate complex message bodies or
|
# 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.
|
# interactive components for Blueski, similar to how it might be done for Mastodon.
|
||||||
# Since ATProtoSocial's interactive features (beyond basic posts) are still evolving
|
# Since Blueski's interactive features (beyond basic posts) are still evolving
|
||||||
# or client-dependent (like polls), this might be less complex initially.
|
# 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.
|
# functions to create those would go here. For now, we can imagine placeholders.
|
||||||
|
|
||||||
def format_welcome_message(session: Any) -> dict[str, Any]:
|
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.
|
This is just a placeholder and example.
|
||||||
"""
|
"""
|
||||||
# user_profile = session.util.get_own_profile_info() # Assuming this method exists and is async or cached
|
# 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
|
# Expect session to expose username via db/settings
|
||||||
handle = (getattr(session, "db", {}).get("user_name")
|
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"))
|
or _("your Bluesky account"))
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"text": _("Welcome to Approve for ATProtoSocial! Your account {handle} is connected.").format(handle=handle),
|
"text": _("Welcome to Approve for Blueski! Your account {handle} is connected.").format(handle=handle),
|
||||||
# "blocks": [ # If ATProtoSocial supports a block kit like Slack or Discord
|
# "blocks": [ # If Blueski supports a block kit like Slack or Discord
|
||||||
# {
|
# {
|
||||||
# "type": "section",
|
# "type": "section",
|
||||||
# "text": {
|
# "text": {
|
||||||
# "type": "mrkdwn", # Or ATProtoSocial's equivalent
|
# "type": "mrkdwn", # Or Blueski's equivalent
|
||||||
# "text": _("Welcome to Approve for ATProtoSocial! Your account *{handle}* is connected.").format(handle=handle)
|
# "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",
|
# "type": "button",
|
||||||
# "text": {"type": "plain_text", "text": _("Post your first Skeet")},
|
# "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
|
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:
|
# or as specific formatting needs for Approve arise. For example:
|
||||||
# - Formatting a post for display with all its embeds and cards.
|
# - 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).
|
# - Creating interactive messages for polls (if supported via some convention).
|
||||||
|
|
||||||
# Example of adapting a function that might exist in mastodon_messages:
|
# 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")
|
# author_handle = post_content.get("author", {}).get("handle", "Unknown user")
|
||||||
# text_preview = post_content.get("text", "")[:100] # First 100 chars of text
|
# 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
|
# # 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).")
|
||||||
@@ -8,29 +8,29 @@ fromapprove.translation import translate as _
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
fromapprove.config import ConfigSectionProxy
|
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__)
|
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.
|
# 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.
|
# 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
|
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.
|
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
|
# Example fields - these should align with what BlueskiSession.get_settings_inputs defines
|
||||||
# and what ATProtoSocialSession.get_configurable_values expects for its config.
|
# and what BlueskiSession.get_configurable_values expects for its config.
|
||||||
|
|
||||||
# instance_url = TextField(
|
# instance_url = TextField(
|
||||||
# _("Instance URL"),
|
# _("Instance URL"),
|
||||||
# default="https://bsky.social", # Default PDS for Bluesky
|
# 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
|
# validators=[], # Add validators if needed, e.g., URL validator
|
||||||
# )
|
# )
|
||||||
handle = TextField(
|
handle = TextField(
|
||||||
@@ -43,19 +43,19 @@ class ATProtoSocialSettingsForm(Form):
|
|||||||
description=_("Your Bluesky App Password. Generate this in your Bluesky account settings."),
|
description=_("Your Bluesky App Password. Generate this in your Bluesky account settings."),
|
||||||
validators=[], # e.g., DataRequired()
|
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.
|
# 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(
|
async def get_settings_form(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
session: ATProtoSocialSession | None = None,
|
session: BlueskiSession | None = None,
|
||||||
config: ConfigSectionProxy | None = None, # User-specific config for ATProtoSocial
|
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
|
||||||
) -> ATProtoSocialSettingsForm:
|
) -> BlueskiSettingsForm:
|
||||||
"""
|
"""
|
||||||
Creates and pre-populates the ATProtoSocial settings form.
|
Creates and pre-populates the Blueski settings form.
|
||||||
"""
|
"""
|
||||||
form_data = {}
|
form_data = {}
|
||||||
if session: # If a session exists, use its current config
|
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["handle"] = config.handle.get("")
|
||||||
form_data["app_password"] = ""
|
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
|
return form
|
||||||
|
|
||||||
|
|
||||||
async def process_settings_form(
|
async def process_settings_form(
|
||||||
form: ATProtoSocialSettingsForm,
|
form: BlueskiSettingsForm,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
session: ATProtoSocialSession | None = None, # Pass if update should affect live session
|
session: BlueskiSession | None = None, # Pass if update should affect live session
|
||||||
config: ConfigSectionProxy | None = None, # User-specific config for ATProtoSocial
|
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
|
||||||
) -> bool:
|
) -> 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.
|
Returns True if successful, False otherwise.
|
||||||
"""
|
"""
|
||||||
if not form.validate(): # Assuming form has a validate method
|
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
|
return False
|
||||||
|
|
||||||
if not config and session: # Try to get config via session if not directly provided
|
if not config and session: # Try to get config via session if not directly provided
|
||||||
# This depends on how ConfigSectionProxy is obtained.
|
# 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
|
pass # Needs actual way to get config proxy
|
||||||
|
|
||||||
if not config:
|
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
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -101,11 +101,11 @@ async def process_settings_form(
|
|||||||
await config.handle.set(form.handle.data)
|
await config.handle.set(form.handle.data)
|
||||||
await config.app_password.set(form.app_password.data) # Ensure this is stored securely
|
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 there's an active session, it might need to be reconfigured or restarted
|
||||||
if session:
|
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
|
# await session.stop() # Stop it
|
||||||
# # Update session instance with new values directly or rely on it re-reading config
|
# # Update session instance with new values directly or rely on it re-reading config
|
||||||
# session.api_base_url = form.instance_url.data
|
# session.api_base_url = form.instance_url.data
|
||||||
@@ -118,11 +118,11 @@ async def process_settings_form(
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
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
|
return False
|
||||||
|
|
||||||
# Any additional ATProtoSocial-specific settings views or handlers would go here.
|
# Any additional Blueski-specific settings views or handlers would go here.
|
||||||
# For instance, if ATProtoSocial had features like "Relays" or "Feed Generators"
|
# 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.
|
# 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).")
|
||||||
@@ -7,29 +7,29 @@ from typing import TYPE_CHECKING, Any
|
|||||||
fromapprove.translation import translate as _
|
fromapprove.translation import translate as _
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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__)
|
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
|
# 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.
|
# is simpler than Mastodon's, or if user-customizable templates are not a primary feature.
|
||||||
# However, having the structure allows for future expansion.
|
# However, having the structure allows for future expansion.
|
||||||
|
|
||||||
# Example: Customizing the format of a "new follower" notification, or how a "skeet" is displayed.
|
# Example: Customizing the format of a "new follower" notification, or how a "skeet" is displayed.
|
||||||
|
|
||||||
class ATProtoSocialTemplateEditor:
|
class BlueskiTemplateEditor:
|
||||||
def __init__(self, session: ATProtoSocialSession) -> None:
|
def __init__(self, session: BlueskiSession) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
# self.user_id = session.user_id
|
# 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]]:
|
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.
|
Each entry should describe the template, its purpose, and current value.
|
||||||
"""
|
"""
|
||||||
# This would typically fetch template definitions from a default set
|
# 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
|
# "id": "new_follower_notification", # Unique ID for this template
|
||||||
# "name": _("New Follower Notification Format"),
|
# "name": _("New Follower Notification Format"),
|
||||||
# "description": _("Customize how new follower notifications from ATProtoSocial are displayed."),
|
# "description": _("Customize how new follower notifications from Blueski are displayed."),
|
||||||
# "default_template": "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on ATProtoSocial!",
|
# "default_template": "{{ actor.displayName }} (@{{ actor.handle }}) is now following you on Blueski!",
|
||||||
# "current_template": self._get_template_content("new_follower_notification"),
|
# "current_template": self._get_template_content("new_follower_notification"),
|
||||||
# "variables": [ # Available variables for this template
|
# "variables": [ # Available variables for this template
|
||||||
# {"name": "actor.displayName", "description": _("Display name of the new follower")},
|
# {"name": "actor.displayName", "description": _("Display name of the new follower")},
|
||||||
@@ -50,10 +50,10 @@ class ATProtoSocialTemplateEditor:
|
|||||||
# ],
|
# ],
|
||||||
# "category": "notifications", # For grouping in UI
|
# "category": "notifications", # For grouping in UI
|
||||||
# },
|
# },
|
||||||
# # Add more editable templates for ATProtoSocial here
|
# # Add more editable templates for Blueski here
|
||||||
# ]
|
# ]
|
||||||
# return templates
|
# 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:
|
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.
|
# This could be hardcoded or loaded from a defaults file.
|
||||||
# if template_id == "new_follower_notification":
|
# 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
|
# # ... other default templates
|
||||||
return "" # Placeholder
|
return "" # Placeholder
|
||||||
|
|
||||||
@@ -81,10 +81,10 @@ class ATProtoSocialTemplateEditor:
|
|||||||
# config_key = self.config_prefix + template_id
|
# config_key = self.config_prefix + template_id
|
||||||
# try:
|
# try:
|
||||||
# await approve.config.config.set_value(config_key, content) # Example config access
|
# 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
|
# return True
|
||||||
# except Exception as e:
|
# 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
|
||||||
return False # Placeholder
|
return False # Placeholder
|
||||||
|
|
||||||
@@ -104,9 +104,9 @@ class ATProtoSocialTemplateEditor:
|
|||||||
# # return preview
|
# # return preview
|
||||||
# return f"Preview for '{template_id}': {content_to_render}" # Basic placeholder
|
# return f"Preview for '{template_id}': {content_to_render}" # Basic placeholder
|
||||||
# except Exception as e:
|
# 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 _("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]:
|
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.
|
# 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 {
|
return {
|
||||||
"editable_templates": editor.get_editable_templates(),
|
"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)
|
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.
|
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)
|
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).")
|
||||||
@@ -7,32 +7,32 @@ fromapprove.translation import translate as _
|
|||||||
# fromapprove.controller.mastodon import userActions as mastodon_user_actions # If adapting
|
# fromapprove.controller.mastodon import userActions as mastodon_user_actions # If adapting
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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__)
|
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.
|
# typically represented as buttons or links in the UI, often on user profiles or posts.
|
||||||
|
|
||||||
# For ATProtoSocial, actions might include:
|
# For Blueski, actions might include:
|
||||||
# - Viewing a user's profile on Bluesky/ATProtoSocial instance.
|
# - Viewing a user's profile on Bluesky/Blueski instance.
|
||||||
# - Following/Unfollowing a user.
|
# - Following/Unfollowing a user.
|
||||||
# - Muting/Blocking a user.
|
# - Muting/Blocking a user.
|
||||||
# - Reporting a user.
|
# - Reporting a user.
|
||||||
# - Fetching a user's latest posts.
|
# - Fetching a user's latest posts.
|
||||||
|
|
||||||
# These actions are often presented in a context menu or as direct buttons.
|
# 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,
|
# 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.
|
# or if actions are too complex for simple lambda/method calls in the session class.
|
||||||
|
|
||||||
# Example structure for defining an action:
|
# Example structure for defining an action:
|
||||||
# (This might be more detailed if actions require forms or multi-step processes)
|
# (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.
|
# Generates data for a "View Profile on Blueski" action.
|
||||||
# user_id here would be the ATProtoSocial DID or handle.
|
# 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
|
# # 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.
|
# # 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}"
|
# # profile_url = f"https://bsky.app/profile/{handle}"
|
||||||
|
|
||||||
# return {
|
# return {
|
||||||
# "id": "atprotosocial_view_profile",
|
# "id": "blueski_view_profile",
|
||||||
# "label": _("View Profile on Bluesky"),
|
# "label": _("View Profile on Bluesky"),
|
||||||
# "icon": "external-link-alt", # FontAwesome icon name
|
# "icon": "external-link-alt", # FontAwesome icon name
|
||||||
# "action_type": "link", # "link", "modal", "api_call"
|
# "action_type": "link", # "link", "modal", "api_call"
|
||||||
# "url": profile_url, # For "link" type
|
# "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},
|
# # "payload": {"action": "view_profile", "target_user_id": user_id},
|
||||||
# "confirmation_required": False,
|
# "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.
|
# target_user_id should be the DID of the user to follow.
|
||||||
# """
|
# """
|
||||||
# # success = await session.util.follow_user(target_user_id)
|
# # 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,
|
# 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
|
# 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.
|
# 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
|
# 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.
|
# 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).")
|
||||||
@@ -7,39 +7,39 @@ fromapprove.translation import translate as _
|
|||||||
# fromapprove.controller.mastodon import userList as mastodon_user_list # If adapting
|
# fromapprove.controller.mastodon import userList as mastodon_user_list # If adapting
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession # Adjusted
|
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
|
||||||
# Define a type for what a user entry in a list might look like for ATProtoSocial
|
# Define a type for what a user entry in a list might look like for Blueski
|
||||||
ATProtoSocialUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."}
|
BlueskiUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."}
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
# Examples include:
|
||||||
# - Followers of a user
|
# - Followers of a user
|
||||||
# - Users a user is following
|
# - Users a user is following
|
||||||
# - Users who liked or reposted a post
|
# - 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
|
# - Search results for users
|
||||||
|
|
||||||
# The structure will likely involve:
|
# 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.
|
# - 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(
|
async def fetch_followers(
|
||||||
session: ATProtoSocialSession,
|
session: BlueskiSession,
|
||||||
user_id: str, # DID of the user whose followers to fetch
|
user_id: str, # DID of the user whose followers to fetch
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
cursor: str | None = None
|
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.
|
user_id is the DID of the target user.
|
||||||
Yields user data dictionaries.
|
Yields user data dictionaries.
|
||||||
"""
|
"""
|
||||||
# client = await session.util._get_client() # Get authenticated client
|
# client = await session.util._get_client() # Get authenticated client
|
||||||
# if not 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
|
# return
|
||||||
|
|
||||||
# current_cursor = cursor
|
# current_cursor = cursor
|
||||||
@@ -80,7 +80,7 @@ async def fetch_followers(
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
if not session.is_ready():
|
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
|
# yield {} # Stop iteration if not ready
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -94,22 +94,22 @@ async def fetch_followers(
|
|||||||
logger.info(f"No followers data returned for user {user_id}.")
|
logger.info(f"No followers data returned for user {user_id}.")
|
||||||
|
|
||||||
except Exception as e:
|
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
|
# Depending on desired error handling, could raise or yield an error marker
|
||||||
|
|
||||||
|
|
||||||
async def fetch_following(
|
async def fetch_following(
|
||||||
session: ATProtoSocialSession,
|
session: BlueskiSession,
|
||||||
user_id: str, # DID of the user whose followed accounts to fetch
|
user_id: str, # DID of the user whose followed accounts to fetch
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
cursor: str | None = None
|
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.
|
Yields user data dictionaries.
|
||||||
"""
|
"""
|
||||||
if not session.is_ready():
|
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
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -122,21 +122,21 @@ async def fetch_following(
|
|||||||
logger.info(f"No following data returned for user {user_id}.")
|
logger.info(f"No following data returned for user {user_id}.")
|
||||||
|
|
||||||
except Exception as e:
|
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(
|
async def search_users(
|
||||||
session: ATProtoSocialSession,
|
session: BlueskiSession,
|
||||||
query: str,
|
query: str,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
cursor: str | None = None
|
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.
|
Yields user data dictionaries.
|
||||||
"""
|
"""
|
||||||
if not session.is_ready():
|
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
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -149,25 +149,25 @@ async def search_users(
|
|||||||
logger.info(f"No users found for search term '{query}'.")
|
logger.info(f"No users found for search term '{query}'.")
|
||||||
|
|
||||||
except Exception as e:
|
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
|
# This function is designed to be called by an API endpoint that returns JSON
|
||||||
async def get_user_list_paginated(
|
async def get_user_list_paginated(
|
||||||
session: ATProtoSocialSession,
|
session: BlueskiSession,
|
||||||
list_type: str, # "followers", "following", "search"
|
list_type: str, # "followers", "following", "search"
|
||||||
identifier: str, # User DID for followers/following, or search query for search
|
identifier: str, # User DID for followers/following, or search query for search
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
cursor: str | None = None
|
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)
|
Fetches a paginated list of users (followers, following, or search results)
|
||||||
and returns the list and the next cursor.
|
and returns the list and the next cursor.
|
||||||
"""
|
"""
|
||||||
users_list: list[ATProtoSocialUserListItem] = []
|
users_list: list[BlueskiUserListItem] = []
|
||||||
next_cursor: str | None = None
|
next_cursor: str | None = None
|
||||||
|
|
||||||
if not session.is_ready():
|
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
|
return [], None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -192,13 +192,13 @@ async def get_user_list_paginated(
|
|||||||
return users_list, next_cursor
|
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.
|
Fetches detailed profile information for a user by DID or handle.
|
||||||
Returns a dictionary of formatted profile data, or None if not found/error.
|
Returns a dictionary of formatted profile data, or None if not found/error.
|
||||||
"""
|
"""
|
||||||
if not session.is_ready():
|
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
|
return None
|
||||||
|
|
||||||
try:
|
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.
|
# 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).
|
# 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).")
|
||||||
@@ -280,6 +280,12 @@ class BaseBuffer(base.Buffer):
|
|||||||
return
|
return
|
||||||
menu = menus.base()
|
menu = menus.base()
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
|
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)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
|
||||||
if self.can_share() == True:
|
if self.can_share() == True:
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
|
||||||
@@ -501,6 +507,49 @@ class BaseBuffer(base.Buffer):
|
|||||||
log.exception("")
|
log.exception("")
|
||||||
self.session.db[self.name] = items
|
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):
|
def user_details(self):
|
||||||
item = self.get_item()
|
item = self.get_item()
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -161,6 +161,13 @@ class NotificationsBuffer(BaseBuffer):
|
|||||||
menu = menus.notification(notification.type)
|
menu = menus.notification(notification.type)
|
||||||
if self.is_post():
|
if self.is_post():
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
|
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)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
|
||||||
if self.can_share() == True:
|
if self.can_share() == True:
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from mysc import localization
|
|||||||
from mysc.thread_utils import call_threaded
|
from mysc.thread_utils import call_threaded
|
||||||
from mysc.repeating_timer import RepeatingTimer
|
from mysc.repeating_timer import RepeatingTimer
|
||||||
from controller.mastodon import handler as MastodonHandler
|
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
|
from . import settings, userAlias
|
||||||
|
|
||||||
log = logging.getLogger("mainController")
|
log = logging.getLogger("mainController")
|
||||||
@@ -99,8 +99,8 @@ class Controller(object):
|
|||||||
try:
|
try:
|
||||||
if type == "mastodon":
|
if type == "mastodon":
|
||||||
return MastodonHandler.Handler()
|
return MastodonHandler.Handler()
|
||||||
if type == "atprotosocial":
|
if type == "blueski":
|
||||||
return ATProtoSocialHandler.Handler()
|
return BlueskiHandler.Handler()
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Error creating handler for type %s", type)
|
log.exception("Error creating handler for type %s", type)
|
||||||
return None
|
return None
|
||||||
@@ -207,8 +207,8 @@ class Controller(object):
|
|||||||
if handler is None:
|
if handler is None:
|
||||||
if type == "mastodon":
|
if type == "mastodon":
|
||||||
handler = MastodonHandler.Handler()
|
handler = MastodonHandler.Handler()
|
||||||
elif type == "atprotosocial":
|
elif type == "blueski":
|
||||||
handler = ATProtoSocialHandler.Handler()
|
handler = BlueskiHandler.Handler()
|
||||||
self.handlers[type] = handler
|
self.handlers[type] = handler
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
@@ -250,9 +250,9 @@ class Controller(object):
|
|||||||
for i in sessions.sessions:
|
for i in sessions.sessions:
|
||||||
log.debug("Working on session %s" % (i,))
|
log.debug("Working on session %s" % (i,))
|
||||||
if sessions.sessions[i].is_logged == False:
|
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:
|
try:
|
||||||
if getattr(sessions.sessions[i], "type", None) == "atprotosocial":
|
if getattr(sessions.sessions[i], "type", None) == "blueski":
|
||||||
sessions.sessions[i].login()
|
sessions.sessions[i].login()
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Auto-login attempt failed for session %s", i)
|
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])
|
self.create_ignored_session_buffer(sessions.sessions[i])
|
||||||
continue
|
continue
|
||||||
# Supported session types
|
# Supported session types
|
||||||
valid_session_types = ["mastodon", "atprotosocial"]
|
valid_session_types = ["mastodon", "blueski"]
|
||||||
if sessions.sessions[i].type in valid_session_types:
|
if sessions.sessions[i].type in valid_session_types:
|
||||||
try:
|
try:
|
||||||
handler = self.get_handler(type=sessions.sessions[i].type)
|
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))
|
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:
|
if kwargs.get("parent") == None:
|
||||||
kwargs["parent"] = self.view.nb
|
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))
|
raise AttributeError("Session type %s does not exist yet." % (session_type))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
buffer_panel_class = None
|
buffer_panel_class = None
|
||||||
if session_type == "atprotosocial":
|
if session_type == "blueski":
|
||||||
from wxUI.buffers.atprotosocial import panels as ATProtoSocialPanels # Import new panels
|
from wxUI.buffers.blueski import panels as BlueskiPanels # Import new panels
|
||||||
if buffer_type == "home_timeline":
|
if buffer_type == "home_timeline":
|
||||||
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialHomeTimelinePanel
|
buffer_panel_class = BlueskiPanels.BlueskiHomeTimelinePanel
|
||||||
# kwargs for HomeTimelinePanel: parent, name, session
|
# kwargs for HomeTimelinePanel: parent, name, session
|
||||||
# 'name' is buffer_title, 'parent' is self.view.nb
|
# 'name' is buffer_title, 'parent' is self.view.nb
|
||||||
# 'session' needs to be fetched based on user_id in kwargs
|
# '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
|
if "name" not in kwargs: kwargs["name"] = buffer_title
|
||||||
|
|
||||||
elif buffer_type == "user_timeline":
|
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
|
# kwargs for UserTimelinePanel: parent, name, session, target_user_did, target_user_handle
|
||||||
if "user_id" in kwargs and "session" not in kwargs:
|
if "user_id" in kwargs and "session" not in kwargs:
|
||||||
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
||||||
kwargs.pop("user_id", None)
|
kwargs.pop("user_id", None)
|
||||||
if "name" not in kwargs: kwargs["name"] = buffer_title
|
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":
|
elif buffer_type == "notifications":
|
||||||
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialNotificationPanel
|
buffer_panel_class = BlueskiPanels.BlueskiNotificationPanel
|
||||||
if "user_id" in kwargs and "session" not in kwargs:
|
if "user_id" in kwargs and "session" not in kwargs:
|
||||||
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
||||||
kwargs.pop("user_id", None)
|
kwargs.pop("user_id", None)
|
||||||
if "name" not in kwargs: kwargs["name"] = buffer_title
|
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":
|
elif buffer_type == "notifications":
|
||||||
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialNotificationPanel
|
buffer_panel_class = BlueskiPanels.BlueskiNotificationPanel
|
||||||
if "user_id" in kwargs and "session" not in kwargs:
|
if "user_id" in kwargs and "session" not in kwargs:
|
||||||
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
||||||
kwargs.pop("user_id", None)
|
kwargs.pop("user_id", None)
|
||||||
if "name" not in kwargs: kwargs["name"] = buffer_title
|
if "name" not in kwargs: kwargs["name"] = buffer_title
|
||||||
elif buffer_type == "user_list_followers" or buffer_type == "user_list_following":
|
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":
|
elif buffer_type == "following_timeline":
|
||||||
buffer_panel_class = ATProtoSocialPanels.ATProtoSocialFollowingTimelinePanel
|
buffer_panel_class = BlueskiPanels.BlueskiFollowingTimelinePanel
|
||||||
# Clean stray keys that this panel doesn't accept
|
# Clean stray keys that this panel doesn't accept
|
||||||
kwargs.pop("user_id", None)
|
kwargs.pop("user_id", None)
|
||||||
kwargs.pop("list_type", None)
|
kwargs.pop("list_type", None)
|
||||||
if "name" not in kwargs: kwargs["name"] = buffer_title
|
if "name" not in kwargs: kwargs["name"] = buffer_title
|
||||||
else:
|
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
|
# Fallback to trying to find it in generic buffers or error
|
||||||
available_buffers = getattr(buffers, "base", None) # Or some generic panel module
|
available_buffers = getattr(buffers, "base", None) # Or some generic panel module
|
||||||
if available_buffers and hasattr(available_buffers, buffer_type):
|
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
|
elif available_buffers and hasattr(available_buffers, "TimelinePanel"): # Example generic
|
||||||
buffer_panel_class = getattr(available_buffers, "TimelinePanel")
|
buffer_panel_class = getattr(available_buffers, "TimelinePanel")
|
||||||
else:
|
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
|
else: # Existing logic for other session types
|
||||||
available_buffers = getattr(buffers, session_type)
|
available_buffers = getattr(buffers, session_type)
|
||||||
if not hasattr(available_buffers, buffer_type):
|
if not hasattr(available_buffers, buffer_type):
|
||||||
@@ -549,6 +549,15 @@ class Controller(object):
|
|||||||
buffer = self.search_buffer(buffer.name, buffer.account)
|
buffer = self.search_buffer(buffer.name, buffer.account)
|
||||||
buffer.destroy_status()
|
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):
|
def exit(self, *args, **kwargs):
|
||||||
if config.app["app-settings"]["ask_at_exit"] == True:
|
if config.app["app-settings"]["ask_at_exit"] == True:
|
||||||
answer = commonMessageDialogs.exit_dialog(self.view)
|
answer = commonMessageDialogs.exit_dialog(self.view)
|
||||||
@@ -598,7 +607,7 @@ class Controller(object):
|
|||||||
|
|
||||||
session = buffer.session
|
session = buffer.session
|
||||||
# Compose for Bluesky (ATProto): dialog with attachments/CW/language
|
# 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
|
# In invisible interface, prefer a quick, minimal compose to avoid complex UI
|
||||||
if self.showing == False:
|
if self.showing == False:
|
||||||
# Parent=None so it shows even if main window is hidden
|
# Parent=None so it shows even if main window is hidden
|
||||||
@@ -620,7 +629,7 @@ class Controller(object):
|
|||||||
else:
|
else:
|
||||||
dlg.Destroy()
|
dlg.Destroy()
|
||||||
return
|
return
|
||||||
from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog
|
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
|
||||||
dlg = ATPostDialog()
|
dlg = ATPostDialog()
|
||||||
if dlg.ShowModal() == wx.ID_OK:
|
if dlg.ShowModal() == wx.ID_OK:
|
||||||
text, files, cw_text, langs = dlg.get_payload()
|
text, files, cw_text, langs = dlg.get_payload()
|
||||||
@@ -712,7 +721,7 @@ class Controller(object):
|
|||||||
return
|
return
|
||||||
|
|
||||||
session = buffer.session
|
session = buffer.session
|
||||||
if getattr(session, "type", "") == "atprotosocial":
|
if getattr(session, "type", "") == "blueski":
|
||||||
if self.showing == False:
|
if self.showing == False:
|
||||||
dlg = wx.TextEntryDialog(None, _("Write your reply:"), _("Reply"))
|
dlg = wx.TextEntryDialog(None, _("Write your reply:"), _("Reply"))
|
||||||
if dlg.ShowModal() == wx.ID_OK:
|
if dlg.ShowModal() == wx.ID_OK:
|
||||||
@@ -732,7 +741,7 @@ class Controller(object):
|
|||||||
else:
|
else:
|
||||||
dlg.Destroy()
|
dlg.Destroy()
|
||||||
return
|
return
|
||||||
from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog
|
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
|
||||||
dlg = ATPostDialog(caption=_("Reply"))
|
dlg = ATPostDialog(caption=_("Reply"))
|
||||||
if dlg.ShowModal() == wx.ID_OK:
|
if dlg.ShowModal() == wx.ID_OK:
|
||||||
text, files, cw_text, langs = dlg.get_payload()
|
text, files, cw_text, langs = dlg.get_payload()
|
||||||
@@ -774,7 +783,7 @@ class Controller(object):
|
|||||||
session = getattr(buffer, "session", None)
|
session = getattr(buffer, "session", None)
|
||||||
if not session:
|
if not session:
|
||||||
return
|
return
|
||||||
if getattr(session, "type", "") == "atprotosocial":
|
if getattr(session, "type", "") == "blueski":
|
||||||
item_uri = None
|
item_uri = None
|
||||||
if hasattr(buffer, "get_selected_item_id"):
|
if hasattr(buffer, "get_selected_item_id"):
|
||||||
item_uri = buffer.get_selected_item_id()
|
item_uri = buffer.get_selected_item_id()
|
||||||
@@ -819,7 +828,7 @@ class Controller(object):
|
|||||||
dlg.Destroy()
|
dlg.Destroy()
|
||||||
return
|
return
|
||||||
|
|
||||||
from wxUI.dialogs.atprotosocial.postDialogs import Post as ATPostDialog
|
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
|
||||||
dlg = ATPostDialog(caption=_("Quote post"))
|
dlg = ATPostDialog(caption=_("Quote post"))
|
||||||
if dlg.ShowModal() == wx.ID_OK:
|
if dlg.ShowModal() == wx.ID_OK:
|
||||||
text, files, cw_text, langs = dlg.get_payload()
|
text, files, cw_text, langs = dlg.get_payload()
|
||||||
@@ -865,7 +874,7 @@ class Controller(object):
|
|||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
if hasattr(buffer, "add_to_favorites"): # Generic buffer method
|
if hasattr(buffer, "add_to_favorites"): # Generic buffer method
|
||||||
return buffer.add_to_favorites()
|
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()
|
item_uri = buffer.get_selected_item_id()
|
||||||
if not item_uri:
|
if not item_uri:
|
||||||
output.speak(_("No item selected to like."), True)
|
output.speak(_("No item selected to like."), True)
|
||||||
@@ -894,7 +903,7 @@ class Controller(object):
|
|||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
if hasattr(buffer, "remove_from_favorites"): # Generic buffer method
|
if hasattr(buffer, "remove_from_favorites"): # Generic buffer method
|
||||||
return buffer.remove_from_favorites()
|
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()
|
item_uri = buffer.get_selected_item_id()
|
||||||
if not item_uri:
|
if not item_uri:
|
||||||
output.speak(_("No item selected to unlike."), True)
|
output.speak(_("No item selected to unlike."), True)
|
||||||
@@ -1423,9 +1432,9 @@ class Controller(object):
|
|||||||
def update_buffers(self):
|
def update_buffers(self):
|
||||||
for i in self.buffers[:]:
|
for i in self.buffers[:]:
|
||||||
if i.session != None and i.session.is_logged == True:
|
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.
|
# Periodic updates would need a separate timer or manual refresh via update_buffer.
|
||||||
if i.session.KIND != "atprotosocial":
|
if i.session.KIND != "blueski":
|
||||||
try:
|
try:
|
||||||
i.start_stream(mandatory=True) # This is likely for streaming connections or timed polling within buffer
|
i.start_stream(mandatory=True) # This is likely for streaming connections or timed polling within buffer
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
@@ -1444,7 +1453,7 @@ class Controller(object):
|
|||||||
async def do_update():
|
async def do_update():
|
||||||
new_ids = []
|
new_ids = []
|
||||||
try:
|
try:
|
||||||
if session.KIND == "atprotosocial":
|
if session.KIND == "blueski":
|
||||||
if bf.name == f"{session.label} Home": # Assuming buffer name indicates type
|
if bf.name == f"{session.label} Home": # Assuming buffer name indicates type
|
||||||
# Its panel's load_initial_posts calls session.fetch_home_timeline
|
# Its panel's load_initial_posts calls session.fetch_home_timeline
|
||||||
if hasattr(bf, "load_initial_posts"): # Generic for timeline panels
|
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))
|
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)]
|
new_ids = [u.get("did") for u in getattr(bf, "user_list_data", []) if isinstance(u,dict)]
|
||||||
else:
|
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)
|
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
|
if count is not None: new_ids = [str(x) for x in range(count)] # Dummy IDs for count
|
||||||
else:
|
else:
|
||||||
@@ -1506,14 +1515,14 @@ class Controller(object):
|
|||||||
# e.g., bf.pagination_cursor or bf.older_items_cursor
|
# 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.
|
# 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"
|
# The panel (bf) itself should manage its own cursor for "load more"
|
||||||
|
|
||||||
current_cursor = None
|
current_cursor = None
|
||||||
can_load_more_natively = False
|
can_load_more_natively = False
|
||||||
|
|
||||||
if session.KIND == "atprotosocial":
|
if session.KIND == "blueski":
|
||||||
if hasattr(bf, "load_more_posts"): # For ATProtoSocialUserTimelinePanel & ATProtoSocialHomeTimelinePanel
|
if hasattr(bf, "load_more_posts"): # For BlueskiUserTimelinePanel & BlueskiHomeTimelinePanel
|
||||||
can_load_more_natively = True
|
can_load_more_natively = True
|
||||||
if hasattr(bf, "load_more_posts"):
|
if hasattr(bf, "load_more_posts"):
|
||||||
can_load_more_natively = True
|
can_load_more_natively = True
|
||||||
@@ -1530,7 +1539,7 @@ class Controller(object):
|
|||||||
else:
|
else:
|
||||||
output.speak(_(u"This buffer does not support loading more items in this way."), True)
|
output.speak(_(u"This buffer does not support loading more items in this way."), True)
|
||||||
return
|
return
|
||||||
else: # For other non-ATProtoSocial session types
|
else: # For other non-Blueski session types
|
||||||
if hasattr(bf, "get_more_items"):
|
if hasattr(bf, "get_more_items"):
|
||||||
return bf.get_more_items()
|
return bf.get_more_items()
|
||||||
else:
|
else:
|
||||||
@@ -1541,7 +1550,7 @@ class Controller(object):
|
|||||||
|
|
||||||
async def do_load_more():
|
async def do_load_more():
|
||||||
try:
|
try:
|
||||||
if session.KIND == "atprotosocial":
|
if session.KIND == "blueski":
|
||||||
if hasattr(bf, "load_more_posts"):
|
if hasattr(bf, "load_more_posts"):
|
||||||
await bf.load_more_posts(limit=config.app["app-settings"].get("items_per_request", 20))
|
await bf.load_more_posts(limit=config.app["app-settings"].get("items_per_request", 20))
|
||||||
elif hasattr(bf, "load_more_users"):
|
elif hasattr(bf, "load_more_users"):
|
||||||
@@ -1664,7 +1673,7 @@ class Controller(object):
|
|||||||
if handler and hasattr(handler, 'user_details'):
|
if handler and hasattr(handler, 'user_details'):
|
||||||
# The handler's user_details method is responsible for extracting context
|
# The handler's user_details method is responsible for extracting context
|
||||||
# (e.g., selected user) from the buffer and displaying the profile.
|
# (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.
|
# It's an async method, so needs to be called appropriately.
|
||||||
async def _show_details():
|
async def _show_details():
|
||||||
await handler.user_details(buffer)
|
await handler.user_details(buffer)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class Handler(object):
|
|||||||
addAlias=_("Add a&lias"),
|
addAlias=_("Add a&lias"),
|
||||||
addToList=None,
|
addToList=None,
|
||||||
removeFromList=None,
|
removeFromList=None,
|
||||||
details=_("Show user profile"),
|
details=_("S&how user profile"),
|
||||||
favs=None,
|
favs=None,
|
||||||
# In buffer Menu.
|
# In buffer Menu.
|
||||||
community_timeline =_("Create c&ommunity timeline"),
|
community_timeline =_("Create c&ommunity timeline"),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import wx
|
import wx
|
||||||
|
import logging
|
||||||
import widgetUtils
|
import widgetUtils
|
||||||
import config
|
import config
|
||||||
import output
|
import output
|
||||||
@@ -14,6 +15,8 @@ from wxUI.dialogs.mastodon import postDialogs
|
|||||||
from extra.autocompletionUsers import completion
|
from extra.autocompletionUsers import completion
|
||||||
from . import userList
|
from . import userList
|
||||||
|
|
||||||
|
log = logging.getLogger("controller.mastodon.messages")
|
||||||
|
|
||||||
def character_count(post_text, post_cw, character_limit=500):
|
def character_count(post_text, post_cw, character_limit=500):
|
||||||
# We will use text for counting character limit only.
|
# We will use text for counting character limit only.
|
||||||
full_text = post_text+post_cw
|
full_text = post_text+post_cw
|
||||||
@@ -262,6 +265,108 @@ class post(messages.basicMessage):
|
|||||||
visibility_setting = visibility_settings.index(setting)
|
visibility_setting = visibility_settings.index(setting)
|
||||||
self.message.visibility.SetSelection(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):
|
class viewPost(post):
|
||||||
def __init__(self, session, post, offset_hours=0, date="", item_url=""):
|
def __init__(self, session, post, offset_hours=0, date="", item_url=""):
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ class autocompletionManageDialog(widgetUtils.BaseDialog):
|
|||||||
self.users = widgets.list(panel, _(u"Username"), _(u"Name"), style=wx.LC_REPORT)
|
self.users = widgets.list(panel, _(u"Username"), _(u"Name"), style=wx.LC_REPORT)
|
||||||
sizer.Add(label, 0, wx.ALL, 5)
|
sizer.Add(label, 0, wx.ALL, 5)
|
||||||
sizer.Add(self.users.list, 0, wx.ALL, 5)
|
sizer.Add(self.users.list, 0, wx.ALL, 5)
|
||||||
self.add = wx.Button(panel, -1, _(u"Add user"))
|
self.add = wx.Button(panel, -1, _(u"&Add user"))
|
||||||
self.remove = wx.Button(panel, -1, _(u"Remove user"))
|
self.remove = wx.Button(panel, -1, _(u"&Remove user"))
|
||||||
optionsBox = wx.BoxSizer(wx.HORIZONTAL)
|
optionsBox = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
optionsBox.Add(self.add, 0, wx.ALL, 5)
|
optionsBox.Add(self.add, 0, wx.ALL, 5)
|
||||||
optionsBox.Add(self.remove, 0, wx.ALL, 5)
|
optionsBox.Add(self.remove, 0, wx.ALL, 5)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ url = string(default="control+win+b")
|
|||||||
go_home = string(default="control+win+home")
|
go_home = string(default="control+win+home")
|
||||||
go_end = string(default="control+win+end")
|
go_end = string(default="control+win+end")
|
||||||
delete = string(default="control+win+delete")
|
delete = string(default="control+win+delete")
|
||||||
|
edit_post = string(default="")
|
||||||
clear_buffer = string(default="control+win+shift+delete")
|
clear_buffer = string(default="control+win+shift+delete")
|
||||||
repeat_item = string(default="control+win+space")
|
repeat_item = string(default="control+win+space")
|
||||||
copy_to_clipboard = string(default="control+win+shift+c")
|
copy_to_clipboard = string(default="control+win+shift+c")
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup")
|
|||||||
go_page_down = string(default="control+win+pagedown")
|
go_page_down = string(default="control+win+pagedown")
|
||||||
update_profile = string(default="control+win+shift+p")
|
update_profile = string(default="control+win+shift+p")
|
||||||
delete = string(default="control+win+delete")
|
delete = string(default="control+win+delete")
|
||||||
|
edit_post = string(default="")
|
||||||
clear_buffer = string(default="control+win+shift+delete")
|
clear_buffer = string(default="control+win+shift+delete")
|
||||||
repeat_item = string(default="control+win+space")
|
repeat_item = string(default="control+win+space")
|
||||||
copy_to_clipboard = string(default="control+win+shift+c")
|
copy_to_clipboard = string(default="control+win+shift+c")
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup")
|
|||||||
go_page_down = string(default="control+win+pagedown")
|
go_page_down = string(default="control+win+pagedown")
|
||||||
update_profile = string(default="alt+win+p")
|
update_profile = string(default="alt+win+p")
|
||||||
delete = string(default="alt+win+delete")
|
delete = string(default="alt+win+delete")
|
||||||
|
edit_post = string(default="")
|
||||||
clear_buffer = string(default="alt+win+shift+delete")
|
clear_buffer = string(default="alt+win+shift+delete")
|
||||||
repeat_item = string(default="alt+win+space")
|
repeat_item = string(default="alt+win+space")
|
||||||
copy_to_clipboard = string(default="alt+win+shift+c")
|
copy_to_clipboard = string(default="alt+win+shift+c")
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ go_page_up = string(default="control+win+pageup")
|
|||||||
go_page_down = string(default="control+win+pagedown")
|
go_page_down = string(default="control+win+pagedown")
|
||||||
update_profile = string(default="alt+win+p")
|
update_profile = string(default="alt+win+p")
|
||||||
delete = string(default="alt+win+delete")
|
delete = string(default="alt+win+delete")
|
||||||
|
edit_post = string(default="")
|
||||||
clear_buffer = string(default="alt+win+shift+delete")
|
clear_buffer = string(default="alt+win+shift+delete")
|
||||||
repeat_item = string(default="control+alt+win+space")
|
repeat_item = string(default="control+alt+win+space")
|
||||||
copy_to_clipboard = string(default="alt+win+shift+c")
|
copy_to_clipboard = string(default="alt+win+shift+c")
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ go_page_up = string(default="control+win+pageup")
|
|||||||
go_page_down = string(default="control+win+pagedown")
|
go_page_down = string(default="control+win+pagedown")
|
||||||
update_profile = string(default="alt+win+p")
|
update_profile = string(default="alt+win+p")
|
||||||
delete = string(default="control+win+delete")
|
delete = string(default="control+win+delete")
|
||||||
|
edit_post = string(default="")
|
||||||
clear_buffer = string(default="control+win+shift+delete")
|
clear_buffer = string(default="control+win+shift+delete")
|
||||||
repeat_item = string(default="control+win+space")
|
repeat_item = string(default="control+win+space")
|
||||||
copy_to_clipboard = string(default="control+win+shift+c")
|
copy_to_clipboard = string(default="control+win+shift+c")
|
||||||
|
|||||||
Binary file not shown.
@@ -1,22 +1,23 @@
|
|||||||
# SOME DESCRIPTIVE TITLE.
|
# SOME DESCRIPTIVE TITLE.
|
||||||
# Copyright (C) 2019 ORGANIZATION
|
# Copyright (C) 2019 ORGANIZATION
|
||||||
# FIRST AUTHOR <EMAIL@ADDRESS>, 2019.
|
# FIRST AUTHOR <EMAIL@ADDRESS>, 2019.
|
||||||
# zvonimir stanecic <zvonimirek222@yandex.com>, 2023.
|
# zvonimir stanecic <zvonimirek222@yandex.com>, 2023, 2025.
|
||||||
msgid ""
|
msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: Tw Blue 0.80\n"
|
"Project-Id-Version: Tw Blue 0.80\n"
|
||||||
"Report-Msgid-Bugs-To: manuel@manuelcortez.net\n"
|
"Report-Msgid-Bugs-To: manuel@manuelcortez.net\n"
|
||||||
"POT-Creation-Date: 2025-04-13 01:18+0000\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 <zvonimirek222@yandex.com>\n"
|
"Last-Translator: zvonimir stanecic <zvonimirek222@yandex.com>\n"
|
||||||
|
"Language-Team: Polish <https://weblate.mcvsoftware.com/projects/twblue/"
|
||||||
|
"twblue/pl/>\n"
|
||||||
"Language: pl\n"
|
"Language: pl\n"
|
||||||
"Language-Team: Polish "
|
|
||||||
"<https://weblate.mcvsoftware.com/projects/twblue/twblue/pl/>\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"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=utf-8\n"
|
"Content-Type: text/plain; charset=utf-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\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"
|
"Generated-By: Babel 2.17.0\n"
|
||||||
|
|
||||||
#: languageHandler.py:61
|
#: languageHandler.py:61
|
||||||
@@ -101,7 +102,7 @@ msgstr "Domyślne dla użytkownika"
|
|||||||
#: main.py:105
|
#: main.py:105
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
msgid "https://twblue.mcvsoftware.com/donate"
|
msgid "https://twblue.mcvsoftware.com/donate"
|
||||||
msgstr "https://twblue.es/donate"
|
msgstr "https://twblue.mcvsoftware.com/donate"
|
||||||
|
|
||||||
#: main.py:118
|
#: main.py:118
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
@@ -246,9 +247,8 @@ msgid "Following for {}"
|
|||||||
msgstr "Śledzący użytkownika {}"
|
msgstr "Śledzący użytkownika {}"
|
||||||
|
|
||||||
#: controller/messages.py:18
|
#: controller/messages.py:18
|
||||||
#, fuzzy
|
|
||||||
msgid "Translated"
|
msgid "Translated"
|
||||||
msgstr "&Przetłumacz"
|
msgstr "Przetłumaczono"
|
||||||
|
|
||||||
#: controller/settings.py:60
|
#: controller/settings.py:60
|
||||||
msgid "System default"
|
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."
|
msgstr "W tym buforze nie ma więcej elementów."
|
||||||
|
|
||||||
#: controller/mastodon/handler.py:30 wxUI/dialogs/mastodon/updateProfile.py:35
|
#: controller/mastodon/handler.py:30 wxUI/dialogs/mastodon/updateProfile.py:35
|
||||||
#, fuzzy
|
|
||||||
msgid "Update Profile"
|
msgid "Update Profile"
|
||||||
msgstr "&Edytuj profil"
|
msgstr "Zaktualizuj profil"
|
||||||
|
|
||||||
#: controller/mastodon/handler.py:31 wxUI/dialogs/mastodon/search.py:10
|
#: controller/mastodon/handler.py:31 wxUI/dialogs/mastodon/search.py:10
|
||||||
#: wxUI/view.py:19
|
#: wxUI/view.py:19
|
||||||
@@ -615,13 +614,12 @@ msgid "Add a&lias"
|
|||||||
msgstr "Dodaj a&lias"
|
msgstr "Dodaj a&lias"
|
||||||
|
|
||||||
#: controller/mastodon/handler.py:51
|
#: controller/mastodon/handler.py:51
|
||||||
#, fuzzy
|
|
||||||
msgid "Show user profile"
|
msgid "Show user profile"
|
||||||
msgstr "&Pokaż profil użytkownika"
|
msgstr "Pokaż profil użytkownika"
|
||||||
|
|
||||||
#: controller/mastodon/handler.py:54
|
#: controller/mastodon/handler.py:54
|
||||||
msgid "Create c&ommunity timeline"
|
msgid "Create c&ommunity timeline"
|
||||||
msgstr ""
|
msgstr "Stwórz &oś czasu społeczności"
|
||||||
|
|
||||||
#: controller/mastodon/handler.py:55 wxUI/view.py:57
|
#: controller/mastodon/handler.py:55 wxUI/view.py:57
|
||||||
msgid "Create a &filter"
|
msgid "Create a &filter"
|
||||||
@@ -647,10 +645,9 @@ msgstr "Wyszukiwanie {}"
|
|||||||
|
|
||||||
#: controller/mastodon/handler.py:111
|
#: controller/mastodon/handler.py:111
|
||||||
msgid "Communities"
|
msgid "Communities"
|
||||||
msgstr ""
|
msgstr "Społeczności"
|
||||||
|
|
||||||
#: controller/mastodon/handler.py:114
|
#: controller/mastodon/handler.py:114
|
||||||
#, fuzzy
|
|
||||||
msgid "federated"
|
msgid "federated"
|
||||||
msgstr "federowana"
|
msgstr "federowana"
|
||||||
|
|
||||||
@@ -4864,4 +4861,3 @@ msgstr "Dodatki"
|
|||||||
|
|
||||||
#~ msgid "DeepL API Key: "
|
#~ msgid "DeepL API Key: "
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from pubsub import pub
|
|||||||
from controller import settings
|
from controller import settings
|
||||||
from sessions.mastodon import session as MastodonSession
|
from sessions.mastodon import session as MastodonSession
|
||||||
from sessions.gotosocial import session as GotosocialSession
|
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 manager
|
||||||
from . import wxUI as view
|
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
|
if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "": # Basic validation
|
||||||
sessionsList.append(name)
|
sessionsList.append(name)
|
||||||
self.sessions.append(dict(type=config_test["mastodon"].get("type", "mastodon"), id=i))
|
self.sessions.append(dict(type=config_test["mastodon"].get("type", "mastodon"), id=i))
|
||||||
elif config_test.get("atprotosocial") != None: # Check for ATProtoSocial config
|
elif config_test.get("blueski") != None: # Check for Blueski config
|
||||||
handle = config_test["atprotosocial"].get("handle")
|
handle = config_test["blueski"].get("handle")
|
||||||
did = config_test["atprotosocial"].get("did") # DID confirms it was authorized
|
did = config_test["blueski"].get("did") # DID confirms it was authorized
|
||||||
if handle and did:
|
if handle and did:
|
||||||
name = _("{handle} (Bluesky)").format(handle=handle)
|
name = _("{handle} (Bluesky)").format(handle=handle)
|
||||||
sessionsList.append(name)
|
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
|
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
|
# Optionally delete malformed config here too
|
||||||
try:
|
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))
|
shutil.rmtree(os.path.join(paths.config_path(), i))
|
||||||
except Exception as e:
|
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
|
continue
|
||||||
else: # Unknown or other session type not explicitly handled here for display
|
else: # Unknown or other session type not explicitly handled here for display
|
||||||
try:
|
try:
|
||||||
@@ -117,14 +133,14 @@ class sessionManagerController(object):
|
|||||||
s = MastodonSession.Session(i.get("id"))
|
s = MastodonSession.Session(i.get("id"))
|
||||||
elif i.get("type") == "gotosocial":
|
elif i.get("type") == "gotosocial":
|
||||||
s = GotosocialSession.Session(i.get("id"))
|
s = GotosocialSession.Session(i.get("id"))
|
||||||
elif i.get("type") == "atprotosocial": # Handle ATProtoSocial session type
|
elif i.get("type") == "blueski": # Handle Blueski session type
|
||||||
s = ATProtoSocialSession.Session(i.get("id"))
|
s = BlueskiSession.Session(i.get("id"))
|
||||||
else:
|
else:
|
||||||
log.warning(f"Unknown session type '{i.get('type')}' for ID {i.get('id')}. Skipping.")
|
log.warning(f"Unknown session type '{i.get('type')}' for ID {i.get('id')}. Skipping.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
s.get_configuration() # Load per-session configuration
|
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,
|
# Login is now primarily handled by session.start() via mainController,
|
||||||
# which calls _ensure_dependencies_ready().
|
# 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.
|
# We'll rely on the mainController to call session.start() which handles login.
|
||||||
# if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
|
# if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
|
||||||
# try:
|
# 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
|
# # 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().")
|
# # log.info(f"Session {s.uid} ({s.kind}) not ready, login will be attempted by start().")
|
||||||
# pass
|
# pass
|
||||||
# except Exception as e:
|
# except Exception as e:
|
||||||
# log.exception(f"Exception during pre-emptive login check for session {s.uid} ({s.kind}).")
|
# log.exception(f"Exception during pre-emptive login check for session {s.uid} ({s.kind}).")
|
||||||
# continue
|
# 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:
|
try:
|
||||||
if i.get("type") == "atprotosocial":
|
if i.get("type") == "blueski":
|
||||||
s.login()
|
s.login()
|
||||||
except Exception:
|
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
|
sessions.sessions[i.get("id")] = s # Add to global session store
|
||||||
self.new_sessions[i.get("id")] = s # Track as a new session for this manager instance
|
self.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":
|
if type == "mastodon":
|
||||||
s = MastodonSession.Session(location)
|
s = MastodonSession.Session(location)
|
||||||
elif type == "atprotosocial":
|
elif type == "blueski":
|
||||||
s = ATProtoSocialSession.Session(location)
|
s = BlueskiSession.Session(location)
|
||||||
# Add other session types here if needed (e.g., gotosocial)
|
# Add other session types here if needed (e.g., gotosocial)
|
||||||
# elif type == "gotosocial":
|
# elif type == "gotosocial":
|
||||||
# s = GotosocialSession.Session(location)
|
# s = GotosocialSession.Session(location)
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ class sessionManagerWindow(wx.Dialog):
|
|||||||
mastodon = menu.Append(wx.ID_ANY, _("Mastodon"))
|
mastodon = menu.Append(wx.ID_ANY, _("Mastodon"))
|
||||||
menu.Bind(wx.EVT_MENU, self.on_new_mastodon_account, mastodon)
|
menu.Bind(wx.EVT_MENU, self.on_new_mastodon_account, mastodon)
|
||||||
|
|
||||||
atprotosocial = menu.Append(wx.ID_ANY, _("ATProtoSocial (Bluesky)"))
|
blueski = menu.Append(wx.ID_ANY, _("Blueski (Bluesky)"))
|
||||||
menu.Bind(wx.EVT_MENU, self.on_new_atprotosocial_account, atprotosocial)
|
menu.Bind(wx.EVT_MENU, self.on_new_blueski_account, blueski)
|
||||||
|
|
||||||
self.PopupMenu(menu, self.new.GetPosition())
|
self.PopupMenu(menu, self.new.GetPosition())
|
||||||
|
|
||||||
@@ -66,12 +66,12 @@ class sessionManagerWindow(wx.Dialog):
|
|||||||
if response == wx.ID_YES:
|
if response == wx.ID_YES:
|
||||||
pub.sendMessage("sessionmanager.new_account", type="mastodon")
|
pub.sendMessage("sessionmanager.new_account", type="mastodon")
|
||||||
|
|
||||||
def on_new_atprotosocial_account(self, *args, **kwargs):
|
def on_new_blueski_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)
|
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()
|
response = dlg.ShowModal()
|
||||||
dlg.Destroy()
|
dlg.Destroy()
|
||||||
if response == wx.ID_YES:
|
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):
|
def add_new_session_to_list(self):
|
||||||
total = self.list.get_count()
|
total = self.list.get_count()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from approve.translation import translate as _
|
|||||||
from approve.util import parse_iso_datetime # For parsing ISO timestamps
|
from approve.util import parse_iso_datetime # For parsing ISO timestamps
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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
|
from atproto.xrpc_client import models # For type hinting ATProto models
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -21,19 +21,19 @@ SUPPORTED_LANG_CHOICES_COMPOSE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ATProtoSocialCompose:
|
class BlueskiCompose:
|
||||||
MAX_CHARS = 300
|
MAX_CHARS = 300
|
||||||
MAX_MEDIA_ATTACHMENTS = 4
|
MAX_MEDIA_ATTACHMENTS = 4
|
||||||
MAX_LANGUAGES = 3
|
MAX_LANGUAGES = 3
|
||||||
MAX_IMAGE_SIZE_BYTES = 1_000_000
|
MAX_IMAGE_SIZE_BYTES = 1_000_000
|
||||||
|
|
||||||
def __init__(self, session: ATProtoSocialSession) -> None:
|
def __init__(self, session: BlueskiSession) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
self.supported_media_types: list[str] = ["image/jpeg", "image/png"]
|
self.supported_media_types: list[str] = ["image/jpeg", "image/png"]
|
||||||
self.max_image_size_bytes: int = self.MAX_IMAGE_SIZE_BYTES
|
self.max_image_size_bytes: int = self.MAX_IMAGE_SIZE_BYTES
|
||||||
|
|
||||||
def get_panel_configuration(self) -> dict[str, Any]:
|
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 {
|
return {
|
||||||
"max_chars": self.MAX_CHARS,
|
"max_chars": self.MAX_CHARS,
|
||||||
"max_media_attachments": self.MAX_MEDIA_ATTACHMENTS,
|
"max_media_attachments": self.MAX_MEDIA_ATTACHMENTS,
|
||||||
@@ -206,7 +206,7 @@ class ATProtoSocialCompose:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
notif_data: A dictionary representing the notification,
|
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
|
which create an approve.notifications.Notification object and then
|
||||||
convert it to dict or pass relevant parts.
|
convert it to dict or pass relevant parts.
|
||||||
Expected keys: 'title', 'body', 'author_name', 'timestamp_dt', 'kind'.
|
Expected keys: 'title', 'body', 'author_name', 'timestamp_dt', 'kind'.
|
||||||
@@ -10,7 +10,7 @@ from sessions import session_exceptions as Exceptions
|
|||||||
import output
|
import output
|
||||||
import application
|
import application
|
||||||
|
|
||||||
log = logging.getLogger("sessions.atprotosocialSession")
|
log = logging.getLogger("sessions.blueskiSession")
|
||||||
|
|
||||||
# Optional import of atproto. Code handles absence gracefully.
|
# Optional import of atproto. Code handles absence gracefully.
|
||||||
try:
|
try:
|
||||||
@@ -27,26 +27,45 @@ class Session(base.baseSession):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
name = "Bluesky"
|
name = "Bluesky"
|
||||||
KIND = "atprotosocial"
|
KIND = "blueski"
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super(Session, self).__init__(*args, **kwargs)
|
super(Session, self).__init__(*args, **kwargs)
|
||||||
self.config_spec = "atprotosocial.defaults"
|
self.config_spec = "blueski.defaults"
|
||||||
self.type = "atprotosocial"
|
self.type = "blueski"
|
||||||
self.char_limit = 300
|
self.char_limit = 300
|
||||||
self.api = None
|
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):
|
def get_name(self):
|
||||||
"""Return a human-friendly, stable account name for UI.
|
"""Return a human-friendly, stable account name for UI.
|
||||||
|
|
||||||
Prefer the user's handle if available so accounts are uniquely
|
Prefer the user's handle if available so accounts are uniquely
|
||||||
identifiable, falling back to a generic network name otherwise.
|
identifiable, falling back to a generic network name otherwise.
|
||||||
"""
|
"""
|
||||||
|
self._ensure_settings_namespace()
|
||||||
try:
|
try:
|
||||||
# Prefer runtime DB, then persisted settings, then SDK client
|
# Prefer runtime DB, then persisted settings, then SDK client
|
||||||
handle = (
|
handle = (
|
||||||
self.db.get("user_name")
|
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)
|
or (getattr(getattr(self, "api", None), "me", None) and self.api.me.handle)
|
||||||
)
|
)
|
||||||
if handle:
|
if handle:
|
||||||
@@ -65,11 +84,12 @@ class Session(base.baseSession):
|
|||||||
return self.api
|
return self.api
|
||||||
|
|
||||||
def login(self, verify_credentials=True):
|
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
|
raise Exceptions.RequireCredentialsSessionError
|
||||||
handle = self.settings["atprotosocial"].get("handle")
|
handle = self.settings["blueski"].get("handle")
|
||||||
app_password = self.settings["atprotosocial"].get("app_password")
|
app_password = self.settings["blueski"].get("app_password")
|
||||||
session_string = self.settings["atprotosocial"].get("session_string")
|
session_string = self.settings["blueski"].get("session_string")
|
||||||
if not handle or (not app_password and not session_string):
|
if not handle or (not app_password and not session_string):
|
||||||
self.logged = False
|
self.logged = False
|
||||||
raise Exceptions.RequireCredentialsSessionError
|
raise Exceptions.RequireCredentialsSessionError
|
||||||
@@ -100,10 +120,10 @@ class Session(base.baseSession):
|
|||||||
self.db["user_name"] = api.me.handle
|
self.db["user_name"] = api.me.handle
|
||||||
self.db["user_id"] = api.me.did
|
self.db["user_id"] = api.me.did
|
||||||
# Persist DID in settings for session manager display
|
# 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
|
# Export session for future reuse
|
||||||
try:
|
try:
|
||||||
self.settings["atprotosocial"]["session_string"] = api.export_session_string()
|
self.settings["blueski"]["session_string"] = api.export_session_string()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.settings.write()
|
self.settings.write()
|
||||||
@@ -114,6 +134,7 @@ class Session(base.baseSession):
|
|||||||
self.logged = False
|
self.logged = False
|
||||||
|
|
||||||
def authorise(self):
|
def authorise(self):
|
||||||
|
self._ensure_settings_namespace()
|
||||||
if self.logged:
|
if self.logged:
|
||||||
raise Exceptions.AlreadyAuthorisedError("Already authorised.")
|
raise Exceptions.AlreadyAuthorisedError("Already authorised.")
|
||||||
# Ask for handle
|
# Ask for handle
|
||||||
@@ -141,8 +162,8 @@ class Session(base.baseSession):
|
|||||||
# Create session folder and config, then attempt login
|
# Create session folder and config, then attempt login
|
||||||
self.create_session_folder()
|
self.create_session_folder()
|
||||||
self.get_configuration()
|
self.get_configuration()
|
||||||
self.settings["atprotosocial"]["handle"] = handle
|
self.settings["blueski"]["handle"] = handle
|
||||||
self.settings["atprotosocial"]["app_password"] = app_password
|
self.settings["blueski"]["app_password"] = app_password
|
||||||
self.settings.write()
|
self.settings.write()
|
||||||
try:
|
try:
|
||||||
self.login()
|
self.login()
|
||||||
@@ -159,7 +180,8 @@ class Session(base.baseSession):
|
|||||||
|
|
||||||
def get_message_url(self, message_id, context=None):
|
def get_message_url(self, message_id, context=None):
|
||||||
# message_id may be full at:// URI or rkey
|
# 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
|
rkey = message_id
|
||||||
if isinstance(message_id, str) and message_id.startswith("at://"):
|
if isinstance(message_id, str) and message_id.startswith("at://"):
|
||||||
parts = message_id.split("/")
|
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):
|
def send_message(self, message, files=None, reply_to=None, cw_text=None, is_sensitive=False, **kwargs):
|
||||||
if not self.logged:
|
if not self.logged:
|
||||||
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
|
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
|
||||||
|
self._ensure_settings_namespace()
|
||||||
try:
|
try:
|
||||||
api = self._ensure_client()
|
api = self._ensure_client()
|
||||||
# Basic text-only post for now. Attachments and CW can be extended later.
|
# 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
|
# Accept full web URL and try to resolve via get_post_thread below
|
||||||
return identifier
|
return identifier
|
||||||
# Accept bare rkey case by constructing a guess using own handle
|
# Accept bare rkey case by constructing a guess using own handle
|
||||||
handle = self.db.get("user_name") or self.settings["atprotosocial"].get("handle")
|
handle = self.db.get("user_name") or self.settings["blueski"].get("handle")
|
||||||
did = self.db.get("user_id") or self.settings["atprotosocial"].get("did")
|
did = self.db.get("user_id") or self.settings["blueski"].get("did")
|
||||||
if handle and did and len(identifier) in (13, 14, 15):
|
if handle and did and len(identifier) in (13, 14, 15):
|
||||||
# rkey length is typically ~13 chars base32
|
# rkey length is typically ~13 chars base32
|
||||||
return f"at://{did}/app.bsky.feed.post/{identifier}"
|
return f"at://{did}/app.bsky.feed.post/{identifier}"
|
||||||
@@ -5,17 +5,17 @@ import logging
|
|||||||
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
from typing import TYPE_CHECKING, Any, Callable, Coroutine
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
|
fromapprove.sessions.blueski.session import Session as BlueskiSession
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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.
|
# This typically involves connecting to a WebSocket endpoint and receiving events.
|
||||||
# The atproto SDK provides tools for this.
|
# The atproto SDK provides tools for this.
|
||||||
|
|
||||||
|
|
||||||
class ATProtoSocialStreaming:
|
class BlueskiStreaming:
|
||||||
def __init__(self, session: ATProtoSocialSession, stream_type: str, params: dict[str, Any] | None = None) -> None:
|
def __init__(self, session: BlueskiSession, stream_type: str, params: dict[str, Any] | None = None) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
self.stream_type = stream_type # e.g., 'user', 'public', 'hashtag' - will need mapping to Firehose concepts
|
self.stream_type = stream_type # e.g., 'user', 'public', 'hashtag' - will need mapping to Firehose concepts
|
||||||
self.params = params or {}
|
self.params = params or {}
|
||||||
@@ -30,19 +30,19 @@ class ATProtoSocialStreaming:
|
|||||||
# or using a more specific subscription if available for user-level events.
|
# or using a more specific subscription if available for user-level events.
|
||||||
|
|
||||||
async def _connect(self) -> None:
|
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 import AsyncClient
|
||||||
# from atproto.firehose import FirehoseSubscribeReposClient, parse_subscribe_repos_message
|
# from atproto.firehose import FirehoseSubscribeReposClient, parse_subscribe_repos_message
|
||||||
# from atproto.xrpc_client.models import get_or_create, ids, models
|
# 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
|
self._should_stop = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# TODO: Replace with actual atproto SDK usage
|
# TODO: Replace with actual atproto SDK usage
|
||||||
# client = self.session.util.get_client() # Get authenticated client from session utils
|
# client = self.session.util.get_client() # Get authenticated client from session utils
|
||||||
# if not client or not client.me: # Check if client is authenticated
|
# 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
|
# return
|
||||||
|
|
||||||
# self._firehose_client = FirehoseSubscribeReposClient(params=None, base_uri=self.session.api_base_url) # Adjust base_uri if needed
|
# 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)
|
# # # await self._handle_event("mention", event_data)
|
||||||
|
|
||||||
# # For now, we'll just log that a message was received
|
# # 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)
|
# 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"}}
|
# mock_event = {"type": "placeholder_event", "data": {"text": "Hello from mock stream"}}
|
||||||
# await self._handler(mock_event) # Call the registered handler
|
# 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:
|
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:
|
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
|
# Optional: implement retry logic here or in the start_streaming method
|
||||||
if not self._should_stop:
|
if not self._should_stop:
|
||||||
await asyncio.sleep(30) # Wait before trying to reconnect (if auto-reconnect is desired)
|
await asyncio.sleep(30) # Wait before trying to reconnect (if auto-reconnect is desired)
|
||||||
@@ -108,7 +108,7 @@ class ATProtoSocialStreaming:
|
|||||||
finally:
|
finally:
|
||||||
# if self._firehose_client:
|
# if self._firehose_client:
|
||||||
# await self._firehose_client.stop()
|
# 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:
|
async def _handle_event(self, event_type: str, data: dict[str, Any]) -> None:
|
||||||
@@ -118,31 +118,31 @@ class ATProtoSocialStreaming:
|
|||||||
if self._handler:
|
if self._handler:
|
||||||
try:
|
try:
|
||||||
# The data should be transformed into a common format expected by session.handle_streaming_event
|
# 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.
|
# This is where Blueski-specific event data is mapped to Approve's internal event structure.
|
||||||
# For example, an ATProtoSocial 'mention' event needs to be structured similarly to
|
# For example, an Blueski 'mention' event needs to be structured similarly to
|
||||||
# how a Mastodon 'mention' event would be.
|
# how a Mastodon 'mention' event would be.
|
||||||
await self.session.handle_streaming_event(event_type, data)
|
await self.session.handle_streaming_event(event_type, data)
|
||||||
except Exception as e:
|
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:
|
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:
|
def start_streaming(self, handler: Callable[[dict[str, Any]], Coroutine[Any, Any, None]]) -> None:
|
||||||
"""Starts the streaming connection."""
|
"""Starts the streaming connection."""
|
||||||
if self._connection_task and not self._connection_task.done():
|
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
|
return
|
||||||
|
|
||||||
self._handler = handler # This handler is what session.py's handle_streaming_event calls
|
self._handler = handler # This handler is what session.py's handle_streaming_event calls
|
||||||
self._should_stop = False
|
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())
|
self._connection_task = asyncio.create_task(self._connect())
|
||||||
|
|
||||||
|
|
||||||
async def stop_streaming(self) -> None:
|
async def stop_streaming(self) -> None:
|
||||||
"""Stops the streaming connection."""
|
"""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
|
self._should_stop = True
|
||||||
# if self._firehose_client: # Assuming the SDK has a stop method
|
# if self._firehose_client: # Assuming the SDK has a stop method
|
||||||
# await self._firehose_client.stop()
|
# await self._firehose_client.stop()
|
||||||
@@ -153,10 +153,10 @@ class ATProtoSocialStreaming:
|
|||||||
try:
|
try:
|
||||||
await self._connection_task
|
await self._connection_task
|
||||||
except asyncio.CancelledError:
|
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._connection_task = None
|
||||||
self._handler = 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:
|
def is_alive(self) -> bool:
|
||||||
"""Checks if the streaming connection is currently active."""
|
"""Checks if the streaming connection is currently active."""
|
||||||
@@ -169,7 +169,7 @@ class ATProtoSocialStreaming:
|
|||||||
def get_params(self) -> dict[str, Any]:
|
def get_params(self) -> dict[str, Any]:
|
||||||
return self.params
|
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.
|
# 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.
|
# 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),
|
# However, if there were different Firehose endpoints (e.g., one for public posts, one for user-specific events),
|
||||||
@@ -6,30 +6,30 @@ from typing import TYPE_CHECKING, Any
|
|||||||
fromapprove.translation import translate as _
|
fromapprove.translation import translate as _
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
fromapprove.sessions.atprotosocial.session import Session as ATProtoSocialSession
|
fromapprove.sessions.blueski.session import Session as BlueskiSession
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ATProtoSocialTemplates:
|
class BlueskiTemplates:
|
||||||
def __init__(self, session: ATProtoSocialSession) -> None:
|
def __init__(self, session: BlueskiSession) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
|
|
||||||
def get_template_data(self, template_name: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
|
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.
|
This method would populate template variables based on the template name and context.
|
||||||
"""
|
"""
|
||||||
base_data = {
|
base_data = {
|
||||||
"session_kind": self.session.kind,
|
"session_kind": self.session.kind,
|
||||||
"session_label": self.session.label,
|
"session_label": self.session.label,
|
||||||
"user_id": self.session.user_id,
|
"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:
|
if context:
|
||||||
base_data.update(context)
|
base_data.update(context)
|
||||||
|
|
||||||
# TODO: Implement specific data fetching for different ATProtoSocial templates
|
# TODO: Implement specific data fetching for different Blueski templates
|
||||||
# Example:
|
# Example:
|
||||||
# if template_name == "profile_summary.html":
|
# if template_name == "profile_summary.html":
|
||||||
# # profile_info = await self.session.util.get_my_profile_info() # Assuming such a method exists
|
# # profile_info = await self.session.util.get_my_profile_info() # Assuming such a method exists
|
||||||
@@ -44,27 +44,27 @@ class ATProtoSocialTemplates:
|
|||||||
return base_data
|
return base_data
|
||||||
|
|
||||||
def get_message_card_template(self) -> str:
|
def get_message_card_template(self) -> str:
|
||||||
"""Returns the path to the message card template for ATProtoSocial."""
|
"""Returns the path to the message card template for Blueski."""
|
||||||
# This template would define how a single ATProtoSocial post (or other message type)
|
# 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).
|
# 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
|
return "sessions/generic/cards/message_generic.html" # Placeholder, use generic if no specific yet
|
||||||
|
|
||||||
def get_notification_template_map(self) -> dict[str, str]:
|
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).
|
# (e.g., mention, reply, new follower, like, repost).
|
||||||
# The keys should match the notification types used internally by Approve
|
# The keys should match the notification types used internally by Approve
|
||||||
# when processing ATProtoSocial events.
|
# when processing Blueski events.
|
||||||
# Example:
|
# Example:
|
||||||
# return {
|
# return {
|
||||||
# "mention": "sessions/atprotosocial/notifications/mention.html",
|
# "mention": "sessions/blueski/notifications/mention.html",
|
||||||
# "reply": "sessions/atprotosocial/notifications/reply.html",
|
# "reply": "sessions/blueski/notifications/reply.html",
|
||||||
# "follow": "sessions/atprotosocial/notifications/follow.html",
|
# "follow": "sessions/blueski/notifications/follow.html",
|
||||||
# "like": "sessions/atprotosocial/notifications/like.html", # Bluesky uses 'like'
|
# "like": "sessions/blueski/notifications/like.html", # Bluesky uses 'like'
|
||||||
# "repost": "sessions/atprotosocial/notifications/repost.html", # Bluesky uses 'repost'
|
# "repost": "sessions/blueski/notifications/repost.html", # Bluesky uses 'repost'
|
||||||
# # ... other notification types
|
# # ... other notification types
|
||||||
# }
|
# }
|
||||||
# Using generic templates as placeholders:
|
# Using generic templates as placeholders:
|
||||||
@@ -77,37 +77,37 @@ class ATProtoSocialTemplates:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_settings_template(self) -> str | None:
|
def get_settings_template(self) -> str | None:
|
||||||
"""Returns the path to the settings template for ATProtoSocial, if any."""
|
"""Returns the path to the settings template for Blueski, if any."""
|
||||||
# This template would be used to render ATProtoSocial-specific settings in the UI.
|
# This template would be used to render Blueski-specific settings in the UI.
|
||||||
# return "sessions/atprotosocial/settings.html"
|
# return "sessions/blueski/settings.html"
|
||||||
return "sessions/generic/settings_auth_password.html" # If using simple handle/password auth
|
return "sessions/generic/settings_auth_password.html" # If using simple handle/password auth
|
||||||
|
|
||||||
def get_user_action_templates(self) -> dict[str, str] | None:
|
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.
|
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:
|
# Example:
|
||||||
# return {
|
# return {
|
||||||
# "view_profile_on_bsky": "sessions/atprotosocial/actions/view_profile_button.html",
|
# "view_profile_on_bsky": "sessions/blueski/actions/view_profile_button.html",
|
||||||
# "send_direct_message": "sessions/atprotosocial/actions/send_dm_form.html", # If DMs are supported
|
# "send_direct_message": "sessions/blueski/actions/send_dm_form.html", # If DMs are supported
|
||||||
# }
|
# }
|
||||||
return None # Placeholder
|
return None # Placeholder
|
||||||
|
|
||||||
def get_user_list_action_templates(self) -> dict[str, str] | None:
|
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).
|
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:
|
# Example:
|
||||||
# return {
|
# 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
|
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)
|
# For example, methods to get templates for specific types of content (images, polls)
|
||||||
# if they need special rendering.
|
# 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).
|
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.
|
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.
|
# that require different display logic.
|
||||||
# if message_type == "quote_post":
|
# 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
|
return None # Default to standard message card if not specified
|
||||||
@@ -16,7 +16,7 @@ fromapprove.notifications import NotificationError
|
|||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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
|
# Define common type aliases if needed
|
||||||
ATUserProfile = models.AppBskyActorDefs.ProfileViewDetailed
|
ATUserProfile = models.AppBskyActorDefs.ProfileViewDetailed
|
||||||
ATPost = models.AppBskyFeedDefs.PostView
|
ATPost = models.AppBskyFeedDefs.PostView
|
||||||
@@ -27,8 +27,8 @@ if TYPE_CHECKING:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ATProtoSocialUtils:
|
class BlueskiUtils:
|
||||||
def __init__(self, session: ATProtoSocialSession) -> None:
|
def __init__(self, session: BlueskiSession) -> None:
|
||||||
self.session = session
|
self.session = session
|
||||||
# _own_did and _own_handle are now set by Session.login upon successful authentication
|
# _own_did and _own_handle are now set by Session.login upon successful authentication
|
||||||
# and directly on the util instance.
|
# and directly on the util instance.
|
||||||
@@ -47,7 +47,7 @@ class ATProtoSocialUtils:
|
|||||||
self._own_handle = self.session.client.me.handle
|
self._own_handle = self.session.client.me.handle
|
||||||
return self.session.client
|
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,
|
# Optionally, try to trigger re-authentication if appropriate,
|
||||||
# but generally, the caller should ensure session is ready.
|
# but generally, the caller should ensure session is ready.
|
||||||
# For example, by calling session.start() or session.authorise()
|
# For example, by calling session.start() or session.authorise()
|
||||||
@@ -59,7 +59,7 @@ class ATProtoSocialUtils:
|
|||||||
"""Retrieves the authenticated user's profile information."""
|
"""Retrieves the authenticated user's profile information."""
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
if not client or not self.get_own_did(): # Use getter for _own_did
|
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
|
return None
|
||||||
try:
|
try:
|
||||||
# client.me should be populated after login by the SDK
|
# client.me should be populated after login by the SDK
|
||||||
@@ -78,7 +78,7 @@ class ATProtoSocialUtils:
|
|||||||
return response
|
return response
|
||||||
return None
|
return None
|
||||||
except AtProtocolError as e:
|
except AtProtocolError as e:
|
||||||
logger.error(f"Error fetching own ATProtoSocial profile: {e}")
|
logger.error(f"Error fetching own Blueski profile: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_own_did(self) -> str | None:
|
def get_own_did(self) -> str | None:
|
||||||
@@ -116,13 +116,13 @@ class ATProtoSocialUtils:
|
|||||||
**kwargs: Any
|
**kwargs: Any
|
||||||
) -> str | None: # Returns the ATURI of the new post, or None on failure
|
) -> 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).
|
Handles text, images, replies, quotes, and content warnings (labels).
|
||||||
"""
|
"""
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
if not client:
|
if not client:
|
||||||
logger.error("ATProtoSocial client not available for posting.")
|
logger.error("Blueski client not available for posting.")
|
||||||
raise NotificationError(_("Not connected to ATProtoSocial. Please check your connection settings or log in."))
|
raise NotificationError(_("Not connected to Blueski. Please check your connection settings or log in."))
|
||||||
|
|
||||||
if not self.get_own_did():
|
if not self.get_own_did():
|
||||||
logger.error("Cannot post status: User DID not available.")
|
logger.error("Cannot post status: User DID not available.")
|
||||||
@@ -130,7 +130,7 @@ class ATProtoSocialUtils:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Prepare core post record
|
# 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:
|
if langs:
|
||||||
post_record_data['langs'] = langs
|
post_record_data['langs'] = langs
|
||||||
@@ -227,13 +227,13 @@ class ATProtoSocialUtils:
|
|||||||
record=final_post_record,
|
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
|
return response.uri
|
||||||
except AtProtocolError as e:
|
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
|
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
|
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
|
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."""
|
"""Deletes a status (post) given its AT URI."""
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
if not client:
|
if not client:
|
||||||
logger.error("ATProtoSocial client not available for deleting post.")
|
logger.error("Blueski client not available for deleting post.")
|
||||||
return False
|
return False
|
||||||
if not self.get_own_did():
|
if not self.get_own_did():
|
||||||
logger.error("Cannot delete status: User DID not available.")
|
logger.error("Cannot delete status: User DID not available.")
|
||||||
@@ -268,10 +268,10 @@ class ATProtoSocialUtils:
|
|||||||
rkey=rkey,
|
rkey=rkey,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.info(f"Successfully deleted post {post_uri} from ATProtoSocial.")
|
logger.info(f"Successfully deleted post {post_uri} from Blueski.")
|
||||||
return True
|
return True
|
||||||
except AtProtocolError as e:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error deleting post {post_uri}: {e}", exc_info=True)
|
logger.error(f"Unexpected error deleting post {post_uri}: {e}", exc_info=True)
|
||||||
return False
|
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:
|
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.
|
Returns a dictionary containing the SDK's BlobRef object and alt_text, or None on failure.
|
||||||
"""
|
"""
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
if not client:
|
if not client:
|
||||||
logger.error("ATProtoSocial client not available for media upload.")
|
logger.error("Blueski client not available for media upload.")
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with open(file_path, "rb") as f:
|
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.")
|
logger.error(f"Media upload failed for {file_path}, no blob in response.")
|
||||||
return None
|
return None
|
||||||
except AtProtocolError as e:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error uploading media {file_path}: {e}", exc_info=True)
|
logger.error(f"Unexpected error uploading media {file_path}: {e}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
@@ -341,7 +341,7 @@ class ATProtoSocialUtils:
|
|||||||
models.ComAtprotoRepoCreateRecord.Input(
|
models.ComAtprotoRepoCreateRecord.Input(
|
||||||
repo=self.get_own_did(),
|
repo=self.get_own_did(),
|
||||||
collection=ids.AppBskyGraphFollow, # "app.bsky.graph.follow"
|
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}.")
|
logger.info(f"Successfully followed user {user_did}.")
|
||||||
@@ -596,7 +596,7 @@ class ATProtoSocialUtils:
|
|||||||
models.ComAtprotoRepoCreateRecord.Input(
|
models.ComAtprotoRepoCreateRecord.Input(
|
||||||
repo=self.get_own_did(),
|
repo=self.get_own_did(),
|
||||||
collection=ids.AppBskyGraphBlock, # "app.bsky.graph.block"
|
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}")
|
logger.info(f"Successfully blocked user {user_did}. Block record URI: {response.uri}")
|
||||||
@@ -1098,7 +1098,7 @@ class ATProtoSocialUtils:
|
|||||||
"""
|
"""
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
if not client:
|
if not client:
|
||||||
logger.error("ATProtoSocial client not available for reporting.")
|
logger.error("Blueski client not available for reporting.")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -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))
|
text = _("Boosted from @{}: {}").format(post.reblog.account.acct, templates.process_text(post.reblog, safe=safe))
|
||||||
else:
|
else:
|
||||||
text = templates.process_text(post, safe=safe)
|
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")
|
filtered = utils.evaluate_filters(post=post, current_context="home")
|
||||||
if filtered != None:
|
if filtered != None:
|
||||||
text = _("hidden by filter {}").format(filtered)
|
text = _("hidden by filter {}").format(filtered)
|
||||||
|
|||||||
@@ -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)
|
pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, lang=language)
|
||||||
return
|
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):
|
def get_name(self):
|
||||||
instance = self.settings["mastodon"]["instance"]
|
instance = self.settings["mastodon"]["instance"]
|
||||||
instance = instance.replace("https://", "")
|
instance = instance.replace("https://", "")
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ def render_post(post, template, settings, relative_times=False, offset_hours=0):
|
|||||||
else:
|
else:
|
||||||
text = process_text(post, safe=False)
|
text = process_text(post, safe=False)
|
||||||
safe_text = process_text(post)
|
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")
|
filtered = utils.evaluate_filters(post=post, current_context="home")
|
||||||
if filtered != None:
|
if filtered != None:
|
||||||
text = _("hidden by filter {}").format(filtered)
|
text = _("hidden by filter {}").format(filtered)
|
||||||
|
|||||||
@@ -3,23 +3,47 @@ import demoji
|
|||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
url_re = re.compile('<a\s*href=[\'|"](.*?)[\'"].*?>')
|
url_re = re.compile(r'<a\s*href=[\'|"](.*?)[\'"].*?>')
|
||||||
|
|
||||||
class HTMLFilter(HTMLParser):
|
class HTMLFilter(HTMLParser):
|
||||||
|
# Classes to ignore when parsing HTML
|
||||||
|
IGNORED_CLASSES = ["quote-inline"]
|
||||||
|
|
||||||
text = ""
|
text = ""
|
||||||
first_paragraph = True
|
first_paragraph = True
|
||||||
|
skip_depth = 0 # Track nesting depth of ignored elements
|
||||||
|
|
||||||
def handle_data(self, data):
|
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):
|
def handle_starttag(self, tag, attrs):
|
||||||
if tag == "br":
|
# Check if this tag has a class that should be ignored
|
||||||
self.text = self.text+"\n"
|
attrs_dict = dict(attrs)
|
||||||
elif tag == "p":
|
tag_class = attrs_dict.get("class", "")
|
||||||
if self.first_paragraph:
|
|
||||||
self.first_paragraph = False
|
# Check if any ignored class is present in this tag
|
||||||
else:
|
should_skip = any(ignored_class in tag_class for ignored_class in self.IGNORED_CLASSES)
|
||||||
self.text = self.text+"\n\n"
|
|
||||||
|
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):
|
def html_filter(data):
|
||||||
f = HTMLFilter()
|
f = HTMLFilter()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import unittest
|
|||||||
from unittest.mock import patch, AsyncMock, MagicMock, PropertyMock
|
from unittest.mock import patch, AsyncMock, MagicMock, PropertyMock
|
||||||
|
|
||||||
# Assuming paths are set up correctly for test environment to find these
|
# 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 sessions.session_exceptions import SessionLoginError, SessionError
|
||||||
from approve.notifications import NotificationError # Assuming this is the correct import path
|
from approve.notifications import NotificationError # Assuming this is the correct import path
|
||||||
from atproto.xrpc_client.models.common import XrpcError
|
from atproto.xrpc_client.models.common import XrpcError
|
||||||
@@ -48,7 +48,7 @@ mock_wx.ICON_QUESTION = 32 # Example
|
|||||||
|
|
||||||
# Mock config objects
|
# Mock config objects
|
||||||
# This structure tries to mimic how config is accessed in session.py
|
# 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:
|
class MockConfigNode:
|
||||||
def __init__(self, initial_value=None):
|
def __init__(self, initial_value=None):
|
||||||
self._value = initial_value
|
self._value = initial_value
|
||||||
@@ -60,9 +60,9 @@ class MockUserSessionConfig:
|
|||||||
self.handle = MockConfigNode("")
|
self.handle = MockConfigNode("")
|
||||||
self.app_password = MockConfigNode("")
|
self.app_password = MockConfigNode("")
|
||||||
self.did = 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):
|
def __init__(self):
|
||||||
self._user_configs = {"test_user": MockUserSessionConfig()}
|
self._user_configs = {"test_user": MockUserSessionConfig()}
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
@@ -70,31 +70,31 @@ class MockATProtoSocialConfig:
|
|||||||
|
|
||||||
class MockSessionsConfig:
|
class MockSessionsConfig:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.atprotosocial = MockATProtoSocialConfig()
|
self.blueski = MockBlueskiConfig()
|
||||||
|
|
||||||
mock_config_global = MagicMock()
|
mock_config_global = MagicMock()
|
||||||
mock_config_global.sessions = MockSessionsConfig()
|
mock_config_global.sessions = MockSessionsConfig()
|
||||||
|
|
||||||
|
|
||||||
class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
|
class TestBlueskiSession(unittest.IsolatedAsyncioTestCase):
|
||||||
|
|
||||||
@patch('sessions.atprotosocial.session.wx', mock_wx)
|
@patch('sessions.blueski.session.wx', mock_wx)
|
||||||
@patch('sessions.atprotosocial.session.config', mock_config_global)
|
@patch('sessions.blueski.session.config', mock_config_global)
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.mock_approval_api = MagicMock()
|
self.mock_approval_api = MagicMock()
|
||||||
|
|
||||||
# Reset mocks for user_config part of global mock_config_global for each test
|
# Reset mocks for user_config part of global mock_config_global for each test
|
||||||
self.mock_user_config_instance = MockUserSessionConfig()
|
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.db = {}
|
||||||
self.session.save_db = AsyncMock()
|
self.session.save_db = AsyncMock()
|
||||||
self.session.notify_session_ready = AsyncMock()
|
self.session.notify_session_ready = AsyncMock()
|
||||||
self.session.send_text_notification = MagicMock()
|
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 = 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_did = None # These are set directly by session.login
|
||||||
self.mock_util_instance._own_handle = None
|
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
|
self.session.util # Call property to ensure _util is set if it's lazy loaded
|
||||||
|
|
||||||
def test_session_initialization(self):
|
def test_session_initialization(self):
|
||||||
self.assertIsInstance(self.session, ATProtoSocialSession)
|
self.assertIsInstance(self.session, BlueskiSession)
|
||||||
self.assertEqual(self.session.KIND, "atprotosocial")
|
self.assertEqual(self.session.KIND, "blueski")
|
||||||
self.assertIsNone(self.session.client)
|
self.assertIsNone(self.session.client)
|
||||||
self.assertEqual(self.session.user_id, "test_user")
|
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):
|
async def test_login_successful(self, MockAsyncClient):
|
||||||
mock_client_instance = MockAsyncClient.return_value
|
mock_client_instance = MockAsyncClient.return_value
|
||||||
# Use actual ATProto models for spec if possible for better type checking in mocks
|
# 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()
|
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):
|
async def test_login_failure_xrpc(self, MockAsyncClient):
|
||||||
mock_client_instance = MockAsyncClient.return_value
|
mock_client_instance = MockAsyncClient.return_value
|
||||||
mock_client_instance.login = AsyncMock(side_effect=XrpcError(error="AuthenticationFailed", message="Invalid credentials"))
|
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.assertIsNone(self.session.client)
|
||||||
self.session.notify_session_ready.assert_not_called()
|
self.session.notify_session_ready.assert_not_called()
|
||||||
|
|
||||||
@patch('sessions.atprotosocial.session.wx', new=mock_wx)
|
@patch('sessions.blueski.session.wx', new=mock_wx)
|
||||||
@patch.object(ATProtoSocialSession, 'login', new_callable=AsyncMock)
|
@patch.object(BlueskiSession, 'login', new_callable=AsyncMock)
|
||||||
async def test_authorise_successful(self, mock_login_method):
|
async def test_authorise_successful(self, mock_login_method):
|
||||||
mock_login_method.return_value = True
|
mock_login_method.return_value = True
|
||||||
|
|
||||||
@@ -174,8 +174,8 @@ class TestATProtoSocialSession(unittest.IsolatedAsyncioTestCase):
|
|||||||
# Further check if wx.MessageBox was called with success
|
# Further check if wx.MessageBox was called with success
|
||||||
# This requires more complex mocking or inspection of calls to mock_wx.MessageBox
|
# This requires more complex mocking or inspection of calls to mock_wx.MessageBox
|
||||||
|
|
||||||
@patch('sessions.atprotosocial.session.wx', new=mock_wx)
|
@patch('sessions.blueski.session.wx', new=mock_wx)
|
||||||
@patch.object(ATProtoSocialSession, 'login', new_callable=AsyncMock)
|
@patch.object(BlueskiSession, 'login', new_callable=AsyncMock)
|
||||||
async def test_authorise_login_fails_with_notification_error(self, mock_login_method):
|
async def test_authorise_login_fails_with_notification_error(self, mock_login_method):
|
||||||
mock_login_method.side_effect = NotificationError("Specific login failure from mock.")
|
mock_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
|
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):
|
async def test_send_post_with_media(self, mock_basename):
|
||||||
self.session.is_ready = MagicMock(return_value=True)
|
self.session.is_ready = MagicMock(return_value=True)
|
||||||
mock_blob_info = {"blob_ref": MagicMock(spec=atp_models.ComAtprotoRepoStrongRef.Blob), "alt_text": "A test image"}
|
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.MessageBox = MockWxMessageBox
|
||||||
mock_wx_module.CallAfter = MagicMock()
|
mock_wx_module.CallAfter = MagicMock()
|
||||||
mock_wx_module.GetApp = MagicMock()
|
mock_wx_module.GetApp = MagicMock()
|
||||||
>>>>>>> REPLACE
|
|
||||||
@@ -11,10 +11,10 @@ from datetime import datetime
|
|||||||
|
|
||||||
from multiplatform_widgets import widgets
|
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.
|
"""Minimal Home timeline buffer for Bluesky.
|
||||||
|
|
||||||
Exposes a .buffer wx.Panel with a List control and provides
|
Exposes a .buffer wx.Panel with a List control and provides
|
||||||
@@ -27,6 +27,7 @@ class ATProtoSocialHomeTimelinePanel(object):
|
|||||||
self.account = session.get_name()
|
self.account = session.get_name()
|
||||||
self.name = name
|
self.name = name
|
||||||
self.type = "home_timeline"
|
self.type = "home_timeline"
|
||||||
|
self.timeline_algorithm = None
|
||||||
self.invisible = True
|
self.invisible = True
|
||||||
self.needs_init = True
|
self.needs_init = True
|
||||||
self.buffer = _HomePanel(parent, name)
|
self.buffer = _HomePanel(parent, name)
|
||||||
@@ -49,17 +50,16 @@ class ATProtoSocialHomeTimelinePanel(object):
|
|||||||
# The atproto SDK expects params, not raw kwargs
|
# The atproto SDK expects params, not raw kwargs
|
||||||
try:
|
try:
|
||||||
from atproto import models as at_models # type: ignore
|
from atproto import models as at_models # type: ignore
|
||||||
# Home: algorithmic/default timeline
|
params = at_models.AppBskyFeedGetTimeline.Params(
|
||||||
try:
|
limit=count,
|
||||||
params = at_models.AppBskyFeedGetTimeline.Params(limit=count)
|
algorithm=self.timeline_algorithm
|
||||||
res = api.app.bsky.feed.get_timeline(params)
|
)
|
||||||
except Exception:
|
res = api.app.bsky.feed.get_timeline(params)
|
||||||
# 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)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Fallback to plain dict params if typed models unavailable
|
payload = {"limit": count}
|
||||||
res = api.app.bsky.feed.get_timeline({"limit": count})
|
if self.timeline_algorithm:
|
||||||
|
payload["algorithm"] = self.timeline_algorithm
|
||||||
|
res = api.app.bsky.feed.get_timeline(payload)
|
||||||
feed = getattr(res, "feed", [])
|
feed = getattr(res, "feed", [])
|
||||||
self.cursor = getattr(res, "cursor", None)
|
self.cursor = getattr(res, "cursor", None)
|
||||||
self.items = []
|
self.items = []
|
||||||
@@ -103,10 +103,17 @@ class ATProtoSocialHomeTimelinePanel(object):
|
|||||||
api = self.session._ensure_client()
|
api = self.session._ensure_client()
|
||||||
try:
|
try:
|
||||||
from atproto import models as at_models # type: ignore
|
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)
|
res = api.app.bsky.feed.get_timeline(params)
|
||||||
except Exception:
|
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", [])
|
feed = getattr(res, "feed", [])
|
||||||
self.cursor = getattr(res, "cursor", None)
|
self.cursor = getattr(res, "cursor", None)
|
||||||
new_items = []
|
new_items = []
|
||||||
@@ -144,7 +151,7 @@ class ATProtoSocialHomeTimelinePanel(object):
|
|||||||
log.exception("Failed to load more Bluesky timeline items")
|
log.exception("Failed to load more Bluesky timeline items")
|
||||||
return 0
|
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):
|
def load_more_posts(self, *args, **kwargs):
|
||||||
return self.get_more_items()
|
return self.get_more_items()
|
||||||
|
|
||||||
@@ -281,12 +288,13 @@ class _HomePanel(wx.Panel):
|
|||||||
self.SetSizer(sizer)
|
self.SetSizer(sizer)
|
||||||
|
|
||||||
|
|
||||||
class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel):
|
class BlueskiFollowingTimelinePanel(BlueskiHomeTimelinePanel):
|
||||||
"""Following-only timeline (reverse-chronological)."""
|
"""Following-only timeline (reverse-chronological)."""
|
||||||
|
|
||||||
def __init__(self, parent, name: str, session):
|
def __init__(self, parent, name: str, session):
|
||||||
super().__init__(parent, name, session)
|
super().__init__(parent, name, session)
|
||||||
self.type = "following_timeline"
|
self.type = "following_timeline"
|
||||||
|
self.timeline_algorithm = "reverse-chronological"
|
||||||
# Make sure the underlying wx panel also reflects this type
|
# Make sure the underlying wx panel also reflects this type
|
||||||
try:
|
try:
|
||||||
self.buffer.type = "following_timeline"
|
self.buffer.type = "following_timeline"
|
||||||
@@ -302,7 +310,7 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel):
|
|||||||
api = self.session._ensure_client()
|
api = self.session._ensure_client()
|
||||||
# Following timeline via reverse-chronological algorithm on get_timeline
|
# Following timeline via reverse-chronological algorithm on get_timeline
|
||||||
# Use plain dict to avoid typed-model mismatches across SDK versions
|
# 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", [])
|
feed = getattr(res, "feed", [])
|
||||||
self.cursor = getattr(res, "cursor", None)
|
self.cursor = getattr(res, "cursor", None)
|
||||||
self.items = []
|
self.items = []
|
||||||
@@ -343,7 +351,11 @@ class ATProtoSocialFollowingTimelinePanel(ATProtoSocialHomeTimelinePanel):
|
|||||||
try:
|
try:
|
||||||
api = self.session._ensure_client()
|
api = self.session._ensure_client()
|
||||||
# Pagination via reverse-chronological algorithm on get_timeline
|
# 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", [])
|
feed = getattr(res, "feed", [])
|
||||||
self.cursor = getattr(res, "cursor", None)
|
self.cursor = getattr(res, "cursor", None)
|
||||||
new_items = []
|
new_items = []
|
||||||
@@ -6,9 +6,9 @@ from pubsub import pub
|
|||||||
|
|
||||||
from approve.translation import translate as _
|
from approve.translation import translate as _
|
||||||
from approve.notifications import NotificationError
|
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:
|
# For direct call to util:
|
||||||
# from sessions.atprotosocial import utils as ATProtoSocialUtils
|
# from sessions.blueski import utils as BlueskiUtils
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -272,7 +272,7 @@ class ShowUserProfileDialog(wx.Dialog):
|
|||||||
self.SetTitle(f"{_('User Profile')} - {text}")
|
self.SetTitle(f"{_('User Profile')} - {text}")
|
||||||
|
|
||||||
```python
|
```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)
|
# (This is conceptual, actual integration in handler.py will use the dialog)
|
||||||
#
|
#
|
||||||
# async def user_details(self, buffer_panel_or_user_ident):
|
# async def user_details(self, buffer_panel_or_user_ident):
|
||||||
@@ -8,6 +8,8 @@ class base(wx.Menu):
|
|||||||
self.Append(self.boost)
|
self.Append(self.boost)
|
||||||
self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply"))
|
self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply"))
|
||||||
self.Append(self.reply)
|
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.fav = wx.MenuItem(self, wx.ID_ANY, _(u"&Add to favorites"))
|
||||||
self.Append(self.fav)
|
self.Append(self.fav)
|
||||||
self.unfav = wx.MenuItem(self, wx.ID_ANY, _(u"R&emove from favorites"))
|
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.Append(self.boost)
|
||||||
self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply"))
|
self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply"))
|
||||||
self.Append(self.reply)
|
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.fav = wx.MenuItem(self, wx.ID_ANY, _(u"&Add to favorites"))
|
||||||
self.Append(self.fav)
|
self.Append(self.fav)
|
||||||
self.unfav = wx.MenuItem(self, wx.ID_ANY, _(u"R&emove from favorites"))
|
self.unfav = wx.MenuItem(self, wx.ID_ANY, _(u"R&emove from favorites"))
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class Post(wx.Dialog):
|
|||||||
visibility_sizer.Add(self.visibility, 0, 0, 0)
|
visibility_sizer.Add(self.visibility, 0, 0, 0)
|
||||||
language_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
language_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
post_actions_sizer.Add(language_sizer, 0, wx.RIGHT, 20)
|
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)
|
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)
|
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)
|
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):
|
def create_buttons_section(self, panel):
|
||||||
sizer = wx.BoxSizer(wx.HORIZONTAL)
|
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.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.share.Enable(False)
|
||||||
self.spellcheck = wx.Button(panel, wx.ID_ANY, _("Check &spelling..."))
|
self.spellcheck = wx.Button(panel, wx.ID_ANY, _("Check &spelling..."))
|
||||||
self.translateButton = wx.Button(panel, wx.ID_ANY, _("&Translate..."))
|
self.translateButton = wx.Button(panel, wx.ID_ANY, _("&Translate..."))
|
||||||
@@ -295,7 +295,7 @@ class poll(wx.Dialog):
|
|||||||
sizer_1 = wx.BoxSizer(wx.VERTICAL)
|
sizer_1 = wx.BoxSizer(wx.VERTICAL)
|
||||||
period_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
period_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
sizer_1.Add(period_sizer, 1, wx.EXPAND, 0)
|
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)
|
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 = 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()
|
self.period.SetFocus()
|
||||||
@@ -305,36 +305,36 @@ class poll(wx.Dialog):
|
|||||||
sizer_1.Add(sizer_2, 1, wx.EXPAND, 0)
|
sizer_1.Add(sizer_2, 1, wx.EXPAND, 0)
|
||||||
option1_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
option1_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
sizer_2.Add(option1_sizer, 1, wx.EXPAND, 0)
|
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)
|
option1_sizer.Add(label_2, 0, 0, 0)
|
||||||
self.option1 = wx.TextCtrl(self, wx.ID_ANY, "")
|
self.option1 = wx.TextCtrl(self, wx.ID_ANY, "")
|
||||||
self.option1.SetMaxLength(25)
|
self.option1.SetMaxLength(25)
|
||||||
option1_sizer.Add(self.option1, 0, 0, 0)
|
option1_sizer.Add(self.option1, 0, 0, 0)
|
||||||
option2_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
option2_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
sizer_2.Add(option2_sizer, 1, wx.EXPAND, 0)
|
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)
|
option2_sizer.Add(label_3, 0, 0, 0)
|
||||||
self.option2 = wx.TextCtrl(self, wx.ID_ANY, "")
|
self.option2 = wx.TextCtrl(self, wx.ID_ANY, "")
|
||||||
self.option2.SetMaxLength(25)
|
self.option2.SetMaxLength(25)
|
||||||
option2_sizer.Add(self.option2, 0, 0, 0)
|
option2_sizer.Add(self.option2, 0, 0, 0)
|
||||||
option3_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
option3_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
sizer_2.Add(option3_sizer, 1, wx.EXPAND, 0)
|
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)
|
option3_sizer.Add(label_4, 0, 0, 0)
|
||||||
self.option3 = wx.TextCtrl(self, wx.ID_ANY, "")
|
self.option3 = wx.TextCtrl(self, wx.ID_ANY, "")
|
||||||
self.option3.SetMaxLength(25)
|
self.option3.SetMaxLength(25)
|
||||||
option3_sizer.Add(self.option3, 0, 0, 0)
|
option3_sizer.Add(self.option3, 0, 0, 0)
|
||||||
option4_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
option4_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
sizer_2.Add(option4_sizer, 1, wx.EXPAND, 0)
|
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)
|
option4_sizer.Add(label_5, 0, 0, 0)
|
||||||
self.option4 = wx.TextCtrl(self, wx.ID_ANY, "")
|
self.option4 = wx.TextCtrl(self, wx.ID_ANY, "")
|
||||||
self.option4.SetMaxLength(25)
|
self.option4.SetMaxLength(25)
|
||||||
option4_sizer.Add(self.option4, 0, 0, 0)
|
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)
|
self.multiple.SetValue(False)
|
||||||
sizer_1.Add(self.multiple, 0, wx.ALL, 5)
|
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)
|
self.hide_votes.SetValue(False)
|
||||||
sizer_1.Add(self.hide_votes, 0, wx.ALL, 5)
|
sizer_1.Add(self.hide_votes, 0, wx.ALL, 5)
|
||||||
btn_sizer = wx.StdDialogButtonSizer()
|
btn_sizer = wx.StdDialogButtonSizer()
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ class ShowUserProfile(wx.Dialog):
|
|||||||
mainSizer.Add(privateSizer, 0, wx.ALL | wx.CENTER)
|
mainSizer.Add(privateSizer, 0, wx.ALL | wx.CENTER)
|
||||||
|
|
||||||
botSizer = wx.BoxSizer(wx.HORIZONTAL)
|
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))
|
botText = self.createTextCtrl(bullSwitch[user.bot], (30, 30))
|
||||||
botSizer.Add(botLabel, wx.SizerFlags().Center())
|
botSizer.Add(botLabel, wx.SizerFlags().Center())
|
||||||
botSizer.Add(botText, wx.SizerFlags().Center())
|
botSizer.Add(botText, wx.SizerFlags().Center())
|
||||||
@@ -154,7 +154,7 @@ class ShowUserProfile(wx.Dialog):
|
|||||||
discoverSizer.Add(discoverText, wx.SizerFlags().Center())
|
discoverSizer.Add(discoverText, wx.SizerFlags().Center())
|
||||||
mainSizer.Add(discoverSizer, 0, wx.ALL | wx.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.SetToolTip(_("Click to open {}'s posts").format(user.display_name))
|
||||||
posts.Bind(wx.EVT_BUTTON, self.onPost)
|
posts.Bind(wx.EVT_BUTTON, self.onPost)
|
||||||
mainSizer.Add(posts, wx.SizerFlags().Center())
|
mainSizer.Add(posts, wx.SizerFlags().Center())
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class UpdateProfileDialog(wx.Dialog):
|
|||||||
|
|
||||||
self.locked = wx.CheckBox(panel, label=_("&Private account"))
|
self.locked = wx.CheckBox(panel, label=_("&Private account"))
|
||||||
self.locked.SetValue(locked)
|
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.bot.SetValue(bot)
|
||||||
self.discoverable = wx.CheckBox(panel, label=_("&Discoverable account"))
|
self.discoverable = wx.CheckBox(panel, label=_("&Discoverable account"))
|
||||||
self.discoverable.SetValue(discoverable)
|
self.discoverable.SetValue(discoverable)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class EditTemplateDialog(wx.Dialog):
|
|||||||
sizer_3.AddButton(self.button_SAVE)
|
sizer_3.AddButton(self.button_SAVE)
|
||||||
self.button_CANCEL = wx.Button(self, wx.ID_CANCEL)
|
self.button_CANCEL = wx.Button(self, wx.ID_CANCEL)
|
||||||
sizer_3.AddButton(self.button_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)
|
self.button_RESTORE.Bind(wx.EVT_BUTTON, self.on_restore)
|
||||||
sizer_3.AddButton(self.button_CANCEL)
|
sizer_3.AddButton(self.button_CANCEL)
|
||||||
sizer_3.Realize()
|
sizer_3.Realize()
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ class UserListDialog(wx.Dialog):
|
|||||||
user_list_sizer.Add(self.user_list, 1, wx.EXPAND | wx.ALL, 10)
|
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)
|
main_sizer.Add(user_list_sizer, 1, wx.EXPAND | wx.ALL, 15)
|
||||||
buttons_sizer = wx.BoxSizer(wx.HORIZONTAL)
|
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)
|
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)
|
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)
|
buttons_sizer.Add(close_button, 0)
|
||||||
main_sizer.Add(buttons_sizer, 0, wx.ALIGN_CENTER | wx.BOTTOM, 15)
|
main_sizer.Add(buttons_sizer, 0, wx.ALIGN_CENTER | wx.BOTTOM, 15)
|
||||||
panel.SetSizer(main_sizer)
|
panel.SetSizer(main_sizer)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class mainFrame(wx.Frame):
|
|||||||
self.menuitem_search = self.menubar_application.Append(wx.ID_ANY, _(u"&Search"))
|
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 = self.menubar_application.Append(wx.ID_ANY, _(u"&Lists manager"))
|
||||||
self.lists.Enable(False)
|
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.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.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"))
|
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.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.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.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.load_previous_items = self.menubar_buffer.Append(wx.ID_ANY, _(u"&Load previous items"))
|
||||||
self.menubar_buffer.AppendSeparator()
|
self.menubar_buffer.AppendSeparator()
|
||||||
self.mute_buffer = self.menubar_buffer.AppendCheckItem(wx.ID_ANY, _(u"&Mute"))
|
self.mute_buffer = self.menubar_buffer.AppendCheckItem(wx.ID_ANY, _(u"&Mute"))
|
||||||
@@ -66,8 +66,8 @@ class mainFrame(wx.Frame):
|
|||||||
|
|
||||||
# audio menu
|
# audio menu
|
||||||
self.menubar_audio = wx.Menu()
|
self.menubar_audio = wx.Menu()
|
||||||
self.seekLeft = self.menubar_audio.Append(wx.ID_ANY, _(u"&Seek back 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"))
|
self.seekRight = self.menubar_audio.Append(wx.ID_ANY, _(u"Seek &forward 5 seconds"))
|
||||||
|
|
||||||
# Help Menu
|
# Help Menu
|
||||||
self.menubar_help = wx.Menu()
|
self.menubar_help = wx.Menu()
|
||||||
|
|||||||
186
test_atproto_session.py
Normal file
186
test_atproto_session.py
Normal file
@@ -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()
|
||||||
40
test_config_dir/test_session/session.conf
Normal file
40
test_config_dir/test_session/session.conf
Normal file
@@ -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]
|
||||||
Reference in New Issue
Block a user