mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-03-18 14:27:33 +01:00
Compare commits
364 Commits
v2025.03.0
...
next-gen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d25229a0c | ||
|
|
c1ae648bcd | ||
|
|
56c5475702 | ||
|
|
b3c453b6c4 | ||
|
|
6e6f74f735 | ||
|
|
3eebc26106 | ||
|
|
c3ad0c2340 | ||
|
|
7b281967c8 | ||
|
|
f7b4f3f408 | ||
|
|
026da1c562 | ||
|
|
e444b0ca4e | ||
|
|
bcde91491b | ||
| 72f07f2a75 | |||
|
|
e7dce5e2b6 | ||
|
|
61cc882fbc | ||
|
|
96ac92957d | ||
|
|
75f2e4f039 | ||
|
|
93e28b24e5 | ||
|
|
79d28a0e50 | ||
|
|
59be1f4b2b | ||
|
|
ec5c2125fa | ||
|
|
daa5b9e7bc | ||
|
|
83bb7b4682 | ||
|
|
1186431a9c | ||
|
|
e115464cc8 | ||
|
|
83781e521e | ||
|
|
876d02d00e | ||
|
|
73bfe93269 | ||
|
|
8d94162233 | ||
|
|
4b0b9b01e4 | ||
|
|
27b97ecdae | ||
|
|
21b44b6e6e | ||
|
|
000f60569d | ||
|
|
0ccbe7933b | ||
|
|
5023671c15 | ||
|
|
6e56d94448 | ||
|
|
c7175f942e | ||
|
|
2cbd888b1b | ||
|
|
fb90a97795 | ||
|
|
8a24b3818f | ||
|
|
3320c1a2d7 | ||
|
|
1970aa6be5 | ||
|
|
2cb61f5895 | ||
|
|
abf4cb0df1 | ||
|
|
de1c9ee744 | ||
|
|
7754cccc2e | ||
|
|
769483eaf5 | ||
|
|
f433970574 | ||
|
|
aa6d8a28c9 | ||
|
|
99760751eb | ||
|
|
5f9cf2c25b | ||
|
|
5abe9e7d74 | ||
|
|
aca1e07f84 | ||
|
|
c357ce4ae5 | ||
|
|
afa12c89ec | ||
|
|
a72505e63b | ||
|
|
64d2af770c | ||
|
|
93d37ab3e8 | ||
|
|
b14bff2d0e | ||
|
|
30a7b1a12a | ||
|
|
4cd8636e4a | ||
|
|
52b8888c3b | ||
|
|
6f0514fd6a | ||
|
|
0d8395c6fc | ||
|
|
86adf2311a | ||
|
|
8402bc6d82 | ||
|
|
c275ed9cf8 | ||
|
|
13a9a6538d | ||
|
|
25ecd8b5fd | ||
|
|
5d4ac82c4d | ||
|
|
de10c927d9 | ||
|
|
ca3ee06738 | ||
|
|
6ee67cc886 | ||
|
|
f45af14229 | ||
|
|
a99611215e | ||
|
|
2b978bcafc | ||
|
|
fdcc77989e | ||
|
|
3e239f05c8 | ||
|
|
9bb1522eca | ||
|
|
6a5e4407ac | ||
|
|
d87d7ffcb5 | ||
|
|
4a9214ab71 | ||
|
|
f5b19b3457 | ||
|
|
87915d9f22 | ||
|
|
f7f12a1c7b | ||
|
|
ec8d6ecada | ||
|
|
e2156efa14 | ||
|
|
a919d31f7c | ||
|
|
1f7459800a | ||
|
|
3b64f5f35a | ||
|
|
cdb97579e9 | ||
|
|
51d019f035 | ||
| beb676d9ab | |||
| e5822ac8ee | |||
| 320c7a361c | |||
| 7c131b6936 | |||
| 198b1eefb7 | |||
| 363d2082c0 | |||
|
|
d65109d935 | ||
| 9688c20dd9 | |||
| 4d20d7744a | |||
| 29e52288df | |||
| 9ed2f6771e | |||
| 302c22ab9f | |||
| da493a88ea | |||
|
|
0512d53043 | ||
| 04dca7681b | |||
| 04843616b3 | |||
| 512e2e1684 | |||
| 7a9337c07a | |||
| 15a9df2ca9 | |||
| 4df58f0880 | |||
|
|
56d05b60a1 | ||
|
|
932e44a9c9 | ||
| 31bab4cf8a | |||
| cb0bb4cf27 | |||
| ccaa0d98ff | |||
| dba26f735c | |||
|
|
9d9d86160d | ||
|
|
d3a12b9016 | ||
|
|
eacce7c197 | ||
|
|
1801d0ae73 | ||
|
|
41e2fc0d10 | ||
|
|
aeac36b65d | ||
|
|
0e4a1cbe65 | ||
|
|
7bf8e1670f | ||
|
|
2efed8d5c6 | ||
| 9b60abb3a0 | |||
|
|
f4c2dd5239 | ||
|
|
067957edbf | ||
|
|
b2b98e38b5 | ||
|
|
9f9ea2fec4 | ||
|
|
eac75d93fd | ||
|
|
39608774e7 | ||
|
|
c182b8517b | ||
|
|
96b5acf4e9 | ||
|
|
01109a6436 | ||
|
|
cb98f74ba8 | ||
|
|
243dfdacb4 | ||
|
|
06732cf9cf | ||
|
|
f39971a8d0 | ||
|
|
5d7994a696 | ||
|
|
5d400f9211 | ||
|
|
2d3590a2a0 | ||
|
|
318d0bcdf9 | ||
|
|
9df29c8b51 | ||
|
|
8abee93749 | ||
|
|
81b1db072c | ||
|
|
d1e20fa776 | ||
|
|
1f679e731d | ||
|
|
7659ee9a9e | ||
|
|
9939713075 | ||
|
|
170d4ebbd5 | ||
|
|
196a71fbd9 | ||
|
|
e48ff3d642 | ||
|
|
39e25532d7 | ||
|
|
25f563464b | ||
|
|
08bb5b41c9 | ||
|
|
486001a46c | ||
|
|
4e255b5f6e | ||
|
|
877040b086 | ||
|
|
c294b967ae | ||
|
|
16cc1fec8b | ||
|
|
fed6f6da60 | ||
|
|
89160497e4 | ||
|
|
beb621de97 | ||
|
|
b46fadd375 | ||
|
|
f0fbfab3b3 | ||
|
|
f83a3e16ce | ||
|
|
b309d23c8b | ||
|
|
d879a9de5c | ||
|
|
b419f8fb23 | ||
|
|
7a4a454376 | ||
|
|
e94ea49142 | ||
|
|
56415c7506 | ||
|
|
c8879e7998 | ||
|
|
8fb30edf31 | ||
|
|
3db6ee3a17 | ||
| e4520a14e8 | |||
| a13e1f1f10 | |||
| 377578dbe2 | |||
| c436fbc944 | |||
|
|
7399ac46d4 | ||
| 3af372973d | |||
| de837e15b9 | |||
| 49eaa57027 | |||
|
|
977de1332a | ||
|
|
cbafb7da69 | ||
|
|
966d43ca29 | ||
|
|
193f9bfb66 | ||
|
|
2f77675562 | ||
|
|
b13dd986fc | ||
|
|
4269124758 | ||
|
|
6be0e82610 | ||
|
|
a6e8f2969a | ||
|
|
eb3a038a60 | ||
|
|
bb60eaa2fc | ||
|
|
46654ef054 | ||
|
|
981d98e5e2 | ||
|
|
7eda523520 | ||
|
|
ea54406127 | ||
|
|
1db78da82f | ||
|
|
9c0b7f0c78 | ||
|
|
e62b4d9d77 | ||
|
|
a99f2e9e64 | ||
|
|
0ad32886e1 | ||
|
|
b1bf2ea95f | ||
|
|
b62b83fa00 | ||
|
|
bfd0d938e1 | ||
|
|
4d2d044aa9 | ||
|
|
e8e1c4dedf | ||
|
|
dd23074177 | ||
|
|
127cd5ddf3 | ||
|
|
cdeab8ffc6 | ||
|
|
d2fc571100 | ||
|
|
4a27571e04 | ||
|
|
f3296d646e | ||
|
|
8c0a863770 | ||
|
|
2b8370a377 | ||
|
|
a4d5066156 | ||
| ebabace52d | |||
|
|
ed0cc67ba5 | ||
|
|
dce625547d | ||
|
|
06c370dfd6 | ||
|
|
db83abdbd3 | ||
|
|
11a60c52f3 | ||
|
|
59f08b991e | ||
|
|
3c4e9a8fa7 | ||
|
|
c632743db3 | ||
|
|
6511a3a889 | ||
|
|
66383b8bda | ||
|
|
d24f89947e | ||
|
|
8a2d6631da | ||
|
|
95d0b575c8 | ||
|
|
1e39ce69ef | ||
|
|
905b0fc255 | ||
|
|
3bf39d2349 | ||
|
|
04b43993eb | ||
|
|
321b358a62 | ||
|
|
83d752c24d | ||
|
|
d8c0094003 | ||
|
|
9124476ce0 | ||
|
|
148e831624 | ||
|
|
5112c309ea | ||
|
|
a0642853e8 | ||
|
|
0a4f2e1936 | ||
|
|
3ae3a304a1 | ||
|
|
23e3327029 | ||
|
|
5fd45b5343 | ||
|
|
706c863cd8 | ||
|
|
a7942c4ffe | ||
|
|
7a4d0b9bf1 | ||
|
|
db8983e469 | ||
|
|
1393813c4e | ||
|
|
4ee3363140 | ||
|
|
5dad01130f | ||
|
|
a5ba726230 | ||
|
|
4070c1bb43 | ||
|
|
73d9474679 | ||
|
|
2bf1c68492 | ||
|
|
f60beb3eab | ||
|
|
f00029a154 | ||
|
|
7ed053c929 | ||
|
|
0de60d085a | ||
|
|
e8443cd526 | ||
|
|
12981ee707 | ||
|
|
3d1b9b9c5e | ||
|
|
dd3d1308b7 | ||
|
|
0fdca4d842 | ||
|
|
985e5d42ea | ||
|
|
80f44a99ca | ||
|
|
04692fb708 | ||
|
|
3cb9a8983d | ||
|
|
29dfa709ef | ||
|
|
7dbfeeced9 | ||
|
|
655828946c | ||
|
|
ca91fe1cbe | ||
|
|
c2ce5bde82 | ||
|
|
4bb65fe624 | ||
|
|
28bcb0f52f | ||
|
|
49a8cd4e65 | ||
|
|
e428b41d85 | ||
|
|
a929db05d2 | ||
|
|
a624a97d14 | ||
|
|
60ac77adf5 | ||
|
|
5cd5179c3e | ||
|
|
6c9ef7ea54 | ||
|
|
6872f4fd1c | ||
|
|
a45e7dd7eb | ||
|
|
ea8d8a9296 | ||
|
|
66bc366da3 | ||
|
|
267875c70a | ||
|
|
e68ba5778a | ||
|
|
82bcf1dbbf | ||
|
|
4779c1d004 | ||
|
|
e295e60fc0 | ||
|
|
e383f15ee8 | ||
|
|
aacbdabee3 | ||
|
|
f290bd80f1 | ||
|
|
8e999e67d4 | ||
|
|
4a0dbd2140 | ||
|
|
4de969d9cd | ||
|
|
b1ad31b2ee | ||
|
|
92f7bfeebd | ||
|
|
1dffa2a6f9 | ||
|
|
b4288ce51e | ||
|
|
345ec6ffb0 | ||
|
|
436e58dae0 | ||
|
|
48d55de460 | ||
|
|
b15151ddf3 | ||
|
|
a2312e8874 | ||
|
|
663431ef5d | ||
|
|
f3cba380ba | ||
|
|
4c8c7ed384 | ||
|
|
0395b372bd | ||
|
|
a2c5bbe65c | ||
|
|
b0ca33c9d8 | ||
|
|
350b25cc67 | ||
|
|
51cf283421 | ||
|
|
fb227886d2 | ||
|
|
0d49b11411 | ||
|
|
43af826eb0 | ||
|
|
b56379e485 | ||
|
|
f8c770947f | ||
|
|
a55dc1b4aa | ||
|
|
cd146e9cf6 | ||
|
|
ecb56ae4f0 | ||
|
|
e58543d6c4 | ||
|
|
35917ee05a | ||
|
|
411465db67 | ||
|
|
8eed4a12c9 | ||
|
|
894f3fd113 | ||
|
|
014c510061 | ||
|
|
f55ff3a3a9 | ||
|
|
67145df1b6 | ||
|
|
0b8f124088 | ||
|
|
12e80dc7e2 | ||
|
|
f58b1b03d2 | ||
|
|
8fc627b77a | ||
|
|
f99282025b | ||
|
|
04916588c3 | ||
|
|
c6501664ee | ||
|
|
f5ba3fdf14 | ||
|
|
e6be7c2563 | ||
|
|
35cba4c6c6 | ||
|
|
4e11d2562a | ||
|
|
00a97a66a4 | ||
|
|
0030b1c65d | ||
|
|
689afb0682 | ||
|
|
08316733d3 | ||
|
|
e5bdddd45c | ||
|
|
b3cd888424 | ||
|
|
f9577de904 | ||
|
|
b011879b0d | ||
|
|
6a64147d96 | ||
|
|
a45138d68b | ||
|
|
47bb008710 | ||
| a64cc0a28c | |||
| bbef9d988b | |||
|
|
a5afd1b1a8 | ||
| 487282d512 | |||
| 54d0f70f38 | |||
|
|
b78eec35f8 | ||
|
|
84b8932df9 |
23
.claude/settings.local.json
Normal file
23
.claude/settings.local.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ls:*)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:bsky.app)",
|
||||
"Bash(cmd /c \"dir /s /b %APPDATA%\\\\twblue\\\\*.log 2>nul\")",
|
||||
"Bash(cmd /c \"dir /s /b %TEMP%\\\\twblue*.log 2>nul\")",
|
||||
"Bash(cmd /c \"type C:\\\\Users\\\\Usuario\\\\Desktop\\\\repos\\\\twblue\\\\src\\\\logs\\\\debug.log | findstr /n . | findstr /r \"^[0-9]*:\" | tail -50\")",
|
||||
"WebFetch(domain:atproto.blue)",
|
||||
"Bash(fc:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(pip show:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Get python interpreter
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install python packages
|
||||
run: python -m pip install -r requirements.txt
|
||||
@@ -29,6 +29,12 @@ jobs:
|
||||
.\scripts\build.ps1
|
||||
mv src/dist scripts\TWBlue64
|
||||
|
||||
- name: Install NSIS
|
||||
run: choco install nsis
|
||||
|
||||
- name: Add NSIS to PATH
|
||||
run: echo "C:\Program Files (x86)\NSIS" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
- name: make installer
|
||||
run: |
|
||||
cd scripts
|
||||
|
||||
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
|
||||
44
README.md
44
README.md
@@ -71,6 +71,50 @@ Now that you have installed all these packages, you can run TW Blue from source
|
||||
|
||||
If necessary, change the first part of the command to reflect the location of your python executable.
|
||||
|
||||
### Development bootstrap (Windows / PowerShell)
|
||||
|
||||
If you are starting fresh in this repository, run:
|
||||
|
||||
```powershell
|
||||
./scripts/bootstrap-dev.ps1
|
||||
```
|
||||
|
||||
This script initializes submodules, creates `.venv` (unless you opt out), and installs dependencies.
|
||||
|
||||
Useful options:
|
||||
|
||||
```powershell
|
||||
# Recreate virtual environment from scratch
|
||||
./scripts/bootstrap-dev.ps1 -RecreateVenv
|
||||
|
||||
# Upgrade pip tooling before installing requirements
|
||||
./scripts/bootstrap-dev.ps1 -UpgradePip
|
||||
|
||||
# Use system Python instead of .venv
|
||||
./scripts/bootstrap-dev.ps1 -SystemPython
|
||||
```
|
||||
|
||||
### Running tests (Windows / PowerShell)
|
||||
|
||||
After bootstrap, run tests with:
|
||||
|
||||
```powershell
|
||||
./scripts/run-tests.ps1
|
||||
```
|
||||
|
||||
Useful options:
|
||||
|
||||
```powershell
|
||||
# Run only Bluesky tests
|
||||
./scripts/run-tests.ps1 -PytestTargets src/test/sessions/blueski -PytestArgs "-q"
|
||||
|
||||
# Run only a specific test file
|
||||
./scripts/run-tests.ps1 -PytestTargets src/test/sessions/blueski/test_blueski_quotes.py -PytestArgs "-q"
|
||||
|
||||
# Use system Python instead of .venv
|
||||
./scripts/run-tests.ps1 -SystemPython
|
||||
```
|
||||
|
||||
### Generating the documentation
|
||||
|
||||
To generate the documentation in html format, navigate to the doc folder inside this repo. After that, run these commands:
|
||||
|
||||
@@ -1,7 +1,91 @@
|
||||
TWBlue Changelog
|
||||
|
||||
## Changes in version 2024.X.X (Upcoming - ATProtoSocial Integration)
|
||||
|
||||
This version introduces comprehensive support for the AT Protocol (ATProto), enabling users to connect to and interact with Bluesky accounts.
|
||||
|
||||
* Core:
|
||||
* **New Protocol Support**: Added ATProtoSocial (Bluesky) as a new session type.
|
||||
* **Session Management**: Users can add Bluesky accounts using their handle and an App Password. Includes session creation, loading, and management through the Session Manager.
|
||||
* **UI Adaptation**:
|
||||
* Menus (e.g., "Post", "User Actions") dynamically update labels and available actions based on whether an ATProtoSocial session is active (e.g., "Tweet" becomes "Post", "Retweet" becomes "Repost", "Favorite" becomes "Like").
|
||||
* New compose dialog (`src/wxUI/dialogs/composeDialog.py`) created to be more generic and configurable by session type, supporting features like character limits, media attachments (images with alt text), language selection, content warnings, and quoting specific to Bluesky.
|
||||
* New user profile dialog (`src/wxUI/dialogs/atprotosocial/showUserProfile.py`) for displaying Bluesky user details and performing actions.
|
||||
* New UI panels (`src/wxUI/buffers/atprotosocial/panels.py`) for displaying Home timelines, User timelines, Notifications, and User Lists (Followers/Following) for Bluesky.
|
||||
* ATProtoSocial (Bluesky) Features:
|
||||
* **Authentication**: Secure login using user handle and App Passwords.
|
||||
* **Posting**:
|
||||
* Create posts (skeets) with text.
|
||||
* Attach images (up to 4, with alt text).
|
||||
* Specify post language(s).
|
||||
* Add content warnings (sensitive content labels).
|
||||
* Quote other posts.
|
||||
* Reply to posts.
|
||||
* **Timelines**:
|
||||
* View Home timeline (posts from followed users), with support for loading newer and older posts.
|
||||
* View other users' timelines (their posts and replies).
|
||||
* **Notifications**:
|
||||
* Fetch and display notifications for likes, reposts, follows, mentions, replies, and quotes.
|
||||
* Notifications are displayed in a dedicated buffer and trigger desktop alerts.
|
||||
* **User Actions**:
|
||||
* View user profiles (display name, handle, bio, counts, etc.).
|
||||
* Follow / Unfollow users.
|
||||
* Mute / Unmute users.
|
||||
* Block / Unblock users.
|
||||
* **User Interaction**:
|
||||
* Like / Unlike posts.
|
||||
* Repost / Unrepost posts (Unrepost might be deleting the repost record).
|
||||
* **User Discovery**:
|
||||
* Search for users by handle or display name.
|
||||
* View lists of followers and accounts a user is following.
|
||||
* **Content Display**:
|
||||
* Posts are formatted for display, showing author, text, timestamp, embedded media (images, quotes, external links with placeholders), reply/repost/like counts, and CWs.
|
||||
* Notifications are formatted for display in their buffer.
|
||||
* Developer / Internal:
|
||||
* New session module: `sessions.atprotosocial` (Session, Utils, Compose, Streaming placeholders).
|
||||
* New controller module: `controller.atprotosocial` (Handler, UserList, etc.).
|
||||
* Extensive use of the `atproto` Python SDK for Bluesky API interactions.
|
||||
|
||||
## changes in this version
|
||||
|
||||
* Core:
|
||||
* Expanded the keystroke editor actions list. Now, many previously hidden or unassignable actions are available to be mapped to custom keyboard shortcuts.
|
||||
* Mastodon:
|
||||
* Added support for sending quoted posts! You can now quote other users' posts from the context menu or the new Boost dialog. ([#860](https://github.com/mcv-software/twblue/issues/860))
|
||||
* Fixed an issue where HTML entities were not decoded when editing a post. ([#893](https://github.com/mcv-software/twblue/issues/893))
|
||||
|
||||
## Changes in version 2026.01.13
|
||||
|
||||
In this version, we have focused on expanding content management capabilities within Mastodon. It is now possible to edit sent posts and schedule them for future publication. Additionally, support for reading quoted posts has been implemented, and a new buffer for server announcements is available. On the Core side, visual stability has been prioritized to ensure proper window display, along with an expansion of keyboard shortcuts.
|
||||
|
||||
* Core:
|
||||
* Fixed a critical issue where buffers were not visible on screen in certain configurations. Now the main window maximizes correctly and visual fixes have been applied to ensure content is accessible. ([#886](https://github.com/mcv-software/twblue/issues/886))
|
||||
* Keyboard shortcut improvements: Several shortcuts have been added and fixed to improve efficiency and avoid conflicts:
|
||||
* New shortcuts for the autocomplete users manager and menu bar items. ([#842](https://github.com/mcv-software/twblue/issues/842))
|
||||
* Added a shortcut for the "Restore Template" button in the template editor dialog. ([#841](https://github.com/mcv-software/twblue/issues/841))
|
||||
* New shortcuts for user list and poll dialogs.
|
||||
* Resolved a conflict with the 's' key shortcut used for seeking media.
|
||||
* Updated the shortcut for marking an account as a "Bot" to avoid conflict with the biography field.
|
||||
* Mastodon:
|
||||
* **Post Editing:** It is finally possible to edit Mastodon posts from TWBlue! You can now correct errors in your posts. ([#859](https://github.com/mcv-software/twblue/issues/859))
|
||||
* Safety warning: if you edit a post containing a poll, votes will be reset.
|
||||
* Polls are now correctly displayed as attachments within the edit dialog.
|
||||
* **Scheduled Posts:** It is now possible to schedule your posts to be published at a later time!
|
||||
* Added a "Schedule post" checkbox to the post dialog with date and time pickers.
|
||||
* Implemented validation to ensure posts are scheduled at least 5 minutes in the future, as required by Mastodon.
|
||||
* The default time is automatically set to 6 minutes in the future for convenience.
|
||||
* **Quoted Posts:** Significantly improved the reading and display of quoted posts. TWBlue now structures and reads this content more clearly. ([#860](https://github.com/mcv-software/twblue/issues/860))
|
||||
* **Content Cleaning:** Implemented a more robust HTML filter to remove junk elements or unnecessary CSS classes from post text, offering a cleaner reading experience.
|
||||
* **Mute Conversation:** Enhanced the "Mute Conversation" feature.
|
||||
* Posts from muted conversations will now be visually hidden from the Home timeline immediately upon muting, ensuring a cleaner experience.
|
||||
* Added a new invisible shortcut to toggle mute on the focused conversation: `Alt+Windows+Shift+Delete` (Default) or `Control+Alt+Windows+Backspace` (Windows 10/11).
|
||||
* The action is also available in the context menu of the post.
|
||||
* **Announcements:** Added support for viewing server announcements.
|
||||
* New dedicated buffer for "Announcements" where you can read instance-wide news.
|
||||
* Added ability to dismiss (mark as read) announcements directly from the buffer.
|
||||
|
||||
## Changes in version 2025.3.8
|
||||
|
||||
In this version, we have focused on providing initial support for Mastodon filters and pinned posts. From TWBlue, it is now possible to initially use filters for posts in most buffers, as well as manage them (create, edit, and delete filters, in addition to adding keywords). A new variable has also been added for post templates in the invisible interface that allows displaying whether a post has been pinned by its author.
|
||||
|
||||
* Mastodon:
|
||||
|
||||
@@ -28,4 +28,15 @@ The invisible interface, as its name suggests, has no graphical window and works
|
||||
Global settings and session settings
|
||||
++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
TWBlue has two different configuration dialogs: the global configuration dialog, which affects how TWBlue works for all sessions, and the session configuration dialog, which only affects how the current session works. You will find specific information about the session settings dialog for Twitter and Mastodon in its corresponding chapter in this guide.
|
||||
TWBlue has two different configuration dialogs: the global configuration dialog, which affects how TWBlue works for all sessions, and the session configuration dialog, which only affects how the current session works. You will find specific information about the session settings dialog for Twitter and Mastodon in its corresponding chapter in this guide.
|
||||
|
||||
Blueski / Bluesky Specific Terms
|
||||
++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
When using the Blueski (Bluesky) integration, you might encounter these terms:
|
||||
|
||||
* **Handle**: Your user-facing address on Bluesky (e.g., ``@username.bsky.social`` or a custom domain like ``@yourname.com``). This is what you use to log in with an App Password in TWBlue. Handles can be changed, but your DID remains the same.
|
||||
* **App Password**: A specific password you generate within your Bluesky account settings (usually under Settings -> Advanced -> App passwords) for use with third-party applications like TWBlue. This is more secure than using your main account password, as each App Password can be revoked individually.
|
||||
* **DID (Decentralized Identifier)**: A unique, permanent identifier for users and data on the AT Protocol. It typically starts with ``did:plc:``. Your DID doesn't change even if your handle does. You generally won't need to interact with DIDs directly in TWBlue, as handles are used more commonly for user interaction.
|
||||
* **Skyline**: This is the term Bluesky uses for your main home timeline, showing posts from people you follow.
|
||||
* **Skeet**: An informal term for a post on Bluesky (akin to a "tweet" on Twitter).
|
||||
59
documentation/source/blueski.rst
Normal file
59
documentation/source/blueski.rst
Normal file
@@ -0,0 +1,59 @@
|
||||
.. _blueski_bluesky:
|
||||
|
||||
**************************************
|
||||
Blueski (Bluesky) Integration
|
||||
**************************************
|
||||
|
||||
TWBlue now supports the AT Protocol (ATProto), the decentralized social networking protocol that powers Bluesky. This allows you to interact with your Bluesky account directly within TWBlue.
|
||||
|
||||
Adding a Blueski Account
|
||||
===============================
|
||||
|
||||
To connect your Bluesky account to TWBlue, you will need your user **handle** and an **App Password**.
|
||||
|
||||
1. **User Handle**: This is your unique Bluesky identifier, often in the format ``@username.bsky.social`` or a custom domain you've configured (e.g., ``@yourname.com``).
|
||||
2. **App Password**: Bluesky uses App Passwords for third-party applications like TWBlue instead of your main account password. You need to generate an App Password from your Bluesky account settings.
|
||||
* Go to Bluesky Settings (usually accessible from the Bluesky app or website).
|
||||
* Navigate to the "App passwords" section (this might be under "Advanced" or "Security").
|
||||
* Generate a new App Password. Give it a descriptive name (e.g., "TWBlue").
|
||||
* Copy the generated App Password immediately. It will usually only be shown once.
|
||||
|
||||
Once you have your handle and the App Password:
|
||||
|
||||
1. Open TWBlue and go to the Session Manager (Application Menu -> Manage accounts).
|
||||
2. Click on "New account".
|
||||
3. Select "Blueski (Bluesky)" from the menu.
|
||||
4. A dialog will prompt you to confirm that you want to authorize your account. Click "Yes".
|
||||
5. You will then be asked for your Bluesky Handle. Enter your full handle (e.g., ``@username.bsky.social`` or ``username.bsky.social``).
|
||||
6. Next, you will be asked for the App Password you generated. Enter it carefully.
|
||||
7. If the credentials are correct, TWBlue will log in to your Bluesky account, and the new session will be added to your accounts list.
|
||||
|
||||
Key Features
|
||||
============
|
||||
|
||||
Once your Blueski account is connected, you can use the following features in TWBlue:
|
||||
|
||||
* **Posting**: Create new posts (often called "skeets") with text, images, and specify language.
|
||||
* **Timelines**:
|
||||
* **Discover (algorithmic)**: A home feed curated by Bluesky.
|
||||
* **Following (chronological)**: View posts from users you follow in order.
|
||||
* **User Timelines**: View posts from specific users.
|
||||
* **Mentions & Replies**: These will appear in your Notifications.
|
||||
* **Notifications**: Receive notifications for likes, reposts, follows, mentions, replies, and quotes.
|
||||
* **User Actions**:
|
||||
* Follow and unfollow users.
|
||||
* Mute and unmute users.
|
||||
* Block and unblock users (blocking is done on your PDS/server).
|
||||
* **Quoting Posts**: Quote other users' posts when you create a new post.
|
||||
* **User Search**: Search for users by their handle or display name.
|
||||
* **Content Warnings**: Create posts with content warnings (sensitive content labels).
|
||||
|
||||
Basic Concepts for Blueski
|
||||
==================================
|
||||
|
||||
* **DID (Decentralized Identifier)**: A unique, permanent identifier for users and data on the AT Protocol. It doesn't change even if your handle does. You generally won't need to interact with DIDs directly in TWBlue, as handles are used more commonly.
|
||||
* **Handle**: Your user-facing address on Bluesky (e.g., ``@username.bsky.social``). This is what you use to log in with an App Password in TWBlue. Handles can be changed, but your DID remains the same.
|
||||
* **App Password**: A specific password you generate within your Bluesky account settings for use with third-party applications like TWBlue. This is more secure than using your main account password.
|
||||
* **PDS (Personal Data Server)**: Where your account data is stored on the AT Protocol network. Most users are on the main Bluesky PDS (bsky.social), but you could potentially use a different one. TWBlue will typically connect to the default Bluesky PDS.
|
||||
|
||||
Further details on specific actions can be found in the relevant sections of this documentation. As Bluesky and the AT Protocol evolve, TWBlue will aim to incorporate new features and refinements.
|
||||
@@ -16,6 +16,7 @@ This is the user guide for the latest available version of TWBlue. The purpose o
|
||||
system_requirements
|
||||
installation
|
||||
basic_concepts
|
||||
blueski
|
||||
usage
|
||||
global_settings
|
||||
credits
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
## Changelog
|
||||
|
||||
In this version, we have focused on providing initial support for Mastodon filters and pinned posts. From TWBlue, it is now possible to initially use filters for posts in most buffers, as well as manage them (create, edit, and delete filters, in addition to adding keywords). A new variable has also been added for post templates in the invisible interface that allows displaying whether a post has been pinned by its author.
|
||||
In this version, we have focused on expanding content management capabilities within Mastodon. It is now possible to edit sent posts and schedule them for future publication. Additionally, support for reading quoted posts has been implemented, and a new buffer for server announcements is available. On the Core side, visual stability has been prioritized to ensure proper window display, along with an expansion of keyboard shortcuts.
|
||||
|
||||
* Core:
|
||||
* Fixed a critical issue where buffers were not visible on screen in certain configurations. Now the main window maximizes correctly and visual fixes have been applied to ensure content is accessible. ([#886](https://github.com/mcv-software/twblue/issues/886))
|
||||
* Keyboard shortcut improvements: Several shortcuts have been added and fixed to improve efficiency and avoid conflicts:
|
||||
* New shortcuts for the autocomplete users manager and menu bar items. ([#842](https://github.com/mcv-software/twblue/issues/842))
|
||||
* Added a shortcut for the "Restore Template" button in the template editor dialog. ([#841](https://github.com/mcv-software/twblue/issues/841))
|
||||
* New shortcuts for user list and poll dialogs.
|
||||
* Resolved a conflict with the 's' key shortcut used for seeking media.
|
||||
* Updated the shortcut for marking an account as a "Bot" to avoid conflict with the biography field.
|
||||
* Mastodon:
|
||||
* Added filters support to TWBlue. Filters are only implemented in posts for the moment. TWBlue will, depending in the selected settings, hide behind a content warning or completely ignore a post based on filters. Also it is possible to add, delete or edit filters from the buffer menu in the menu bar.
|
||||
* A language selector has been added for posting in TWBlue. It is now possible to choose the language in which a post will be made, which will be useful for content filtering and other language-dependent features. The default language can be chosen based on your Mastodon account’s language, the language of the post you’re replying to, or, if no automatic selection is possible, TWBlue’s own language will be used by default.
|
||||
* TWBlue now supports announcing (via a new template variable for posts) pinned posts. You can edit your posts template and use the $pinned variable. When reading the post, TWBlue will indicate if the post is pinned. Also, when loading an user timeline, pinned posts will be loaded at the top or bottom of the buffer according to local settings.
|
||||
* TWBlue should be able to display all posts in the post displayer dialog.
|
||||
* reading long posts in the graphical user interface should work better.
|
||||
* **Post Editing:** It is finally possible to edit Mastodon posts from TWBlue! You can now correct errors in your posts. ([#859](https://github.com/mcv-software/twblue/issues/859))
|
||||
* Safety warning: if you edit a post containing a poll, votes will be reset.
|
||||
* Polls are now correctly displayed as attachments within the edit dialog.
|
||||
* **Scheduled Posts:** It is now possible to schedule your posts to be published at a later time!
|
||||
* Added a "Schedule post" checkbox to the post dialog with date and time pickers.
|
||||
* Implemented validation to ensure posts are scheduled at least 5 minutes in the future, as required by Mastodon.
|
||||
* The default time is automatically set to 6 minutes in the future for convenience.
|
||||
* **Quoted Posts:** Significantly improved the reading and display of quoted posts. TWBlue now structures and reads this content more clearly. ([#860](https://github.com/mcv-software/twblue/issues/860))
|
||||
* **Content Cleaning:** Implemented a more robust HTML filter to remove junk elements or unnecessary CSS classes from post text, offering a cleaner reading experience.
|
||||
* **Mute Conversation:** Enhanced the "Mute Conversation" feature.
|
||||
* Posts from muted conversations will now be visually hidden from the Home timeline immediately upon muting, ensuring a cleaner experience.
|
||||
* Added a new invisible shortcut to toggle mute on the focused conversation: `Alt+Windows+Shift+Delete` (Default) or `Control+Alt+Windows+Backspace` (Windows 10/11).
|
||||
* The action is also available in the context menu of the post.
|
||||
* **Announcements:** Added support for viewing server announcements.
|
||||
* New dedicated buffer for "Announcements" where you can read instance-wide news.
|
||||
* Added ability to dismiss (mark as read) announcements directly from the buffer.
|
||||
@@ -1,58 +1,59 @@
|
||||
accessible_output2 @ git+https://github.com/accessibleapps/accessible_output2@57bda997d98e87dd78aa049e7021cf777871619b
|
||||
arrow==1.3.0
|
||||
attrs==25.1.0
|
||||
arrow==1.4.0
|
||||
attrs==25.4.0
|
||||
backports.functools-lru-cache==2.0.0
|
||||
blurhash==1.1.4
|
||||
certifi==2025.1.31
|
||||
chardet==5.2.0
|
||||
charset-normalizer==3.4.1
|
||||
blurhash==1.1.5
|
||||
certifi==2026.2.25
|
||||
chardet==7.1.0
|
||||
charset-normalizer==3.4.6
|
||||
colorama==0.4.6
|
||||
configobj==5.0.9
|
||||
coverage==7.6.12
|
||||
cx-Freeze==7.2.10
|
||||
coverage==7.13.4
|
||||
cx-Freeze==8.6.0
|
||||
cx-Logging==3.2.1
|
||||
decorator==5.2.1
|
||||
demoji==1.1.0
|
||||
deepl==1.21.0
|
||||
deepl==1.28.0
|
||||
future==1.0.0
|
||||
idna==3.10
|
||||
importlib-metadata==8.6.1
|
||||
iniconfig==2.0.0
|
||||
idna==3.11
|
||||
importlib-metadata==8.7.1
|
||||
iniconfig==2.3.0
|
||||
libloader @ git+https://github.com/accessibleapps/libloader@bc94811c095b2e57a036acd88660be9a33260267
|
||||
libretranslatepy==2.1.4
|
||||
lief==0.15.1
|
||||
Markdown==3.7
|
||||
Mastodon.py==2.0.1
|
||||
numpy==2.2.3
|
||||
oauthlib==3.2.2
|
||||
packaging==24.2
|
||||
pillow==11.1.0
|
||||
Markdown==3.10.2
|
||||
Mastodon.py==2.1.4
|
||||
numpy==2.4.3
|
||||
oauthlib==3.3.1
|
||||
packaging==25.0
|
||||
pillow==12.1.1
|
||||
platform_utils @ git+https://github.com/accessibleapps/platform_utils@e0d79f7b399c4ea677a633d2dde9202350d62c38
|
||||
pluggy==1.5.0
|
||||
psutil==7.0.0
|
||||
pyenchant==3.2.2
|
||||
pluggy==1.6.0
|
||||
psutil==7.2.2
|
||||
pyenchant==3.3.0
|
||||
pypiwin32==223
|
||||
Pypubsub==4.0.3
|
||||
Pypubsub==4.0.7
|
||||
PySocks==1.7.1
|
||||
pytest==8.3.5
|
||||
pytest==9.0.2
|
||||
python-dateutil==2.9.0.post0
|
||||
python-magic-bin==0.4.14
|
||||
python-vlc==3.0.21203
|
||||
pywin32==308
|
||||
requests==2.32.3
|
||||
pywin32==311
|
||||
requests==2.32.5
|
||||
requests-oauthlib==2.0.0
|
||||
requests-toolbelt==1.0.0
|
||||
rfc3986==2.0.0
|
||||
setuptools==69.0.0
|
||||
setuptools==82.0.1
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
sound_lib @ git+https://github.com/accessibleapps/sound_lib@a439f0943fb95ee7b6ba24f51a686f47c4ad66b2
|
||||
sqlitedict==2.1.0
|
||||
twitter-text-parser==3.0.0
|
||||
types-python-dateutil==2.9.0.20241206
|
||||
urllib3==2.3.0
|
||||
types-python-dateutil==2.9.0.20260305
|
||||
urllib3==2.6.3
|
||||
win-inet-pton==1.1.0
|
||||
winpaths==0.2
|
||||
wxPython==4.2.2
|
||||
wxPython==4.2.5
|
||||
youtube-dl==2021.12.17
|
||||
zipp==3.21.0
|
||||
zipp==3.23.0
|
||||
atproto>=0.0.65
|
||||
|
||||
176
scripts/bootstrap-dev.ps1
Normal file
176
scripts/bootstrap-dev.ps1
Normal file
@@ -0,0 +1,176 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bootstraps a local TWBlue development environment on Windows.
|
||||
|
||||
.DESCRIPTION
|
||||
This script initializes git submodules, creates/uses a virtual environment,
|
||||
and installs Python dependencies from requirements.txt.
|
||||
It is intended to be run once when starting on the repository (or whenever
|
||||
you need to rebuild your local environment).
|
||||
|
||||
.PARAMETER RecreateVenv
|
||||
Deletes and recreates the `.venv` folder before installing dependencies.
|
||||
|
||||
.PARAMETER UpgradePip
|
||||
Upgrades `pip`, `setuptools`, and `wheel` before installing requirements.
|
||||
|
||||
.PARAMETER SkipSubmodules
|
||||
Skips `git submodule init` and `git submodule update --recursive`.
|
||||
|
||||
.PARAMETER SystemPython
|
||||
Uses a detected system Python instead of creating/using `.venv`.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/bootstrap-dev.ps1
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/bootstrap-dev.ps1 -RecreateVenv -UpgradePip
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/bootstrap-dev.ps1 -SystemPython -SkipSubmodules
|
||||
#>
|
||||
|
||||
param(
|
||||
[switch]$RecreateVenv,
|
||||
[switch]$UpgradePip,
|
||||
[switch]$SkipSubmodules,
|
||||
[switch]$SystemPython
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Resolve-RepoRoot {
|
||||
param([string]$ScriptDir)
|
||||
return (Resolve-Path (Join-Path $ScriptDir "..")).Path
|
||||
}
|
||||
|
||||
function Test-PythonCandidate {
|
||||
param(
|
||||
[string]$Exe,
|
||||
[string[]]$BaseArgs = @()
|
||||
)
|
||||
|
||||
try {
|
||||
& $Exe @BaseArgs -c "import sys; print(sys.version)" | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function New-PythonSpec {
|
||||
param(
|
||||
[string]$Exe,
|
||||
[string[]]$BaseArgs = @()
|
||||
)
|
||||
|
||||
return [pscustomobject]@{
|
||||
Exe = $Exe
|
||||
BaseArgs = $BaseArgs
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PythonSpec {
|
||||
param([string]$RepoRoot, [bool]$UseSystemPython)
|
||||
|
||||
if (-not $UseSystemPython) {
|
||||
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
|
||||
if (Test-Path $venvPython) {
|
||||
return (New-PythonSpec -Exe $venvPython)
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = @()
|
||||
|
||||
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
|
||||
if ($pythonCmd) {
|
||||
$candidates += ,(New-PythonSpec -Exe "python")
|
||||
}
|
||||
|
||||
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($pyCmd) {
|
||||
$candidates += ,(New-PythonSpec -Exe "py" -BaseArgs @("-3.10"))
|
||||
$candidates += ,(New-PythonSpec -Exe "py" -BaseArgs @("-3"))
|
||||
}
|
||||
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-PythonCandidate -Exe $candidate.Exe -BaseArgs $candidate.BaseArgs) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw "Could not find a usable Python. Install Python 3.10+ and try again."
|
||||
}
|
||||
|
||||
function Invoke-Python {
|
||||
param(
|
||||
[pscustomobject]$PythonSpec,
|
||||
[string[]]$Arguments,
|
||||
[string]$WorkingDirectory
|
||||
)
|
||||
|
||||
Push-Location $WorkingDirectory
|
||||
try {
|
||||
& $PythonSpec.Exe @($PythonSpec.BaseArgs + $Arguments)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Python command failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Resolve-RepoRoot -ScriptDir $scriptDir
|
||||
|
||||
Write-Step "Repository: $repoRoot"
|
||||
|
||||
if (-not $SkipSubmodules) {
|
||||
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
|
||||
if ($gitCmd) {
|
||||
Write-Step "Initializing/updating submodules"
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
& git submodule init | Out-Host
|
||||
& git submodule update --recursive | Out-Host
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning "Git is not available. Skipping submodule initialization."
|
||||
}
|
||||
}
|
||||
|
||||
$venvPath = Join-Path $repoRoot ".venv"
|
||||
if ($RecreateVenv -and (Test-Path $venvPath)) {
|
||||
Write-Step "Removing existing virtual environment"
|
||||
Remove-Item -Recurse -Force $venvPath
|
||||
}
|
||||
|
||||
if (-not $SystemPython -and -not (Test-Path (Join-Path $venvPath "Scripts\python.exe"))) {
|
||||
Write-Step "Creating virtual environment in .venv"
|
||||
$bootstrapPython = Get-PythonSpec -RepoRoot $repoRoot -UseSystemPython $true
|
||||
Invoke-Python -PythonSpec $bootstrapPython -Arguments @("-m", "venv", ".venv") -WorkingDirectory $repoRoot
|
||||
}
|
||||
|
||||
$python = Get-PythonSpec -RepoRoot $repoRoot -UseSystemPython $SystemPython.IsPresent
|
||||
Write-Step "Using Python: $($python.Exe) $($python.BaseArgs -join ' ')"
|
||||
|
||||
if ($UpgradePip) {
|
||||
Write-Step "Upgrading pip/setuptools/wheel"
|
||||
Invoke-Python -PythonSpec $python -Arguments @("-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel") -WorkingDirectory $repoRoot
|
||||
}
|
||||
|
||||
Write-Step "Installing dependencies"
|
||||
Invoke-Python -PythonSpec $python -Arguments @("-m", "pip", "install", "-r", "requirements.txt") -WorkingDirectory $repoRoot
|
||||
|
||||
Write-Step "Bootstrap completed"
|
||||
163
scripts/run-tests.ps1
Normal file
163
scripts/run-tests.ps1
Normal file
@@ -0,0 +1,163 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs TWBlue tests with minimal runtime setup.
|
||||
|
||||
.DESCRIPTION
|
||||
This script only executes pytest. It does not install dependencies,
|
||||
create virtual environments, or initialize submodules.
|
||||
|
||||
Use `./scripts/bootstrap-dev.ps1` first to prepare a development environment.
|
||||
|
||||
.PARAMETER PytestTargets
|
||||
One or more pytest target paths/files. Defaults to `src/test`.
|
||||
|
||||
.PARAMETER PytestArgs
|
||||
Additional pytest arguments. Defaults to `-q`.
|
||||
|
||||
.PARAMETER SystemPython
|
||||
Uses a detected system Python instead of `.venv`.
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/run-tests.ps1
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/run-tests.ps1 -PytestTargets src/test/sessions/blueski -PytestArgs "-q"
|
||||
|
||||
.EXAMPLE
|
||||
./scripts/run-tests.ps1 -SystemPython
|
||||
#>
|
||||
|
||||
param(
|
||||
[string[]]$PytestTargets = @("src/test"),
|
||||
[string[]]$PytestArgs = @("-q"),
|
||||
[switch]$SystemPython
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Resolve-RepoRoot {
|
||||
param([string]$ScriptDir)
|
||||
return (Resolve-Path (Join-Path $ScriptDir "..")).Path
|
||||
}
|
||||
|
||||
function Test-PythonCandidate {
|
||||
param(
|
||||
[string]$Exe,
|
||||
[string[]]$BaseArgs = @()
|
||||
)
|
||||
|
||||
try {
|
||||
& $Exe @BaseArgs -c "import sys; print(sys.version)" | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function New-PythonSpec {
|
||||
param(
|
||||
[string]$Exe,
|
||||
[string[]]$BaseArgs = @()
|
||||
)
|
||||
|
||||
return [pscustomobject]@{
|
||||
Exe = $Exe
|
||||
BaseArgs = $BaseArgs
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PythonSpec {
|
||||
param([string]$RepoRoot, [bool]$UseSystemPython)
|
||||
|
||||
if (-not $UseSystemPython) {
|
||||
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
|
||||
if (Test-Path $venvPython) {
|
||||
return (New-PythonSpec -Exe $venvPython)
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = @()
|
||||
|
||||
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
|
||||
if ($pythonCmd) {
|
||||
$candidates += ,(New-PythonSpec -Exe "python")
|
||||
}
|
||||
|
||||
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
|
||||
if ($pyCmd) {
|
||||
$candidates += ,(New-PythonSpec -Exe "py" -BaseArgs @("-3.10"))
|
||||
$candidates += ,(New-PythonSpec -Exe "py" -BaseArgs @("-3"))
|
||||
}
|
||||
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-PythonCandidate -Exe $candidate.Exe -BaseArgs $candidate.BaseArgs) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw "Could not find a usable Python. Install Python 3.10+ and try again."
|
||||
}
|
||||
|
||||
function Invoke-Python {
|
||||
param(
|
||||
[pscustomobject]$PythonSpec,
|
||||
[string[]]$Arguments,
|
||||
[string]$WorkingDirectory
|
||||
)
|
||||
|
||||
Push-Location $WorkingDirectory
|
||||
try {
|
||||
& $PythonSpec.Exe @($PythonSpec.BaseArgs + $Arguments)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Python command failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Resolve-RepoRoot -ScriptDir $scriptDir
|
||||
|
||||
Write-Step "Repository: $repoRoot"
|
||||
|
||||
$venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe"
|
||||
if (-not $SystemPython -and -not (Test-Path $venvPython)) {
|
||||
throw "No .venv Python found. Run ./scripts/bootstrap-dev.ps1 first, or pass -SystemPython."
|
||||
}
|
||||
|
||||
$python = Get-PythonSpec -RepoRoot $repoRoot -UseSystemPython $SystemPython.IsPresent
|
||||
Write-Step "Using Python: $($python.Exe) $($python.BaseArgs -join ' ')"
|
||||
|
||||
Write-Step "Detecting Python architecture"
|
||||
$archOutput = & $python.Exe @($python.BaseArgs + @("-c", "import struct; print('x64' if struct.calcsize('P')*8 == 64 else 'x86')"))
|
||||
if ($LASTEXITCODE -ne 0 -or -not $archOutput) {
|
||||
throw "Could not determine Python architecture."
|
||||
}
|
||||
$arch = ($archOutput | Select-Object -Last 1).Trim()
|
||||
if ($arch -ne "x86" -and $arch -ne "x64") {
|
||||
throw "Could not determine Python architecture (result: '$arch')."
|
||||
}
|
||||
|
||||
$vlcPath = Join-Path $repoRoot "windows-dependencies\$arch"
|
||||
if (-not (Test-Path $vlcPath)) {
|
||||
throw "Could not find '$vlcPath'. Run ./scripts/bootstrap-dev.ps1 first."
|
||||
}
|
||||
|
||||
$env:PYTHON_VLC_MODULE_PATH = $vlcPath
|
||||
if (-not ($env:PATH -split ';' | Where-Object { $_ -eq $vlcPath })) {
|
||||
$env:PATH = "$vlcPath;$env:PATH"
|
||||
}
|
||||
|
||||
Write-Step "PYTHON_VLC_MODULE_PATH=$($env:PYTHON_VLC_MODULE_PATH)"
|
||||
|
||||
Write-Step "Running tests"
|
||||
$pytestCommandArgs = @("-m", "pytest") + $PytestTargets + $PytestArgs
|
||||
Invoke-Python -PythonSpec $python -Arguments $pytestCommandArgs -WorkingDirectory $repoRoot
|
||||
54
src/blueski.defaults
Normal file
54
src/blueski.defaults
Normal file
@@ -0,0 +1,54 @@
|
||||
[blueski]
|
||||
handle = string(default="")
|
||||
app_password = string(default="")
|
||||
did = string(default="")
|
||||
session_string = string(default="")
|
||||
user_name = string(default="")
|
||||
|
||||
[general]
|
||||
relative_times = boolean(default=True)
|
||||
max_posts_per_call = integer(default=40)
|
||||
reverse_timelines = boolean(default=False)
|
||||
persist_size = integer(default=0)
|
||||
load_cache_in_memory = boolean(default=True)
|
||||
show_screen_names = boolean(default=False)
|
||||
hide_emojis = boolean(default=False)
|
||||
buffer_order = list(default=list('home', 'notifications'))
|
||||
disable_streaming = boolean(default=True)
|
||||
|
||||
[sound]
|
||||
volume = float(default=1.0)
|
||||
input_device = string(default="Default")
|
||||
output_device = string(default="Default")
|
||||
session_mute = boolean(default=False)
|
||||
current_soundpack = string(default="FreakyBlue")
|
||||
indicate_audio = boolean(default=True)
|
||||
indicate_img = boolean(default=True)
|
||||
|
||||
[other_buffers]
|
||||
timelines = list(default=list())
|
||||
followers_timelines = list(default=list())
|
||||
following_timelines = list(default=list())
|
||||
searches = list(default=list())
|
||||
muted_buffers = list(default=list())
|
||||
autoread_buffers = list(default=list(notifications))
|
||||
|
||||
[mysc]
|
||||
spelling_language = string(default="")
|
||||
save_followers_in_autocompletion_db = boolean(default=False)
|
||||
save_friends_in_autocompletion_db = boolean(default=False)
|
||||
ocr_language = string(default="")
|
||||
|
||||
[reporting]
|
||||
braille_reporting = boolean(default=True)
|
||||
speech_reporting = boolean(default=True)
|
||||
|
||||
[templates]
|
||||
post = string(default="$display_name, $reply_to$safe_text $date.")
|
||||
person = string(default="$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.")
|
||||
notification = string(default="$display_name $text, $date")
|
||||
|
||||
[filters]
|
||||
|
||||
[user-aliases]
|
||||
|
||||
3
src/controller/blueski/__init__.py
Normal file
3
src/controller/blueski/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .handler import Handler
|
||||
|
||||
__all__ = ["Handler"]
|
||||
973
src/controller/blueski/handler.py
Normal file
973
src/controller/blueski/handler.py
Normal file
@@ -0,0 +1,973 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import wx
|
||||
import asyncio
|
||||
import output
|
||||
from mysc.thread_utils import call_threaded
|
||||
import widgetUtils
|
||||
from extra.autocompletionUsers import completion
|
||||
from wxUI.dialogs.blueski.showUserProfile import ShowUserProfileDialog
|
||||
from typing import Any
|
||||
import languageHandler # Ensure _() injection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Handler:
|
||||
"""Handler for Bluesky integration: creates minimal buffers."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.menus = dict(
|
||||
# Application menu
|
||||
updateProfile="HIDE",
|
||||
menuitem_search=_("&Search"),
|
||||
lists="HIDE",
|
||||
manageAliases="HIDE",
|
||||
# Item menu
|
||||
compose=_("&Post"),
|
||||
reply=_("Re&ply"),
|
||||
share=_("&Boost"),
|
||||
fav=_("&Add to favorites"),
|
||||
unfav="HIDE",
|
||||
view=_("&Show post"),
|
||||
view_conversation=_("View conversa&tion"),
|
||||
ocr=_("&OCR"),
|
||||
delete=_("&Delete"),
|
||||
# User menu
|
||||
follow=_("&Actions..."),
|
||||
timeline=_("&View timeline..."),
|
||||
dm=_("Direct me&ssage"),
|
||||
addAlias="HIDE",
|
||||
addToList="HIDE",
|
||||
removeFromList="HIDE",
|
||||
details=_("S&how user profile"),
|
||||
favs="HIDE",
|
||||
# Buffer menu
|
||||
community_timeline="HIDE",
|
||||
filter="HIDE",
|
||||
manage_filters="HIDE",
|
||||
)
|
||||
self.item_menu = _("&Post")
|
||||
|
||||
def create_buffers(self, session, createAccounts=True, controller=None):
|
||||
name = session.get_name()
|
||||
if createAccounts:
|
||||
from pubsub import pub
|
||||
pub.sendMessage("core.create_account", name=name, session_id=session.session_id, logged=session.logged)
|
||||
|
||||
if not session.logged:
|
||||
logger.debug(f"Session {session.session_id} is not logged in, skipping timeline buffer creation.")
|
||||
return
|
||||
if name not in controller.accounts:
|
||||
controller.accounts.append(name)
|
||||
|
||||
root_position = controller.view.search(name, name)
|
||||
from pubsub import pub
|
||||
# Home (Following-only timeline - reverse-chronological)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="following_timeline",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Home"),
|
||||
parent_tab=root_position,
|
||||
start=True,
|
||||
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session, sound="tweet_received.ogg")
|
||||
)
|
||||
# Discover timeline
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="home_timeline",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Discover"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session, sound="tweet_received.ogg")
|
||||
)
|
||||
# Mentions (replies, mentions, quotes)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="MentionsBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Mentions"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="mentions", session=session, sound="mention_received.ogg")
|
||||
)
|
||||
# Chats
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="ConversationListBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Chats"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="direct_messages", session=session, sound="dm_received.ogg")
|
||||
)
|
||||
# Notifications
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="notifications",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Notifications"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="notifications", session=session, sound="new_event.ogg")
|
||||
)
|
||||
# Sent posts
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="SentBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Sent"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="sent", session=session, sound="tweet_received.ogg")
|
||||
)
|
||||
# Likes
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="likes",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Likes"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="likes", session=session, sound="favourite.ogg")
|
||||
)
|
||||
# Followers
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="FollowersBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Followers"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="followers", session=session, sound="update_followers.ogg")
|
||||
)
|
||||
# Followings (Users you follow)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="FollowingBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Following"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="following", session=session, sound="update_followers.ogg")
|
||||
)
|
||||
# Blocks
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="BlocksBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=_("Blocked Users"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="blocked", session=session)
|
||||
)
|
||||
|
||||
# Timelines container
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="EmptyBuffer",
|
||||
session_type="base",
|
||||
buffer_title=_("Timelines"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="timelines", account=name)
|
||||
)
|
||||
timelines_position = controller.view.search("timelines", name)
|
||||
|
||||
# Searches container (Bluesky supports search buffers)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="EmptyBuffer",
|
||||
session_type="base",
|
||||
buffer_title=_("Searches"),
|
||||
parent_tab=root_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name="searches", account=name)
|
||||
)
|
||||
searches_position = controller.view.search("searches", name)
|
||||
|
||||
# Saved searches
|
||||
try:
|
||||
searches = session.settings["other_buffers"].get("searches")
|
||||
if searches is None:
|
||||
searches = []
|
||||
if isinstance(searches, str):
|
||||
searches = [s for s in searches.split(",") if s]
|
||||
for query in searches:
|
||||
buffer_name = f"search_{query[:20]}"
|
||||
title = _("Search: {query}").format(query=query)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="SearchBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=searches_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name=buffer_name, session=session, query=query, sound="search_updated.ogg")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to restore Bluesky search buffers: %s", e)
|
||||
|
||||
# Saved user timelines
|
||||
try:
|
||||
timelines = session.settings["other_buffers"].get("timelines")
|
||||
if timelines is None:
|
||||
timelines = []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
for actor in timelines:
|
||||
handle = actor
|
||||
try:
|
||||
if isinstance(actor, str) and actor.startswith("did:"):
|
||||
profile = session.get_profile(actor)
|
||||
if profile:
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
handle = g(profile, "handle") or actor
|
||||
except Exception:
|
||||
handle = actor
|
||||
title = _("Timeline for {user}").format(user=handle)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="UserTimeline",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=timelines_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name=f"{handle}-timeline", session=session, actor=actor, handle=handle, sound="tweet_timeline.ogg")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to restore Bluesky timeline buffers: %s", e)
|
||||
|
||||
# Saved followers/following timelines
|
||||
try:
|
||||
followers = session.settings["other_buffers"].get("followers_timelines")
|
||||
if followers is None:
|
||||
followers = []
|
||||
if isinstance(followers, str):
|
||||
followers = [t for t in followers.split(",") if t]
|
||||
for actor in followers:
|
||||
handle = actor
|
||||
try:
|
||||
if isinstance(actor, str) and actor.startswith("did:"):
|
||||
profile = session.get_profile(actor)
|
||||
if profile:
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
handle = g(profile, "handle") or actor
|
||||
except Exception:
|
||||
handle = actor
|
||||
own_actor = session.db.get("user_id") or session.db.get("user_name")
|
||||
own_handle = session.db.get("user_name")
|
||||
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
|
||||
continue
|
||||
title = _("Followers for {user}").format(user=handle)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="FollowersBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=timelines_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name=f"{handle}-followers", session=session, actor=actor, handle=handle, sound="new_event.ogg")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to restore Bluesky followers buffers: %s", e)
|
||||
|
||||
try:
|
||||
following = session.settings["other_buffers"].get("following_timelines")
|
||||
if following is None:
|
||||
following = []
|
||||
if isinstance(following, str):
|
||||
following = [t for t in following.split(",") if t]
|
||||
for actor in following:
|
||||
handle = actor
|
||||
try:
|
||||
if isinstance(actor, str) and actor.startswith("did:"):
|
||||
profile = session.get_profile(actor)
|
||||
if profile:
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
handle = g(profile, "handle") or actor
|
||||
except Exception:
|
||||
handle = actor
|
||||
own_actor = session.db.get("user_id") or session.db.get("user_name")
|
||||
own_handle = session.db.get("user_name")
|
||||
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
|
||||
continue
|
||||
title = _("Following for {user}").format(user=handle)
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="FollowingBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=timelines_position,
|
||||
start=False,
|
||||
kwargs=dict(parent=controller.view.nb, name=f"{handle}-following", session=session, actor=actor, handle=handle, sound="new_event.ogg")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to restore Bluesky following buffers: %s", e)
|
||||
|
||||
# Start the background poller for real-time-like updates
|
||||
try:
|
||||
session.start_streaming()
|
||||
except Exception as e:
|
||||
logger.error("Failed to start Bluesky streaming for session %s: %s", name, e)
|
||||
|
||||
def start_buffer(self, controller, buffer):
|
||||
"""Start a newly created Bluesky buffer."""
|
||||
try:
|
||||
if hasattr(buffer, "start_stream"):
|
||||
buffer.start_stream(mandatory=True, play_sound=False)
|
||||
# Enable periodic auto-refresh to simulate real-time updates
|
||||
if hasattr(buffer, "enable_auto_refresh"):
|
||||
buffer.enable_auto_refresh()
|
||||
finally:
|
||||
# Ensure we won't try to start it again
|
||||
try:
|
||||
buffer.needs_init = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def account_settings(self, buffer, controller):
|
||||
"""Open a minimal account settings dialog for Bluesky."""
|
||||
try:
|
||||
current_mode = None
|
||||
try:
|
||||
current_mode = buffer.session.settings["general"].get("boost_mode")
|
||||
except Exception:
|
||||
current_mode = None
|
||||
ask_default = True if current_mode in (None, "ask") else False
|
||||
|
||||
from wxUI.dialogs.blueski.configuration import AccountSettingsDialog
|
||||
from .templateEditor import EditTemplate
|
||||
dlg = AccountSettingsDialog(controller.view, ask_before_boost=ask_default)
|
||||
try:
|
||||
if buffer.session.settings.get("templates") is None:
|
||||
buffer.session.settings["templates"] = {}
|
||||
templates_cfg = buffer.session.settings.get("templates", {})
|
||||
template_state = {
|
||||
"post": templates_cfg.get("post", "$display_name, $reply_to$safe_text $date."),
|
||||
"person": templates_cfg.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at."),
|
||||
"notification": templates_cfg.get("notification", "$display_name $text, $date"),
|
||||
}
|
||||
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
|
||||
|
||||
def edit_post_template(*args, **kwargs):
|
||||
control = EditTemplate(template=template_state["post"], type="post")
|
||||
result = control.run_dialog()
|
||||
if result:
|
||||
buffer.session.settings["templates"]["post"] = result
|
||||
buffer.session.settings.write()
|
||||
template_state["post"] = result
|
||||
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
|
||||
|
||||
def edit_person_template(*args, **kwargs):
|
||||
control = EditTemplate(template=template_state["person"], type="person")
|
||||
result = control.run_dialog()
|
||||
if result:
|
||||
buffer.session.settings["templates"]["person"] = result
|
||||
buffer.session.settings.write()
|
||||
template_state["person"] = result
|
||||
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
|
||||
|
||||
def edit_notification_template(*args, **kwargs):
|
||||
control = EditTemplate(template=template_state["notification"], type="notification")
|
||||
result = control.run_dialog()
|
||||
if result:
|
||||
buffer.session.settings["templates"]["notification"] = result
|
||||
buffer.session.settings.write()
|
||||
template_state["notification"] = result
|
||||
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
|
||||
|
||||
widgetUtils.connect_event(dlg.template_post, widgetUtils.BUTTON_PRESSED, edit_post_template)
|
||||
widgetUtils.connect_event(dlg.template_person, widgetUtils.BUTTON_PRESSED, edit_person_template)
|
||||
widgetUtils.connect_event(dlg.template_notification, widgetUtils.BUTTON_PRESSED, edit_notification_template)
|
||||
except Exception as e:
|
||||
logger.error("Failed to init Bluesky templates editor: %s", e)
|
||||
resp = dlg.ShowModal()
|
||||
if resp == wx.ID_OK:
|
||||
vals = dlg.get_values()
|
||||
boost_mode = "ask" if vals.get("ask_before_boost") else "direct"
|
||||
try:
|
||||
buffer.session.settings["general"]["boost_mode"] = boost_mode
|
||||
buffer.session.settings.write()
|
||||
except Exception as e:
|
||||
logger.error("Failed to persist Bluesky boost_mode setting: %s", e)
|
||||
dlg.Destroy()
|
||||
except Exception as e:
|
||||
logger.error("Error opening Bluesky account settings dialog: %s", e)
|
||||
|
||||
def user_details(self, buffer):
|
||||
"""Show user profile dialog for the selected user/post."""
|
||||
session = getattr(buffer, "session", None)
|
||||
if not session:
|
||||
output.speak(_("No active session to view user details."), True)
|
||||
return
|
||||
|
||||
item = buffer.get_item() if hasattr(buffer, "get_item") else None
|
||||
if not item:
|
||||
output.speak(_("No user selected or identified to view details."), True)
|
||||
return
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
user_ident = None
|
||||
|
||||
# If we're in a user list, the item itself is the user profile dict/model.
|
||||
if g(item, "did") or g(item, "handle"):
|
||||
user_ident = g(item, "did") or g(item, "handle")
|
||||
else:
|
||||
author = g(item, "author")
|
||||
if not author:
|
||||
post = g(item, "post") or g(item, "record")
|
||||
author = g(post, "author") if post else None
|
||||
if author:
|
||||
user_ident = g(author, "did") or g(author, "handle")
|
||||
|
||||
if not user_ident:
|
||||
output.speak(_("No user selected or identified to view details."), True)
|
||||
return
|
||||
|
||||
parent = getattr(buffer, "buffer", None) or wx.GetApp().GetTopWindow()
|
||||
dialog = ShowUserProfileDialog(parent, session, user_ident)
|
||||
dialog.ShowModal()
|
||||
dialog.Destroy()
|
||||
|
||||
async def handle_action(self, action_name: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
logger.debug("handle_action stub: %s %s %s", action_name, user_id, payload)
|
||||
return None
|
||||
|
||||
async def handle_message_command(self, command: str, user_id: str, message_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
logger.debug("handle_message_command stub: %s %s %s %s", command, user_id, message_id, payload)
|
||||
return None
|
||||
|
||||
async def handle_user_command(self, command: str, user_id: str, target_user_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
logger.debug("handle_user_command stub: %s %s %s %s", command, user_id, target_user_id, payload)
|
||||
return None
|
||||
|
||||
def add_to_favourites(self, buffer):
|
||||
"""Standard action for Alt+Win+F"""
|
||||
if hasattr(buffer, "add_to_favorites"):
|
||||
buffer.add_to_favorites()
|
||||
elif hasattr(buffer, "on_like"):
|
||||
# Fallback
|
||||
buffer.on_like(None)
|
||||
|
||||
def remove_from_favourites(self, buffer):
|
||||
"""Standard action for Alt+Shift+Win+F"""
|
||||
if hasattr(buffer, "remove_from_favorites"):
|
||||
buffer.remove_from_favorites()
|
||||
elif hasattr(buffer, "on_like"):
|
||||
buffer.on_like(None)
|
||||
|
||||
def follow(self, buffer):
|
||||
"""Standard action for Ctrl+Win+S - Opens user actions dialog"""
|
||||
if not hasattr(buffer, "get_item"):
|
||||
return
|
||||
session = getattr(buffer, "session", None)
|
||||
if not session:
|
||||
output.speak(_("No active session."), True)
|
||||
return
|
||||
|
||||
item = buffer.get_item()
|
||||
if not item:
|
||||
return
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
users = []
|
||||
buffer_type = getattr(buffer, "type", "")
|
||||
|
||||
if buffer_type in ("user", "post_user_list"):
|
||||
# User buffer - item is a user object
|
||||
handle = g(item, "handle")
|
||||
if handle:
|
||||
users = [handle]
|
||||
elif buffer_type == "notifications":
|
||||
# Notification buffer
|
||||
author = g(item, "author")
|
||||
if author:
|
||||
handle = g(author, "handle")
|
||||
if handle:
|
||||
users.append(handle)
|
||||
# Also check for post author in the notification subject
|
||||
record = g(item, "record")
|
||||
if record:
|
||||
subject = g(record, "subject")
|
||||
if subject:
|
||||
subject_author = g(subject, "author")
|
||||
if subject_author:
|
||||
subject_handle = g(subject_author, "handle")
|
||||
if subject_handle and subject_handle not in users:
|
||||
users.append(subject_handle)
|
||||
else:
|
||||
# Post buffer - extract author and mentioned users
|
||||
# Get the actual post (could be nested in "post" key)
|
||||
actual_post = g(item, "post", item)
|
||||
record = g(actual_post, "record") or {}
|
||||
|
||||
# Extract mentions from facets
|
||||
facets = g(record, "facets") or []
|
||||
for facet in facets:
|
||||
features = g(facet, "features") or []
|
||||
for feature in features:
|
||||
ftype = g(feature, "$type") or g(feature, "py_type") or ""
|
||||
if "mention" in ftype.lower():
|
||||
mention_did = g(feature, "did")
|
||||
# We'd need to resolve DID to handle, but for simplicity just skip
|
||||
# The main author will be added below
|
||||
|
||||
# Get the post author
|
||||
author = g(actual_post, "author") or g(item, "author")
|
||||
if author:
|
||||
handle = g(author, "handle")
|
||||
if handle and handle not in users:
|
||||
users.insert(0, handle)
|
||||
|
||||
# Ensure we have at least the author if no users found
|
||||
if not users:
|
||||
author = g(item, "author") or g(g(item, "post"), "author")
|
||||
if author:
|
||||
handle = g(author, "handle")
|
||||
if handle:
|
||||
users = [handle]
|
||||
|
||||
from controller.blueski import userActions as user_actions_controller
|
||||
user_actions_controller.userActions(session, users)
|
||||
|
||||
def open_conversation(self, controller, buffer):
|
||||
"""Standard action for Control+Win+C"""
|
||||
# If this is a chat conversation list, open the selected chat
|
||||
if buffer.type == "chat" and hasattr(buffer, "view_chat"):
|
||||
buffer.view_chat()
|
||||
return
|
||||
item = buffer.get_item()
|
||||
if not item:
|
||||
return
|
||||
|
||||
uri = None
|
||||
if hasattr(buffer, "get_selected_item_id"):
|
||||
uri = buffer.get_selected_item_id()
|
||||
if not uri:
|
||||
uri = getattr(item, "uri", None) or (item.get("post", {}).get("uri") if isinstance(item, dict) else None)
|
||||
if not uri: return
|
||||
|
||||
# Buffer Title
|
||||
handle = None
|
||||
display_name = None
|
||||
if hasattr(buffer, "get_selected_item_author_details"):
|
||||
details = buffer.get_selected_item_author_details()
|
||||
if details:
|
||||
handle = details.get("handle")
|
||||
if not handle:
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
author = g(item, "author") or g(g(item, "post"), "author")
|
||||
if author:
|
||||
handle = g(author, "handle")
|
||||
display_name = g(author, "displayName") or g(author, "display_name")
|
||||
label = handle or display_name or _("Unknown")
|
||||
title = _("Conversation with {0}").format(label)
|
||||
|
||||
from pubsub import pub
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="conversation",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=controller.view.search(buffer.session.get_name(), buffer.session.get_name()) if hasattr(buffer.session, "get_name") else None,
|
||||
start=True,
|
||||
kwargs=dict(parent=controller.view.nb, name=title, session=buffer.session, uri=uri, sound="search_updated.ogg")
|
||||
)
|
||||
|
||||
def open_timeline(self, controller, buffer, default="posts"):
|
||||
if not hasattr(buffer, "get_item"):
|
||||
return
|
||||
item = buffer.get_item()
|
||||
if not item:
|
||||
output.speak(_("No user selected."), True)
|
||||
return
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
users = []
|
||||
handle = None
|
||||
if hasattr(buffer, "get_selected_item_author_details"):
|
||||
details = buffer.get_selected_item_author_details()
|
||||
if details:
|
||||
handle = details.get("handle") or details.get("did")
|
||||
if not handle:
|
||||
if g(item, "handle") or g(item, "did"):
|
||||
handle = g(item, "handle") or g(item, "did")
|
||||
else:
|
||||
author = g(item, "author") or g(g(item, "post"), "author")
|
||||
if author:
|
||||
handle = g(author, "handle") or g(author, "did")
|
||||
|
||||
if not handle:
|
||||
output.speak(_("No user selected."), True)
|
||||
return
|
||||
users.append(handle)
|
||||
|
||||
# Add mentioned users if available (facets)
|
||||
record = g(g(item, "post"), "record") or g(item, "record")
|
||||
facets = g(record, "facets", []) if record else []
|
||||
handle_cache = {}
|
||||
|
||||
def resolve_handle(did):
|
||||
if did in handle_cache:
|
||||
return handle_cache[did]
|
||||
try:
|
||||
profile = buffer.session.get_profile(did)
|
||||
if profile:
|
||||
h = g(profile, "handle")
|
||||
if h:
|
||||
handle_cache[did] = h
|
||||
return h
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
self_did = buffer.session.db.get("user_id")
|
||||
for facet in facets or []:
|
||||
features = g(facet, "features", []) or []
|
||||
for feat in features:
|
||||
ftype = g(feat, "$type") or g(feat, "py_type") or ""
|
||||
if "facet#mention" in ftype:
|
||||
did = g(feat, "did")
|
||||
if not did or did == self_did:
|
||||
continue
|
||||
h = resolve_handle(did)
|
||||
if h and h not in users:
|
||||
users.append(h)
|
||||
|
||||
from wxUI.dialogs.mastodon import userTimeline as userTimelineDialog
|
||||
dlg = userTimelineDialog.UserTimeline(users=users, default=default)
|
||||
try:
|
||||
widgetUtils.connect_event(
|
||||
dlg.autocompletion,
|
||||
widgetUtils.BUTTON_PRESSED,
|
||||
lambda *args, **kwargs: completion.autocompletionUsers(dlg, buffer.session.session_id).show_menu("free"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if hasattr(dlg, "autocompletion"):
|
||||
dlg.autocompletion.Enable(True)
|
||||
except Exception:
|
||||
pass
|
||||
if dlg.ShowModal() != wx.ID_OK:
|
||||
dlg.Destroy()
|
||||
return
|
||||
|
||||
action = dlg.get_action()
|
||||
user = dlg.get_user().strip() or handle
|
||||
dlg.Destroy()
|
||||
|
||||
if user.startswith("@"):
|
||||
user = user[1:]
|
||||
try:
|
||||
profile = buffer.session.get_profile(user)
|
||||
if profile is None:
|
||||
output.speak(_("User not found."), True)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
user_payload = {"handle": user}
|
||||
if action == "posts":
|
||||
result = self.open_user_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
|
||||
elif action == "followers":
|
||||
result = self.open_followers_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
|
||||
elif action == "following":
|
||||
result = self.open_following_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
|
||||
else:
|
||||
return
|
||||
|
||||
if asyncio.iscoroutine(result):
|
||||
call_threaded(asyncio.run, result)
|
||||
|
||||
def open_followers_timeline(self, main_controller, session, user_payload=None):
|
||||
actor, handle = self._resolve_actor(session, user_payload)
|
||||
if not actor:
|
||||
output.speak(_("No user selected."), True)
|
||||
return
|
||||
self._open_user_list(main_controller, session, actor, handle, list_type="followers")
|
||||
|
||||
def open_following_timeline(self, main_controller, session, user_payload=None):
|
||||
actor, handle = self._resolve_actor(session, user_payload)
|
||||
if not actor:
|
||||
output.speak(_("No user selected."), True)
|
||||
return
|
||||
self._open_user_list(main_controller, session, actor, handle, list_type="following")
|
||||
|
||||
def open_user_timeline(self, main_controller, session, user_payload=None):
|
||||
"""Open posts timeline for a user (Alt+Win+I)."""
|
||||
actor, handle = self._resolve_actor(session, user_payload)
|
||||
if not actor:
|
||||
output.speak(_("No user selected."), True)
|
||||
return
|
||||
|
||||
actor, handle = self._resolve_actor(session, {"did": actor, "handle": handle})
|
||||
if not handle:
|
||||
handle = actor
|
||||
|
||||
account_name = session.get_name()
|
||||
list_name = f"{handle}-timeline"
|
||||
if main_controller.search_buffer(list_name, account_name):
|
||||
index = main_controller.view.search(list_name, account_name)
|
||||
if index is not None:
|
||||
main_controller.view.change_buffer(index)
|
||||
return
|
||||
|
||||
title = _("Timeline for {user}").format(user=handle)
|
||||
from pubsub import pub
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="UserTimeline",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=main_controller.view.search("timelines", account_name),
|
||||
start=True,
|
||||
kwargs=dict(parent=main_controller.view.nb, name=list_name, session=session, actor=actor, handle=handle, sound="tweet_timeline.ogg")
|
||||
)
|
||||
try:
|
||||
timelines = session.settings["other_buffers"].get("timelines")
|
||||
if timelines is None:
|
||||
timelines = []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
key = actor or handle
|
||||
if key in timelines:
|
||||
from wxUI import commonMessageDialogs
|
||||
commonMessageDialogs.timeline_exist()
|
||||
return
|
||||
if key:
|
||||
timelines.append(key)
|
||||
session.settings["other_buffers"]["timelines"] = timelines
|
||||
session.settings.write()
|
||||
except Exception as e:
|
||||
logger.error("Failed to persist Bluesky timeline buffer: %s", e)
|
||||
|
||||
def _resolve_actor(self, session, user_payload):
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
actor = None
|
||||
handle = None
|
||||
if user_payload:
|
||||
actor = g(user_payload, "did") or g(user_payload, "handle")
|
||||
handle = g(user_payload, "handle") or g(user_payload, "did")
|
||||
if isinstance(actor, str):
|
||||
actor = actor.strip()
|
||||
if actor.startswith("@"):
|
||||
actor = actor[1:]
|
||||
if isinstance(handle, str):
|
||||
handle = handle.strip()
|
||||
if handle.startswith("@"):
|
||||
handle = handle[1:]
|
||||
# Resolve handle -> DID when possible, and keep handle for titles
|
||||
try:
|
||||
if isinstance(actor, str) and not actor.startswith("did:"):
|
||||
profile = session.get_profile(actor)
|
||||
if profile:
|
||||
did = g(profile, "did")
|
||||
if did:
|
||||
actor = did
|
||||
if not handle:
|
||||
handle = g(profile, "handle")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not actor:
|
||||
actor = session.db.get("user_id") or session.db.get("user_name")
|
||||
handle = session.db.get("user_name") or actor
|
||||
|
||||
if not handle and isinstance(actor, str):
|
||||
try:
|
||||
if actor.startswith("did:"):
|
||||
profile = session.get_profile(actor)
|
||||
if profile:
|
||||
handle = g(profile, "handle")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return actor, handle
|
||||
|
||||
def _open_user_list(self, main_controller, session, actor, handle, list_type):
|
||||
account_name = session.get_name()
|
||||
if not handle:
|
||||
handle = actor
|
||||
own_actor = session.db.get("user_id") or session.db.get("user_name")
|
||||
own_handle = session.db.get("user_name")
|
||||
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
|
||||
name = "followers" if list_type == "followers" else "following"
|
||||
try:
|
||||
stored = session.settings["other_buffers"].get("followers_timelines" if list_type == "followers" else "following_timelines") or []
|
||||
if isinstance(stored, str):
|
||||
stored = [t for t in stored.split(",") if t]
|
||||
if actor in stored:
|
||||
stored.remove(actor)
|
||||
session.settings["other_buffers"]["followers_timelines" if list_type == "followers" else "following_timelines"] = stored
|
||||
session.settings.write()
|
||||
except Exception:
|
||||
pass
|
||||
index = main_controller.view.search(name, account_name)
|
||||
if index is not None:
|
||||
main_controller.view.change_buffer(index)
|
||||
return
|
||||
list_name = f"{handle}-{list_type}"
|
||||
if main_controller.search_buffer(list_name, account_name):
|
||||
index = main_controller.view.search(list_name, account_name)
|
||||
if index is not None:
|
||||
main_controller.view.change_buffer(index)
|
||||
return
|
||||
|
||||
settings_key = "followers_timelines" if list_type == "followers" else "following_timelines"
|
||||
try:
|
||||
stored = session.settings["other_buffers"].get(settings_key)
|
||||
if stored is None:
|
||||
stored = []
|
||||
if isinstance(stored, str):
|
||||
stored = [t for t in stored.split(",") if t]
|
||||
key = actor or handle
|
||||
if key in stored:
|
||||
from wxUI import commonMessageDialogs
|
||||
commonMessageDialogs.timeline_exist()
|
||||
return
|
||||
except Exception:
|
||||
stored = None
|
||||
|
||||
title = _("Followers for {user}").format(user=handle) if list_type == "followers" else _("Following for {user}").format(user=handle)
|
||||
from pubsub import pub
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="FollowersBuffer" if list_type == "followers" else "FollowingBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=main_controller.view.search("timelines", account_name),
|
||||
start=True,
|
||||
kwargs=dict(parent=main_controller.view.nb, name=list_name, session=session, actor=actor, handle=handle, sound="new_event.ogg")
|
||||
)
|
||||
try:
|
||||
if stored is None:
|
||||
stored = session.settings["other_buffers"].get(settings_key) or []
|
||||
if isinstance(stored, str):
|
||||
stored = [t for t in stored.split(",") if t]
|
||||
key = actor or handle
|
||||
if key:
|
||||
stored.append(key)
|
||||
session.settings["other_buffers"][settings_key] = stored
|
||||
session.settings.write()
|
||||
except Exception as e:
|
||||
logger.error("Failed to persist Bluesky %s buffer: %s", list_type, e)
|
||||
|
||||
def delete(self, buffer, controller):
|
||||
"""Standard action for delete key / menu item"""
|
||||
item = buffer.get_item()
|
||||
if not item: return
|
||||
|
||||
uri = getattr(item, "uri", None) or (item.get("post", {}).get("uri") if isinstance(item, dict) else None)
|
||||
if not uri: return
|
||||
|
||||
import wx
|
||||
if wx.MessageBox(_("Are you sure you want to delete this post?"), _("Delete post"), wx.YES_NO | wx.ICON_QUESTION) == wx.YES:
|
||||
if buffer.session.delete_post(uri):
|
||||
import output
|
||||
output.speak(_("Post deleted."))
|
||||
# Refresh buffer
|
||||
if hasattr(buffer, "start_stream"):
|
||||
buffer.start_stream(mandatory=True, play_sound=False)
|
||||
else:
|
||||
import output
|
||||
output.speak(_("Failed to delete post."))
|
||||
|
||||
def search(self, controller, session, value=""):
|
||||
"""Open search dialog and create search buffer for results."""
|
||||
dlg = wx.TextEntryDialog(
|
||||
controller.view,
|
||||
_("Enter search term:"),
|
||||
_("Search Bluesky"),
|
||||
value
|
||||
)
|
||||
if dlg.ShowModal() != wx.ID_OK:
|
||||
dlg.Destroy()
|
||||
return
|
||||
|
||||
query = dlg.GetValue().strip()
|
||||
dlg.Destroy()
|
||||
|
||||
if not query:
|
||||
return
|
||||
|
||||
# Create unique buffer name for this search
|
||||
buffer_name = f"search_{query[:20]}"
|
||||
account_name = session.get_name()
|
||||
|
||||
# Check if buffer already exists
|
||||
existing = controller.search_buffer(buffer_name, account_name)
|
||||
if existing:
|
||||
# Navigate to existing buffer
|
||||
index = controller.view.search(buffer_name, account_name)
|
||||
if index is not None:
|
||||
controller.view.change_buffer(index)
|
||||
# Refresh search
|
||||
existing.search_query = query
|
||||
existing.start_stream(mandatory=True, play_sound=False)
|
||||
return
|
||||
|
||||
# Create new search buffer
|
||||
title = _("Search: {query}").format(query=query)
|
||||
from pubsub import pub
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="SearchBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=controller.view.search("searches", account_name),
|
||||
start=True,
|
||||
kwargs=dict(
|
||||
parent=controller.view.nb,
|
||||
name=buffer_name,
|
||||
session=session,
|
||||
query=query,
|
||||
sound="search_updated.ogg"
|
||||
)
|
||||
)
|
||||
|
||||
# Save search to settings for persistence
|
||||
try:
|
||||
searches = session.settings["other_buffers"].get("searches")
|
||||
if searches is None:
|
||||
searches = []
|
||||
if isinstance(searches, str):
|
||||
searches = [s for s in searches.split(",") if s]
|
||||
if query not in searches:
|
||||
searches.append(query)
|
||||
session.settings["other_buffers"]["searches"] = searches
|
||||
session.settings.write()
|
||||
except Exception as e:
|
||||
logger.error("Failed to save search to settings: %s", e)
|
||||
433
src/controller/blueski/messages.py
Normal file
433
src/controller/blueski/messages.py
Normal file
@@ -0,0 +1,433 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import arrow
|
||||
import languageHandler
|
||||
import output
|
||||
import widgetUtils
|
||||
from controller import messages as base_messages
|
||||
from sessions.blueski import utils as bluesky_utils
|
||||
from wxUI.dialogs.blueski import postDialogs
|
||||
from extra.autocompletionUsers import completion
|
||||
|
||||
# Translation function is provided globally by TWBlue's language handler (_)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# This file would typically contain functions to generate complex message bodies or
|
||||
# interactive components for Blueski, similar to how it might be done for Mastodon.
|
||||
# Since Blueski's interactive features (beyond basic posts) are still evolving
|
||||
# or client-dependent (like polls), this might be less complex initially.
|
||||
|
||||
# Example: If Blueski develops a standard for "cards" or interactive messages,
|
||||
# functions to create those would go here. For now, we can imagine placeholders.
|
||||
|
||||
def format_welcome_message(session: Any) -> dict[str, Any]:
|
||||
"""
|
||||
Generates a welcome message for a new Blueski session.
|
||||
This is just a placeholder and example.
|
||||
"""
|
||||
# user_profile = session.util.get_own_profile_info() # Assuming this method exists and is async or cached
|
||||
# handle = user_profile.get("handle", _("your Blueski account")) if user_profile else _("your Blueski account")
|
||||
# Expect session to expose username via db/settings
|
||||
handle = (getattr(session, "db", {}).get("user_name")
|
||||
or getattr(getattr(session, "settings", {}), "get", lambda *_: {})("blueski").get("handle")
|
||||
or _("your Bluesky account"))
|
||||
|
||||
|
||||
return {
|
||||
"text": _("Welcome to Approve for Blueski! Your account {handle} is connected.").format(handle=handle),
|
||||
# "blocks": [ # If Blueski supports a block kit like Slack or Discord
|
||||
# {
|
||||
# "type": "section",
|
||||
# "text": {
|
||||
# "type": "mrkdwn", # Or Blueski's equivalent
|
||||
# "text": _("Welcome to Approve for Blueski! Your account *{handle}* is connected.").format(handle=handle)
|
||||
# }
|
||||
# },
|
||||
# {
|
||||
# "type": "actions",
|
||||
# "elements": [
|
||||
# {
|
||||
# "type": "button",
|
||||
# "text": {"type": "plain_text", "text": _("Post your first Skeet")},
|
||||
# "action_id": "blueski_compose_new_post" # Example action ID
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ]
|
||||
}
|
||||
|
||||
def format_error_message(error_description: str, details: str | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Generates a standardized error message.
|
||||
"""
|
||||
message = {"text": f":warning: Error: {error_description}"} # Basic text message
|
||||
# if details:
|
||||
# message["blocks"] = [
|
||||
# {
|
||||
# "type": "section",
|
||||
# "text": {"type": "mrkdwn", "text": f":warning: *Error:* {error_description}\n{details}"}
|
||||
# }
|
||||
# ]
|
||||
return message
|
||||
|
||||
# More functions could be added here as Blueski's capabilities become clearer
|
||||
# or as specific formatting needs for Approve arise. For example:
|
||||
# - Formatting a post for display with all its embeds and cards.
|
||||
# - Generating help messages specific to Blueski features.
|
||||
# - Creating interactive messages for polls (if supported via some convention).
|
||||
|
||||
# Example of adapting a function that might exist in mastodon_messages:
|
||||
# def build_post_summary_message(session: BlueskiSession, post_uri: str, post_content: dict) -> dict[str, Any]:
|
||||
# """
|
||||
# Builds a summary message for an Blueski post.
|
||||
# """
|
||||
# author_handle = post_content.get("author", {}).get("handle", "Unknown user")
|
||||
# text_preview = post_content.get("text", "")[:100] # First 100 chars of text
|
||||
# # url = session.get_message_url(post_uri) # Assuming this method exists
|
||||
# url = f"https://bsky.app/profile/{author_handle}/post/{post_uri.split('/')[-1]}" # Construct a URL
|
||||
|
||||
# return {
|
||||
# "text": _("Post by {author_handle}: {text_preview}... ({url})").format(
|
||||
# author_handle=author_handle, text_preview=text_preview, url=url
|
||||
# ),
|
||||
# # Potentially with "blocks" for richer formatting if the platform supports it
|
||||
# }
|
||||
|
||||
logger.info("Blueski messages module loaded (placeholders).")
|
||||
|
||||
|
||||
class post(base_messages.basicMessage):
|
||||
# Bluesky character limit
|
||||
MAX_CHARS = 300
|
||||
|
||||
def __init__(self, session: Any, title: str, caption: str, text: str = "", *args, **kwargs):
|
||||
self.session = session
|
||||
self.title = title
|
||||
langs = session.supported_languages
|
||||
display_langs = [l.name for l in langs]
|
||||
self.message = postDialogs.Post(caption=caption, text=text, languages=display_langs, *args, **kwargs)
|
||||
try:
|
||||
self.message.SetTitle(title)
|
||||
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
||||
except Exception:
|
||||
pass
|
||||
# Set default language
|
||||
self.set_language(session.default_language)
|
||||
# Connect events for text processing and buttons
|
||||
widgetUtils.connect_event(self.message.text, widgetUtils.ENTERED_TEXT, self.text_processor)
|
||||
widgetUtils.connect_event(self.message.spoiler, widgetUtils.ENTERED_TEXT, self.text_processor)
|
||||
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||
widgetUtils.connect_event(self.message.translate, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||
widgetUtils.connect_event(self.message.autocomplete_users, widgetUtils.BUTTON_PRESSED, self.autocomplete_users)
|
||||
# Initial text processing to show character count
|
||||
self.text_processor()
|
||||
|
||||
def set_language(self, language_code=None):
|
||||
"""Set the language selection based on language code."""
|
||||
if language_code is None:
|
||||
language_code = languageHandler.curLang[:2]
|
||||
for idx, lang in enumerate(self.session.supported_languages):
|
||||
if lang.code == language_code:
|
||||
self.message.language.SetSelection(idx)
|
||||
return
|
||||
# If not found, select first item (Not set)
|
||||
self.message.language.SetSelection(0)
|
||||
|
||||
def get_language(self):
|
||||
"""Get the selected language code."""
|
||||
langs = self.session.supported_languages
|
||||
idx = self.message.language.GetSelection()
|
||||
if idx >= 0 and idx < len(langs):
|
||||
return langs[idx].code
|
||||
return None
|
||||
|
||||
def get_data(self):
|
||||
text, files, cw_text, lang_index = self.message.get_payload()
|
||||
langs = self.session.supported_languages
|
||||
lang_code = None
|
||||
if lang_index >= 0 and lang_index < len(langs):
|
||||
lang_code = langs[lang_index].code
|
||||
return text, files, cw_text, ([lang_code] if lang_code else [])
|
||||
|
||||
def text_processor(self, *args, **kwargs):
|
||||
text = self.message.text.GetValue()
|
||||
cw = self.message.spoiler.GetValue() if self.message.spoiler.IsEnabled() else ""
|
||||
char_count = len(text) + len(cw)
|
||||
self.message.SetTitle(_("%s - %s of %d characters") % (self.title, char_count, self.MAX_CHARS))
|
||||
if char_count > self.MAX_CHARS:
|
||||
self.session.sound.play("max_length.ogg")
|
||||
|
||||
def autocomplete_users(self, *args, **kwargs):
|
||||
c = completion.autocompletionUsers(self.message, self.session.session_id)
|
||||
c.show_menu()
|
||||
|
||||
|
||||
def _g(obj: Any, key: str, default: Any = None) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
|
||||
def has_post_data(item: Any) -> bool:
|
||||
post = _g(item, "post")
|
||||
record = _g(post, "record") if post is not None else None
|
||||
if record is None:
|
||||
record = _g(item, "record")
|
||||
return record is not None or post is not None
|
||||
|
||||
|
||||
def _extract_labels(obj: Any) -> list[dict[str, Any]]:
|
||||
labels = _g(obj, "labels", None)
|
||||
if labels is None:
|
||||
return []
|
||||
if isinstance(labels, dict):
|
||||
labels = labels.get("values", [])
|
||||
if isinstance(labels, list):
|
||||
return labels
|
||||
return []
|
||||
|
||||
|
||||
def _extract_cw_text(post: Any, record: Any) -> str:
|
||||
labels = _extract_labels(post) + _extract_labels(record)
|
||||
for label in labels:
|
||||
val = _g(label, "val", "")
|
||||
if val == "warn":
|
||||
return _("Sensitive Content")
|
||||
if isinstance(val, str) and val.startswith("warn:"):
|
||||
return val.split("warn:", 1)[-1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_image_descriptions(post: Any, record: Any) -> str:
|
||||
def _collect_images(embed: Any) -> list[Any]:
|
||||
if not embed:
|
||||
return []
|
||||
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
|
||||
if "recordWithMedia" in etype:
|
||||
media = _g(embed, "media")
|
||||
mtype = _g(media, "$type") or _g(media, "py_type") or ""
|
||||
if "images" in mtype:
|
||||
return list(_g(media, "images", []) or [])
|
||||
return []
|
||||
if "images" in etype:
|
||||
return list(_g(embed, "images", []) or [])
|
||||
return []
|
||||
|
||||
images = []
|
||||
images.extend(_collect_images(_g(post, "embed")))
|
||||
if not images:
|
||||
images.extend(_collect_images(_g(record, "embed")))
|
||||
|
||||
descriptions = []
|
||||
for idx, img in enumerate(images, start=1):
|
||||
alt = _g(img, "alt", "") or ""
|
||||
if alt:
|
||||
descriptions.append(_("Image {index}: {alt}").format(index=idx, alt=alt))
|
||||
return "\n".join(descriptions)
|
||||
|
||||
|
||||
def _format_date(raw_date: str | None, offset_hours: int = 0) -> str:
|
||||
if not raw_date:
|
||||
return ""
|
||||
try:
|
||||
ts = arrow.get(raw_date)
|
||||
if offset_hours:
|
||||
ts = ts.shift(hours=offset_hours)
|
||||
return ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
return str(raw_date)[:16].replace("T", " ")
|
||||
|
||||
|
||||
def _extract_post_view_data(session: Any, item: Any) -> dict[str, Any] | None:
|
||||
post = _g(item, "post", item)
|
||||
record = _g(post, "record") or _g(item, "record")
|
||||
if record is None:
|
||||
return None
|
||||
|
||||
author = _g(post, "author") or _g(item, "author") or {}
|
||||
handle = _g(author, "handle", "")
|
||||
display_name = _g(author, "displayName") or _g(author, "display_name") or handle or _("Unknown")
|
||||
if handle and display_name != handle:
|
||||
author_label = f"{display_name} (@{handle})"
|
||||
elif handle:
|
||||
author_label = f"@{handle}"
|
||||
else:
|
||||
author_label = display_name
|
||||
|
||||
text = _g(record, "text", "") or ""
|
||||
reply_to_handle = bluesky_utils.extract_reply_to_handle(item)
|
||||
if reply_to_handle:
|
||||
if text:
|
||||
text = _("Replying to @{handle}: {text}").format(handle=reply_to_handle, text=text)
|
||||
else:
|
||||
text = _("Replying to @{handle}").format(handle=reply_to_handle)
|
||||
|
||||
quote_info = bluesky_utils.extract_quoted_post_info(item)
|
||||
if quote_info:
|
||||
if quote_info["kind"] == "not_found":
|
||||
text += f" [{_('Quoted post not found')}]"
|
||||
elif quote_info["kind"] == "blocked":
|
||||
text += f" [{_('Quoted post blocked')}]"
|
||||
elif quote_info["kind"] == "feed":
|
||||
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
|
||||
else:
|
||||
q_handle = quote_info.get("handle", "unknown")
|
||||
q_text = quote_info.get("text", "")
|
||||
if q_text:
|
||||
text += " " + _("Quoting @{handle}: {text}").format(handle=q_handle, text=q_text)
|
||||
else:
|
||||
text += " " + _("Quoting @{handle}").format(handle=q_handle)
|
||||
|
||||
cw_text = _extract_cw_text(post, record)
|
||||
if cw_text:
|
||||
text = f"CW: {cw_text}\n\n{text}" if text else f"CW: {cw_text}"
|
||||
|
||||
created_at = _g(record, "createdAt") or _g(record, "created_at")
|
||||
indexed_at = _g(post, "indexedAt") or _g(post, "indexed_at")
|
||||
date = _format_date(created_at or indexed_at, offset_hours=_g(session.db, "utc_offset", 0))
|
||||
|
||||
reply_count = _g(post, "replyCount", 0) or 0
|
||||
repost_count = _g(post, "repostCount", 0) or 0
|
||||
like_count = _g(post, "likeCount", 0) or 0
|
||||
|
||||
uri = _g(post, "uri") or _g(item, "uri")
|
||||
item_url = ""
|
||||
if uri and handle:
|
||||
rkey = uri.split("/")[-1]
|
||||
item_url = f"https://bsky.app/profile/{handle}/post/{rkey}"
|
||||
|
||||
image_description = _extract_image_descriptions(post, record)
|
||||
|
||||
return {
|
||||
"author": author_label,
|
||||
"text": text,
|
||||
"date": date,
|
||||
"replies": reply_count,
|
||||
"reposts": repost_count,
|
||||
"likes": like_count,
|
||||
"source": _("Bluesky"),
|
||||
"privacy": _("Public"),
|
||||
"image_description": image_description,
|
||||
"item_url": item_url,
|
||||
}
|
||||
|
||||
|
||||
class viewPost(base_messages.basicMessage):
|
||||
def __init__(self, session: Any, item: Any, controller: Any = None):
|
||||
self.session = session
|
||||
self.controller = controller
|
||||
data = _extract_post_view_data(session, item)
|
||||
if not data:
|
||||
output.speak(_("No post available to view."), True)
|
||||
return
|
||||
self.post_uri = _g(_g(item, "post", item), "uri") or _g(item, "uri")
|
||||
title = _("Post from {}").format(data["author"])
|
||||
self.message = postDialogs.viewPost(
|
||||
text=data["text"],
|
||||
reposts_count=data["reposts"],
|
||||
likes_count=data["likes"],
|
||||
source=data["source"],
|
||||
date=data["date"],
|
||||
privacy=data["privacy"],
|
||||
)
|
||||
self.message.SetTitle(title)
|
||||
if data["image_description"]:
|
||||
self.message.image_description.Enable(True)
|
||||
self.message.image_description.ChangeValue(data["image_description"])
|
||||
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||
if data["item_url"]:
|
||||
self.message.enable_button("share")
|
||||
self.item_url = data["item_url"]
|
||||
widgetUtils.connect_event(self.message.share, widgetUtils.BUTTON_PRESSED, self.share)
|
||||
if self.post_uri:
|
||||
try:
|
||||
self.message.reposts_button.Enable(True)
|
||||
self.message.likes_button.Enable(True)
|
||||
widgetUtils.connect_event(self.message.reposts_button, widgetUtils.BUTTON_PRESSED, self.on_reposts)
|
||||
widgetUtils.connect_event(self.message.likes_button, widgetUtils.BUTTON_PRESSED, self.on_likes)
|
||||
except Exception:
|
||||
pass
|
||||
self.message.ShowModal()
|
||||
|
||||
def text_processor(self):
|
||||
pass
|
||||
|
||||
def share(self, *args, **kwargs):
|
||||
if hasattr(self, "item_url"):
|
||||
output.copy(self.item_url)
|
||||
output.speak(_("Link copied to clipboard."))
|
||||
|
||||
def on_reposts(self, *args, **kwargs):
|
||||
if not self.post_uri or not self.controller:
|
||||
return
|
||||
try:
|
||||
controller = self.controller
|
||||
account_name = self.session.get_name()
|
||||
list_name = f"{self.post_uri}-reposts"
|
||||
existing = controller.search_buffer(list_name, account_name)
|
||||
if existing:
|
||||
index = controller.view.search(list_name, account_name)
|
||||
if index is not None:
|
||||
controller.view.change_buffer(index)
|
||||
return
|
||||
title = _("people who reposted this post")
|
||||
from pubsub import pub
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="PostUserListBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=controller.view.search("timelines", account_name),
|
||||
start=True,
|
||||
kwargs=dict(parent=controller.view.nb, name=list_name, session=self.session,
|
||||
post_uri=self.post_uri, api_method="get_post_reposts")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_likes(self, *args, **kwargs):
|
||||
if not self.post_uri or not self.controller:
|
||||
return
|
||||
try:
|
||||
controller = self.controller
|
||||
account_name = self.session.get_name()
|
||||
list_name = f"{self.post_uri}-likes"
|
||||
existing = controller.search_buffer(list_name, account_name)
|
||||
if existing:
|
||||
index = controller.view.search(list_name, account_name)
|
||||
if index is not None:
|
||||
controller.view.change_buffer(index)
|
||||
return
|
||||
title = _("people who liked this post")
|
||||
from pubsub import pub
|
||||
pub.sendMessage(
|
||||
"createBuffer",
|
||||
buffer_type="PostUserListBuffer",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=controller.view.search("timelines", account_name),
|
||||
start=True,
|
||||
kwargs=dict(parent=controller.view.nb, name=list_name, session=self.session,
|
||||
post_uri=self.post_uri, api_method="get_post_likes")
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class text(base_messages.basicMessage):
|
||||
"""Simple text viewer dialog for OCR results and similar."""
|
||||
|
||||
def __init__(self, title, text="", *args, **kwargs):
|
||||
self.title = title
|
||||
self.message = postDialogs.viewText(title=title, text=text, *args, **kwargs)
|
||||
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
||||
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||
|
||||
def text_processor(self):
|
||||
pass
|
||||
128
src/controller/blueski/settings.py
Normal file
128
src/controller/blueski/settings.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
fromapprove.forms import Form, SubmitField, TextAreaField, TextField
|
||||
fromapprove.translation import translate as _
|
||||
|
||||
if TYPE_CHECKING:
|
||||
fromapprove.config import ConfigSectionProxy
|
||||
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# This file is for defining forms and handling for Blueski-specific settings
|
||||
# that might be more complex than simple key-value pairs handled by Session.get_settings_inputs.
|
||||
# For Blueski, initial settings might be simple (handle, app password),
|
||||
# but this structure allows for expansion.
|
||||
|
||||
|
||||
class BlueskiSettingsForm(Form):
|
||||
"""
|
||||
A settings form for Blueski sessions.
|
||||
This would mirror the kind of settings found in Session.get_settings_inputs
|
||||
but using the WTForms-like Form structure for more complex validation or layout.
|
||||
"""
|
||||
# Example fields - these should align with what BlueskiSession.get_settings_inputs defines
|
||||
# and what BlueskiSession.get_configurable_values expects for its config.
|
||||
|
||||
# instance_url = TextField(
|
||||
# _("Instance URL"),
|
||||
# default="https://bsky.social", # Default PDS for Bluesky
|
||||
# description=_("The base URL of your Blueski PDS instance (e.g., https://bsky.social)."),
|
||||
# validators=[], # Add validators if needed, e.g., URL validator
|
||||
# )
|
||||
handle = TextField(
|
||||
_("Bluesky Handle"),
|
||||
description=_("Your Bluesky user handle (e.g., @username.bsky.social or username.bsky.social)."),
|
||||
validators=[], # e.g., DataRequired()
|
||||
)
|
||||
app_password = TextField( # Consider PasswordField if sensitive and your Form class supports it
|
||||
_("App Password"),
|
||||
description=_("Your Bluesky App Password. Generate this in your Bluesky account settings."),
|
||||
validators=[], # e.g., DataRequired()
|
||||
)
|
||||
# Add more fields as needed for Blueski configuration.
|
||||
# For example, if there were specific notification settings, content filters, etc.
|
||||
|
||||
submit = SubmitField(_("Save Blueski Settings"))
|
||||
|
||||
|
||||
async def get_settings_form(
|
||||
user_id: str,
|
||||
session: BlueskiSession | None = None,
|
||||
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
|
||||
) -> BlueskiSettingsForm:
|
||||
"""
|
||||
Creates and pre-populates the Blueski settings form.
|
||||
"""
|
||||
form_data = {}
|
||||
if session: # If a session exists, use its current config
|
||||
# form_data["instance_url"] = session.config_get("api_base_url", "https://bsky.social")
|
||||
form_data["handle"] = session.config_get("handle", "")
|
||||
# App password should not be pre-filled for security.
|
||||
form_data["app_password"] = ""
|
||||
elif config: # Fallback to persisted config if no active session
|
||||
# form_data["instance_url"] = config.api_base_url.get("https://bsky.social")
|
||||
form_data["handle"] = config.handle.get("")
|
||||
form_data["app_password"] = ""
|
||||
|
||||
form = BlueskiSettingsForm(formdata=None, **form_data) # formdata=None for initial display
|
||||
return form
|
||||
|
||||
|
||||
async def process_settings_form(
|
||||
form: BlueskiSettingsForm,
|
||||
user_id: str,
|
||||
session: BlueskiSession | None = None, # Pass if update should affect live session
|
||||
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
|
||||
) -> bool:
|
||||
"""
|
||||
Processes the submitted Blueski settings form and updates configuration.
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
if not form.validate(): # Assuming form has a validate method
|
||||
logger.warning(f"Blueski settings form validation failed for user {user_id}: {form.errors}")
|
||||
return False
|
||||
|
||||
if not config and session: # Try to get config via session if not directly provided
|
||||
# This depends on how ConfigSectionProxy is obtained.
|
||||
# config = approve.config.config.sessions.blueski[user_id] # Example path
|
||||
pass # Needs actual way to get config proxy
|
||||
|
||||
if not config:
|
||||
logger.error(f"Cannot process Blueski settings for user {user_id}: no config proxy available.")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Update the configuration values
|
||||
# await config.api_base_url.set(form.instance_url.data)
|
||||
await config.handle.set(form.handle.data)
|
||||
await config.app_password.set(form.app_password.data) # Ensure this is stored securely
|
||||
|
||||
logger.info(f"Blueski settings updated for user {user_id}.")
|
||||
|
||||
# If there's an active session, it might need to be reconfigured or restarted
|
||||
if session:
|
||||
logger.info(f"Requesting Blueski session re-initialization for user {user_id} due to settings change.")
|
||||
# await session.stop() # Stop it
|
||||
# # Update session instance with new values directly or rely on it re-reading config
|
||||
# session.api_base_url = form.instance_url.data
|
||||
# session.handle = form.handle.data
|
||||
# # App password should be handled carefully, session might need to re-login
|
||||
# await session.start() # Restart with new settings
|
||||
# Or, more simply, the session might have a reconfigure method:
|
||||
# await session.reconfigure(new_settings_dict)
|
||||
pass # Placeholder for session reconfiguration logic
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving Blueski settings for user {user_id}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
# Any additional Blueski-specific settings views or handlers would go here.
|
||||
# For instance, if Blueski had features like "Relays" or "Feed Generators"
|
||||
# that needed UI configuration within Approve, those forms and handlers could be defined here.
|
||||
|
||||
logger.info("Blueski settings module loaded (placeholders).")
|
||||
45
src/controller/blueski/templateEditor.py
Normal file
45
src/controller/blueski/templateEditor.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import wx
|
||||
from typing import List
|
||||
from sessions.blueski.templates import post_variables, person_variables, notification_variables
|
||||
from wxUI.dialogs import templateDialogs
|
||||
|
||||
|
||||
class EditTemplate(object):
|
||||
def __init__(self, template: str, type: str) -> None:
|
||||
super(EditTemplate, self).__init__()
|
||||
self.default_template = template
|
||||
if type == "post":
|
||||
self.variables = post_variables
|
||||
elif type == "notification":
|
||||
self.variables = notification_variables
|
||||
else:
|
||||
self.variables = person_variables
|
||||
self.template: str = template
|
||||
|
||||
def validate_template(self, template: str) -> bool:
|
||||
used_variables: List[str] = re.findall(r"\$\w+", template)
|
||||
validated: bool = True
|
||||
for var in used_variables:
|
||||
if var[1:] not in self.variables:
|
||||
validated = False
|
||||
return validated
|
||||
|
||||
def run_dialog(self) -> str:
|
||||
dialog = templateDialogs.EditTemplateDialog(
|
||||
template=self.template,
|
||||
variables=self.variables,
|
||||
default_template=self.default_template,
|
||||
)
|
||||
response = dialog.ShowModal()
|
||||
if response == wx.ID_SAVE:
|
||||
validated: bool = self.validate_template(dialog.template.GetValue())
|
||||
if validated == False:
|
||||
templateDialogs.invalid_template()
|
||||
self.template = dialog.template.GetValue()
|
||||
return self.run_dialog()
|
||||
else:
|
||||
return dialog.template.GetValue()
|
||||
else:
|
||||
return ""
|
||||
104
src/controller/blueski/userActions.py
Normal file
104
src/controller/blueski/userActions.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import widgetUtils
|
||||
import output
|
||||
from wxUI.dialogs.blueski import userActions as userActionsDialog
|
||||
from extra.autocompletionUsers import completion
|
||||
import languageHandler
|
||||
|
||||
log = logging.getLogger("controller.blueski.userActions")
|
||||
|
||||
|
||||
class BasicUserSelector(object):
|
||||
def __init__(self, session, users=None):
|
||||
super(BasicUserSelector, self).__init__()
|
||||
self.session = session
|
||||
self.create_dialog(users=users or [])
|
||||
|
||||
def create_dialog(self, users):
|
||||
pass
|
||||
|
||||
def resolve_profile(self, actor):
|
||||
try:
|
||||
return self.session.get_profile(actor)
|
||||
except Exception:
|
||||
log.exception("Error resolving Bluesky profile for %s.", actor)
|
||||
return None
|
||||
|
||||
def autocomplete_users(self, *args, **kwargs):
|
||||
c = completion.autocompletionUsers(self.dialog, self.session.session_id)
|
||||
c.show_menu("free")
|
||||
|
||||
|
||||
class userActions(BasicUserSelector):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(userActions, self).__init__(*args, **kwargs)
|
||||
if self.dialog.get_response() == widgetUtils.OK:
|
||||
self.process_action()
|
||||
|
||||
def create_dialog(self, users):
|
||||
self.dialog = userActionsDialog.UserActionsDialog(users)
|
||||
widgetUtils.connect_event(self.dialog.autocompletion, widgetUtils.BUTTON_PRESSED, self.autocomplete_users)
|
||||
|
||||
def process_action(self):
|
||||
action = self.dialog.get_action()
|
||||
actor = self.dialog.get_user().strip()
|
||||
if not actor:
|
||||
output.speak(_("No user specified."), True)
|
||||
return
|
||||
|
||||
profile = self.resolve_profile(actor)
|
||||
if not profile:
|
||||
output.speak(_("User not found."), True)
|
||||
return
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
did = g(profile, "did")
|
||||
viewer = g(profile, "viewer") or {}
|
||||
|
||||
if not did:
|
||||
output.speak(_("User identifier not available."), True)
|
||||
return
|
||||
|
||||
if action == "follow":
|
||||
if self.session.follow_user(did):
|
||||
output.speak(_("Followed."))
|
||||
else:
|
||||
output.speak(_("Failed to follow user."), True)
|
||||
elif action == "unfollow":
|
||||
follow_uri = g(viewer, "following")
|
||||
if not follow_uri:
|
||||
output.speak(_("Follow information not available."), True)
|
||||
return
|
||||
if self.session.unfollow_user(follow_uri):
|
||||
output.speak(_("Unfollowed."))
|
||||
else:
|
||||
output.speak(_("Failed to unfollow user."), True)
|
||||
elif action == "mute":
|
||||
if self.session.mute_user(did):
|
||||
output.speak(_("Muted."))
|
||||
else:
|
||||
output.speak(_("Failed to mute user."), True)
|
||||
elif action == "unmute":
|
||||
if self.session.unmute_user(did):
|
||||
output.speak(_("Unmuted."))
|
||||
else:
|
||||
output.speak(_("Failed to unmute user."), True)
|
||||
elif action == "block":
|
||||
if self.session.block_user(did):
|
||||
output.speak(_("Blocked."))
|
||||
else:
|
||||
output.speak(_("Failed to block user."), True)
|
||||
elif action == "unblock":
|
||||
block_uri = g(viewer, "blocking")
|
||||
if not block_uri:
|
||||
output.speak(_("Block information not available."), True)
|
||||
return
|
||||
if self.session.unblock_user(block_uri):
|
||||
output.speak(_("Unblocked."))
|
||||
else:
|
||||
output.speak(_("Failed to unblock user."), True)
|
||||
309
src/controller/blueski/userList.py
Normal file
309
src/controller/blueski/userList.py
Normal file
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, AsyncGenerator
|
||||
|
||||
import widgetUtils
|
||||
from wxUI.dialogs.blueski.showUserProfile import ShowUserProfileDialog
|
||||
from controller.userList import UserListController
|
||||
from controller.blueski import userActions as user_actions_controller
|
||||
|
||||
fromapprove.translation import translate as _
|
||||
# fromapprove.controller.mastodon import userList as mastodon_user_list # If adapting
|
||||
|
||||
if TYPE_CHECKING:
|
||||
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
|
||||
# Define a type for what a user entry in a list might look like for Blueski
|
||||
BlueskiUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# This file is responsible for fetching and managing lists of users from Blueski.
|
||||
# Examples include:
|
||||
# - Followers of a user
|
||||
# - Users a user is following
|
||||
# - Users who liked or reposted a post
|
||||
# - Users in a specific list or feed (if Blueski supports user lists like Twitter/Mastodon)
|
||||
# - Search results for users
|
||||
|
||||
# The structure will likely involve:
|
||||
# - A base class or functions for paginating through user lists from the Blueski API.
|
||||
# - Specific functions for each type of user list.
|
||||
# - Formatting Blueski user data into a consistent structure for UI display.
|
||||
|
||||
async def fetch_followers(
|
||||
session: BlueskiSession,
|
||||
user_id: str, # DID of the user whose followers to fetch
|
||||
limit: int = 20,
|
||||
cursor: str | None = None
|
||||
) -> AsyncGenerator[BlueskiUserListItem, None]:
|
||||
"""
|
||||
Asynchronously fetches a list of followers for a given Blueski user.
|
||||
user_id is the DID of the target user.
|
||||
Yields user data dictionaries.
|
||||
"""
|
||||
# client = await session.util._get_client() # Get authenticated client
|
||||
# if not client:
|
||||
# logger.warning(f"Blueski client not available for fetching followers of {user_id}.")
|
||||
# return
|
||||
|
||||
# current_cursor = cursor
|
||||
# try:
|
||||
# while True:
|
||||
# # response = await client.app.bsky.graph.get_followers(
|
||||
# # models.AppBskyGraphGetFollowers.Params(
|
||||
# # actor=user_id,
|
||||
# # limit=min(limit, 100), # ATProto API might have its own max limit per request (e.g. 100)
|
||||
# # cursor=current_cursor
|
||||
# # )
|
||||
# # )
|
||||
# # if not response or not response.followers:
|
||||
# # break
|
||||
|
||||
# # for user_profile_view in response.followers:
|
||||
# # yield session.util._format_profile_data(user_profile_view) # Use a utility to standardize format
|
||||
|
||||
# # current_cursor = response.cursor
|
||||
# # if not current_cursor or len(response.followers) < limit : # Or however the API indicates end of list
|
||||
# # break
|
||||
|
||||
# # This is a placeholder loop for demonstration
|
||||
# if current_cursor == "simulated_end_cursor": break # Stop after one simulated page
|
||||
# for i in range(limit):
|
||||
# if current_cursor and int(current_cursor) + i >= 25: # Simulate total 25 followers
|
||||
# current_cursor = "simulated_end_cursor"
|
||||
# break
|
||||
# yield {
|
||||
# "did": f"did:plc:follower{i + (int(current_cursor) if current_cursor else 0)}",
|
||||
# "handle": f"follower{i + (int(current_cursor) if current_cursor else 0)}.bsky.social",
|
||||
# "displayName": f"Follower {i + (int(current_cursor) if current_cursor else 0)}",
|
||||
# "avatar": None # Placeholder
|
||||
# }
|
||||
# if not current_cursor: current_cursor = str(limit) # Simulate next cursor
|
||||
# elif current_cursor != "simulated_end_cursor": current_cursor = str(int(current_cursor) + limit)
|
||||
|
||||
|
||||
"""
|
||||
if not session.is_ready():
|
||||
logger.warning(f"Cannot fetch followers for {user_id}: Blueski session not ready.")
|
||||
# yield {} # Stop iteration if not ready
|
||||
return
|
||||
|
||||
try:
|
||||
followers_data = await session.util.get_followers(user_did=user_id, limit=limit, cursor=cursor)
|
||||
if followers_data:
|
||||
users, _ = followers_data # We'll return the cursor separately via the calling HTTP handler
|
||||
for user_profile_view in users:
|
||||
yield session.util._format_profile_data(user_profile_view)
|
||||
else:
|
||||
logger.info(f"No followers data returned for user {user_id}.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in fetch_followers for Blueski user {user_id}: {e}", exc_info=True)
|
||||
# Depending on desired error handling, could raise or yield an error marker
|
||||
|
||||
|
||||
async def fetch_following(
|
||||
session: BlueskiSession,
|
||||
user_id: str, # DID of the user whose followed accounts to fetch
|
||||
limit: int = 20,
|
||||
cursor: str | None = None
|
||||
) -> AsyncGenerator[BlueskiUserListItem, None]:
|
||||
"""
|
||||
Asynchronously fetches a list of users followed by a given Blueski user.
|
||||
Yields user data dictionaries.
|
||||
"""
|
||||
if not session.is_ready():
|
||||
logger.warning(f"Cannot fetch following for {user_id}: Blueski session not ready.")
|
||||
return
|
||||
|
||||
try:
|
||||
following_data = await session.util.get_following(user_did=user_id, limit=limit, cursor=cursor)
|
||||
if following_data:
|
||||
users, _ = following_data
|
||||
for user_profile_view in users:
|
||||
yield session.util._format_profile_data(user_profile_view)
|
||||
else:
|
||||
logger.info(f"No following data returned for user {user_id}.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in fetch_following for Blueski user {user_id}: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def search_users(
|
||||
session: BlueskiSession,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
cursor: str | None = None
|
||||
) -> AsyncGenerator[BlueskiUserListItem, None]:
|
||||
"""
|
||||
Searches for users on Blueski based on a query string.
|
||||
Yields user data dictionaries.
|
||||
"""
|
||||
if not session.is_ready():
|
||||
logger.warning(f"Cannot search users for '{query}': Blueski session not ready.")
|
||||
return
|
||||
|
||||
try:
|
||||
search_data = await session.util.search_users(term=query, limit=limit, cursor=cursor)
|
||||
if search_data:
|
||||
users, _ = search_data
|
||||
for user_profile_view in users:
|
||||
yield session.util._format_profile_data(user_profile_view)
|
||||
else:
|
||||
logger.info(f"No users found for search term '{query}'.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in search_users for Blueski query '{query}': {e}", exc_info=True)
|
||||
|
||||
# This function is designed to be called by an API endpoint that returns JSON
|
||||
async def get_user_list_paginated(
|
||||
session: BlueskiSession,
|
||||
list_type: str, # "followers", "following", "search"
|
||||
identifier: str, # User DID for followers/following, or search query for search
|
||||
limit: int = 20,
|
||||
cursor: str | None = None
|
||||
) -> tuple[list[BlueskiUserListItem], str | None]:
|
||||
"""
|
||||
Fetches a paginated list of users (followers, following, or search results)
|
||||
and returns the list and the next cursor.
|
||||
"""
|
||||
users_list: list[BlueskiUserListItem] = []
|
||||
next_cursor: str | None = None
|
||||
|
||||
if not session.is_ready():
|
||||
logger.warning(f"Cannot fetch user list '{list_type}': Blueski session not ready.")
|
||||
return [], None
|
||||
|
||||
try:
|
||||
if list_type == "followers":
|
||||
data = await session.util.get_followers(user_did=identifier, limit=limit, cursor=cursor)
|
||||
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
|
||||
elif list_type == "following":
|
||||
data = await session.util.get_following(user_did=identifier, limit=limit, cursor=cursor)
|
||||
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
|
||||
elif list_type == "search_users":
|
||||
data = await session.util.search_users(term=identifier, limit=limit, cursor=cursor)
|
||||
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
|
||||
else:
|
||||
logger.error(f"Unknown list_type: {list_type}")
|
||||
return [], None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching paginated user list '{list_type}' for '{identifier}': {e}", exc_info=True)
|
||||
# Optionally re-raise or return empty with no cursor to indicate error
|
||||
return [], None
|
||||
|
||||
return users_list, next_cursor
|
||||
|
||||
|
||||
async def get_user_profile_details(session: BlueskiSession, user_ident: str) -> BlueskiUserListItem | None:
|
||||
"""
|
||||
Fetches detailed profile information for a user by DID or handle.
|
||||
Returns a dictionary of formatted profile data, or None if not found/error.
|
||||
"""
|
||||
if not session.is_ready():
|
||||
logger.warning(f"Cannot get profile for {user_ident}: Blueski session not ready.")
|
||||
return None
|
||||
|
||||
try:
|
||||
profile_view_detailed = await session.util.get_user_profile(user_ident=user_ident)
|
||||
if profile_view_detailed:
|
||||
return session.util._format_profile_data(profile_view_detailed)
|
||||
else:
|
||||
logger.info(f"No profile data found for user {user_ident}.")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_user_profile_details for {user_ident}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
# Other list types could include:
|
||||
# - fetch_likers(session, post_uri, limit, cursor) # Needs app.bsky.feed.getLikes
|
||||
# - fetch_reposters(session, post_uri, limit, cursor)
|
||||
# - fetch_muted_users(session, limit, cursor)
|
||||
# - fetch_blocked_users(session, limit, cursor)
|
||||
|
||||
# The UI part of Approve that displays user lists would call these functions.
|
||||
# Each function needs to handle pagination as provided by the ATProto API (usually cursor-based).
|
||||
|
||||
logger.info("Blueski userList module loaded (placeholders).")
|
||||
|
||||
|
||||
class BlueskyUserList(UserListController):
|
||||
def __init__(self, users, session, title, fetch_fn=None, cursor=None):
|
||||
self.session = session
|
||||
self.users = self.process_users(users)
|
||||
self._fetch_fn = fetch_fn
|
||||
self._cursor = cursor
|
||||
from wxUI.dialogs import userList
|
||||
self.dialog = userList.UserListDialog(title=title, users=[user.get("display_name") for user in self.users])
|
||||
widgetUtils.connect_event(self.dialog.actions_button, widgetUtils.BUTTON_PRESSED, self.on_actions)
|
||||
widgetUtils.connect_event(self.dialog.details_button, widgetUtils.BUTTON_PRESSED, self.on_details)
|
||||
self._enable_pagination()
|
||||
self.dialog.ShowModal()
|
||||
def process_users(self, users):
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
processed = []
|
||||
for item in users or []:
|
||||
actor = g(item, "actor") or g(item, "user") or item
|
||||
did = g(actor, "did")
|
||||
handle = g(actor, "handle")
|
||||
display_name = g(actor, "displayName") or g(actor, "display_name") or handle or "Unknown"
|
||||
label = f"{display_name} (@{handle})" if handle and display_name != handle else (f"@{handle}" if handle else display_name)
|
||||
processed.append(dict(did=did, handle=handle, display_name=label))
|
||||
return processed
|
||||
|
||||
def on_actions(self, *args, **kwargs):
|
||||
idx = self.dialog.user_list.GetSelection()
|
||||
if idx < 0 or idx >= len(self.users):
|
||||
return
|
||||
handle = self.users[idx].get("handle")
|
||||
if not handle:
|
||||
return
|
||||
user_actions_controller.userActions(self.session, [handle])
|
||||
|
||||
def on_details(self, *args, **kwargs):
|
||||
idx = self.dialog.user_list.GetSelection()
|
||||
if idx < 0 or idx >= len(self.users):
|
||||
return
|
||||
user_ident = self.users[idx].get("did") or self.users[idx].get("handle")
|
||||
if not user_ident:
|
||||
return
|
||||
dlg = ShowUserProfileDialog(self.dialog, self.session, user_ident)
|
||||
dlg.ShowModal()
|
||||
dlg.Destroy()
|
||||
|
||||
def _enable_pagination(self):
|
||||
if not self._fetch_fn:
|
||||
return
|
||||
if not self._cursor:
|
||||
return
|
||||
self.dialog.load_more_button.Show()
|
||||
widgetUtils.connect_event(self.dialog.load_more_button, widgetUtils.BUTTON_PRESSED, self.load_more)
|
||||
self.dialog.Layout()
|
||||
|
||||
def load_more(self, *args, **kwargs):
|
||||
if not self._fetch_fn:
|
||||
return
|
||||
if not self._cursor:
|
||||
self.dialog.load_more_button.Disable()
|
||||
return
|
||||
try:
|
||||
res = self._fetch_fn(cursor=self._cursor)
|
||||
items = res.get("items", []) if isinstance(res, dict) else []
|
||||
self._cursor = res.get("cursor") if isinstance(res, dict) else None
|
||||
new_users = self.process_users(items)
|
||||
if not new_users:
|
||||
self.dialog.load_more_button.Disable()
|
||||
return
|
||||
self.users.extend(new_users)
|
||||
self.dialog.add_users([u.get("display_name") for u in new_users])
|
||||
if not self._cursor:
|
||||
self.dialog.load_more_button.Disable()
|
||||
except Exception:
|
||||
self.dialog.load_more_button.Disable()
|
||||
@@ -10,7 +10,7 @@ from . import base
|
||||
log = logging.getLogger("controller.buffers.base.account")
|
||||
|
||||
class AccountBuffer(base.Buffer):
|
||||
def __init__(self, parent, name, account, account_id):
|
||||
def __init__(self, parent, name, account, account_id, session=None):
|
||||
super(AccountBuffer, self).__init__(parent, None, name)
|
||||
log.debug("Initializing buffer %s, account %s" % (name, account,))
|
||||
self.buffer = buffers.accountPanel(parent, name)
|
||||
@@ -53,4 +53,4 @@ class AccountBuffer(base.Buffer):
|
||||
else:
|
||||
self.buffer.change_autostart(False)
|
||||
config.app["sessions"]["ignored_sessions"].append(self.account_id)
|
||||
config.app.write()
|
||||
config.app.write()
|
||||
|
||||
14
src/controller/buffers/blueski/__init__.py
Normal file
14
src/controller/buffers/blueski/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from .timeline import (
|
||||
HomeTimeline,
|
||||
FollowingTimeline,
|
||||
NotificationBuffer,
|
||||
Conversation,
|
||||
LikesBuffer,
|
||||
MentionsBuffer,
|
||||
SentBuffer,
|
||||
UserTimeline,
|
||||
SearchBuffer,
|
||||
)
|
||||
from .user import FollowersBuffer, FollowingBuffer, BlocksBuffer, PostUserListBuffer
|
||||
from .chat import ConversationListBuffer, ChatBuffer as ChatMessageBuffer
|
||||
1229
src/controller/buffers/blueski/base.py
Normal file
1229
src/controller/buffers/blueski/base.py
Normal file
File diff suppressed because it is too large
Load Diff
559
src/controller/buffers/blueski/chat.py
Normal file
559
src/controller/buffers/blueski/chat.py
Normal file
@@ -0,0 +1,559 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import wx
|
||||
import widgetUtils
|
||||
import output
|
||||
from .base import BaseBuffer
|
||||
from controller.blueski import messages as blueski_messages
|
||||
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||
from sessions.blueski import compose
|
||||
from mysc.thread_utils import call_threaded
|
||||
|
||||
log = logging.getLogger("controller.buffers.blueski.chat")
|
||||
|
||||
class ConversationListBuffer(BaseBuffer):
|
||||
"""Buffer for listing conversations, similar to Mastodon's ConversationListBuffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_convo"
|
||||
super(ConversationListBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "chat"
|
||||
self.sound = "dm_received.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.ChatPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def bind_events(self):
|
||||
"""Bind events like Mastodon's ConversationListBuffer."""
|
||||
self.buffer.set_focus_function(self.onFocus)
|
||||
widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event)
|
||||
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_ITEM_RIGHT_CLICK, self.show_menu)
|
||||
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_KEY_DOWN, self.show_menu_by_key)
|
||||
# Buttons
|
||||
if hasattr(self.buffer, "post"):
|
||||
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.on_post, self.buffer.post)
|
||||
if hasattr(self.buffer, "reply"):
|
||||
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.reply, self.buffer.reply)
|
||||
if hasattr(self.buffer, "new_chat"):
|
||||
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.on_new_chat, self.buffer.new_chat)
|
||||
|
||||
def get_item(self):
|
||||
"""Get the last message from the selected conversation (like Mastodon)."""
|
||||
index = self.buffer.list.get_selected()
|
||||
if index > -1 and self.session.db.get(self.name) is not None and len(self.session.db[self.name]) > index:
|
||||
convo = self.session.db[self.name][index]
|
||||
# Return lastMessage for compatibility with item-based operations
|
||||
last_msg = getattr(convo, "lastMessage", None) or (convo.get("lastMessage") if isinstance(convo, dict) else None)
|
||||
return last_msg
|
||||
return None
|
||||
|
||||
def get_conversation(self):
|
||||
"""Get the full conversation object."""
|
||||
index = self.buffer.list.get_selected()
|
||||
if index > -1 and self.session.db.get(self.name) is not None and len(self.session.db[self.name]) > index:
|
||||
return self.session.db[self.name][index]
|
||||
return None
|
||||
|
||||
def get_convo_id(self, conversation):
|
||||
"""Extract conversation ID from a conversation object, handling different field names."""
|
||||
if not conversation:
|
||||
return None
|
||||
# Try different possible field names for the conversation ID
|
||||
for attr in ("id", "convo_id", "convoId"):
|
||||
val = getattr(conversation, attr, None)
|
||||
if val:
|
||||
return val
|
||||
if isinstance(conversation, dict) and attr in conversation:
|
||||
return conversation[attr]
|
||||
return None
|
||||
|
||||
def _get_members_key(self, conversation):
|
||||
"""Fallback key when convo id is missing: stable member DID list."""
|
||||
members = getattr(conversation, "members", None) or (conversation.get("members") if isinstance(conversation, dict) else None) or []
|
||||
dids = []
|
||||
for m in members:
|
||||
did = getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)
|
||||
if did:
|
||||
dids.append(did)
|
||||
if not dids:
|
||||
return None
|
||||
dids.sort()
|
||||
return tuple(dids)
|
||||
|
||||
def _get_last_message_key(self, conversation):
|
||||
"""Key for detecting conversation updates based on last message."""
|
||||
last_msg = getattr(conversation, "lastMessage", None) or (conversation.get("lastMessage") if isinstance(conversation, dict) else None)
|
||||
if not last_msg:
|
||||
return None
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
for attr in ("id", "messageId", "message_id", "msgId", "msg_id", "cid", "rev"):
|
||||
val = g(last_msg, attr)
|
||||
if val:
|
||||
return str(val)
|
||||
|
||||
nested = g(last_msg, "message") or g(last_msg, "record")
|
||||
if nested:
|
||||
for attr in ("id", "messageId", "message_id", "msgId", "msg_id", "cid", "rev"):
|
||||
val = g(nested, attr)
|
||||
if val:
|
||||
return str(val)
|
||||
|
||||
sent_at = g(last_msg, "sentAt") or g(last_msg, "sent_at") or g(last_msg, "createdAt") or g(last_msg, "created_at")
|
||||
sender = g(last_msg, "sender") or (g(nested, "sender") if nested else {}) or {}
|
||||
sender_did = g(sender, "did")
|
||||
text = g(last_msg, "text") or (g(nested, "text") if nested else None)
|
||||
if sent_at or sender_did or text:
|
||||
return (str(sent_at) if sent_at else None, str(sender_did) if sender_did else None, str(text) if text else None)
|
||||
return None
|
||||
|
||||
def get_formatted_message(self):
|
||||
"""Return last message text for current conversation."""
|
||||
conversation = self.get_conversation()
|
||||
if not conversation:
|
||||
return None
|
||||
return self.compose_function(
|
||||
conversation,
|
||||
self.session.db,
|
||||
self.session.settings,
|
||||
self.session.settings["general"].get("relative_times", False),
|
||||
self.session.settings["general"].get("show_screen_names", False),
|
||||
)[1]
|
||||
|
||||
def get_message(self):
|
||||
"""Return a readable summary for the selected conversation."""
|
||||
conversation = self.get_conversation()
|
||||
if not conversation:
|
||||
return None
|
||||
composed = self.compose_function(
|
||||
conversation,
|
||||
self.session.db,
|
||||
self.session.settings,
|
||||
self.session.settings["general"].get("relative_times", False),
|
||||
self.session.settings["general"].get("show_screen_names", False),
|
||||
)
|
||||
return " ".join(composed)
|
||||
|
||||
def on_new_chat(self, *args, **kwargs):
|
||||
"""Start a new conversation by entering a handle."""
|
||||
dlg = wx.TextEntryDialog(None, _("Enter the handle of the user (e.g., user.bsky.social):"), _("New Chat"))
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
handle = dlg.GetValue().strip()
|
||||
if handle:
|
||||
if handle.startswith("@"):
|
||||
handle = handle[1:]
|
||||
def do_create():
|
||||
try:
|
||||
# Resolve handle to DID
|
||||
profile = self.session.get_profile(handle)
|
||||
if not profile:
|
||||
wx.CallAfter(output.speak, _("User not found."), True)
|
||||
return
|
||||
did = getattr(profile, "did", None) or (profile.get("did") if isinstance(profile, dict) else None)
|
||||
if not did:
|
||||
wx.CallAfter(output.speak, _("Could not get user ID."), True)
|
||||
return
|
||||
# Get or create conversation
|
||||
convo = self.session.get_or_create_convo([did])
|
||||
if not convo:
|
||||
wx.CallAfter(output.speak, _("Could not create conversation."), True)
|
||||
return
|
||||
convo_id = self.get_convo_id(convo)
|
||||
user_handle = getattr(profile, "handle", None) or (profile.get("handle") if isinstance(profile, dict) else None) or handle
|
||||
title = _("Chat: {0}").format(user_handle)
|
||||
# Create the buffer under direct_messages node
|
||||
wx.CallAfter(self._create_chat_buffer, self.controller, title, convo_id)
|
||||
# Refresh conversation list
|
||||
wx.CallAfter(self.start_stream, True, False)
|
||||
except Exception:
|
||||
log.exception("Error creating new conversation")
|
||||
wx.CallAfter(output.speak, _("Error creating conversation."), True)
|
||||
call_threaded(do_create)
|
||||
dlg.Destroy()
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True, avoid_autoreading=False):
|
||||
count = self.get_max_items()
|
||||
try:
|
||||
res = self.session.list_convos(limit=count)
|
||||
items = res.get("items", [])
|
||||
self._build_member_maps(items)
|
||||
return self._merge_conversations(items, play_sound, avoid_autoreading=avoid_autoreading)
|
||||
except Exception:
|
||||
log.exception("Error fetching conversations")
|
||||
output.speak(_("Error loading conversations."), True)
|
||||
return 0
|
||||
|
||||
def _build_member_maps(self, convos):
|
||||
"""Build DID→name maps from conversation members and store in db for chat buffers."""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
for convo in convos:
|
||||
convo_id = self.get_convo_id(convo)
|
||||
if not convo_id:
|
||||
continue
|
||||
members = g(convo, "members", []) or []
|
||||
member_map = {}
|
||||
for m in members:
|
||||
did = g(m, "did", None)
|
||||
if did:
|
||||
name = g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
|
||||
member_map[did] = name
|
||||
if member_map:
|
||||
self.session.db["convo_" + str(convo_id) + "_members"] = member_map
|
||||
|
||||
def _merge_conversations(self, items, play_sound=True, avoid_autoreading=False):
|
||||
"""Merge conversation list, updating items without duplicating or re-alerting."""
|
||||
if self.session.db.get(self.name) is None:
|
||||
self.session.db[self.name] = []
|
||||
|
||||
# Track current selection to restore after refresh
|
||||
selected_key = None
|
||||
current_convo = self.get_conversation()
|
||||
if current_convo:
|
||||
selected_key = self.get_convo_id(current_convo) or self._get_members_key(current_convo)
|
||||
|
||||
existing = {}
|
||||
existing_last = {}
|
||||
for convo in self.session.db[self.name]:
|
||||
key = self.get_convo_id(convo) or self._get_members_key(convo)
|
||||
if key is None:
|
||||
continue
|
||||
existing[key] = convo
|
||||
existing_last[key] = self._get_last_message_key(convo)
|
||||
|
||||
new_db = []
|
||||
new_count = 0
|
||||
first_load = len(self.session.db[self.name]) == 0
|
||||
for convo in items:
|
||||
key = self.get_convo_id(convo) or self._get_members_key(convo)
|
||||
new_db.append(convo)
|
||||
if key is None:
|
||||
if first_load:
|
||||
new_count += 1
|
||||
continue
|
||||
if key not in existing:
|
||||
new_count += 1
|
||||
continue
|
||||
if self._get_last_message_key(convo) != existing_last.get(key):
|
||||
new_count += 1
|
||||
|
||||
# Replace DB with latest ordered list from API
|
||||
self.session.db[self.name] = new_db
|
||||
|
||||
# Rebuild list UI to keep ordering consistent with API
|
||||
self.buffer.list.clear()
|
||||
safe = True
|
||||
relative_times = self.session.settings["general"].get("relative_times", False)
|
||||
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
|
||||
for convo in new_db:
|
||||
row = self.compose_function(convo, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe)
|
||||
self.buffer.list.insert_item(False, *row)
|
||||
|
||||
# Restore selection if possible
|
||||
if selected_key is not None:
|
||||
for idx, convo in enumerate(new_db):
|
||||
key = self.get_convo_id(convo) or self._get_members_key(convo)
|
||||
if key == selected_key:
|
||||
self.buffer.list.select_item(idx)
|
||||
break
|
||||
|
||||
# Sound and auto-read only when something actually changed
|
||||
if new_count > 0:
|
||||
if play_sound and self.sound and not self.session.settings["sound"].get("session_mute", False):
|
||||
self.session.sound.play(self.sound)
|
||||
if not avoid_autoreading:
|
||||
self.auto_read(new_count)
|
||||
|
||||
return new_count
|
||||
|
||||
def fav(self):
|
||||
pass
|
||||
|
||||
def unfav(self):
|
||||
pass
|
||||
|
||||
def can_share(self):
|
||||
return False
|
||||
|
||||
def url(self, *args, **kwargs):
|
||||
"""Enter key opens the chat conversation buffer."""
|
||||
self.view_chat()
|
||||
|
||||
def send_message(self, *args, **kwargs):
|
||||
"""Global DM shortcut - reply to conversation."""
|
||||
return self.reply()
|
||||
|
||||
def reply(self, *args, **kwargs):
|
||||
"""Reply to the selected conversation (like Mastodon)."""
|
||||
conversation = self.get_conversation()
|
||||
if not conversation:
|
||||
output.speak(_("No conversation selected."), True)
|
||||
return
|
||||
|
||||
convo_id = self.get_convo_id(conversation)
|
||||
if not convo_id:
|
||||
log.error("Could not get conversation ID from conversation object")
|
||||
output.speak(_("Could not identify conversation."), True)
|
||||
return
|
||||
|
||||
# Get participants for title
|
||||
members = getattr(conversation, "members", []) or (conversation.get("members", []) if isinstance(conversation, dict) else [])
|
||||
user_did = self.session.db.get("user_id")
|
||||
others = [m for m in members if (getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)) != user_did]
|
||||
if not others:
|
||||
others = members
|
||||
|
||||
if others:
|
||||
first_user = others[0]
|
||||
username = getattr(first_user, "handle", None) or (first_user.get("handle") if isinstance(first_user, dict) else None) or "unknown"
|
||||
else:
|
||||
username = "unknown"
|
||||
|
||||
title = _("Conversation with {0}").format(username)
|
||||
caption = _("Write your message here")
|
||||
initial_text = "@{} ".format(username)
|
||||
|
||||
post = blueski_messages.post(session=self.session, title=title, caption=caption, text=initial_text)
|
||||
if post.message.ShowModal() == wx.ID_OK:
|
||||
text, files, cw_text, langs = post.get_data()
|
||||
if text:
|
||||
def do_send():
|
||||
try:
|
||||
self.session.send_chat_message(convo_id, text)
|
||||
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
|
||||
wx.CallAfter(output.speak, _("Message sent."))
|
||||
wx.CallAfter(self.start_stream, True, False)
|
||||
except Exception:
|
||||
log.exception("Error sending message")
|
||||
wx.CallAfter(output.speak, _("Failed to send message."), True)
|
||||
call_threaded(do_send)
|
||||
if hasattr(post.message, "Destroy"):
|
||||
post.message.Destroy()
|
||||
|
||||
def view_chat(self):
|
||||
"""Open the conversation in a separate buffer (nested under Chats node)."""
|
||||
conversation = self.get_conversation()
|
||||
if not conversation:
|
||||
output.speak(_("No conversation selected."), True)
|
||||
return
|
||||
|
||||
convo_id = self.get_convo_id(conversation)
|
||||
if not convo_id:
|
||||
log.error("Could not get conversation ID from conversation object: %r", conversation)
|
||||
output.speak(_("Could not identify conversation."), True)
|
||||
return
|
||||
|
||||
# Determine participants names for title
|
||||
members = getattr(conversation, "members", []) or (conversation.get("members", []) if isinstance(conversation, dict) else [])
|
||||
user_did = self.session.db.get("user_id")
|
||||
others = [m for m in members if (getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)) != user_did]
|
||||
if not others:
|
||||
others = members
|
||||
names = ", ".join([getattr(m, "handle", None) or (m.get("handle") if isinstance(m, dict) else None) or "unknown" for m in others])
|
||||
|
||||
title = _("Chat: {0}").format(names)
|
||||
|
||||
self._create_chat_buffer(self.controller, title, convo_id)
|
||||
|
||||
def _create_chat_buffer(self, controller, title, convo_id):
|
||||
"""Create a chat buffer under the direct_messages node, avoiding duplicates."""
|
||||
account_name = self.session.get_name()
|
||||
|
||||
# Avoid duplicates: if buffer already exists, navigate to it
|
||||
existing = controller.search_buffer(title, account_name)
|
||||
if existing:
|
||||
index = controller.view.search(title, account_name)
|
||||
if index is not None:
|
||||
controller.view.change_buffer(index)
|
||||
return
|
||||
|
||||
# Insert under direct_messages node (like Mastodon's ConversationBuffer)
|
||||
chats_position = controller.view.search("direct_messages", account_name)
|
||||
if chats_position is None:
|
||||
chats_position = controller.view.search(account_name, account_name)
|
||||
|
||||
controller.create_buffer(
|
||||
buffer_type="chat_messages",
|
||||
session_type="blueski",
|
||||
buffer_title=title,
|
||||
parent_tab=chats_position,
|
||||
kwargs={"session": self.session, "convo_id": convo_id, "name": title},
|
||||
start=True
|
||||
)
|
||||
|
||||
# Navigate to the newly created buffer and announce it
|
||||
new_index = controller.view.search(title, account_name)
|
||||
if new_index is not None:
|
||||
controller.view.change_buffer(new_index)
|
||||
buffer_obj = controller.search_buffer(title, account_name)
|
||||
if buffer_obj and hasattr(buffer_obj.buffer, "list"):
|
||||
try:
|
||||
count = buffer_obj.buffer.list.get_count()
|
||||
if count > 0:
|
||||
msg = _("{0}, {1} of {2}").format(title, buffer_obj.buffer.list.get_selected()+1, count)
|
||||
else:
|
||||
msg = _("{0}. Empty").format(title)
|
||||
except Exception:
|
||||
msg = _("{0}. Empty").format(title)
|
||||
output.speak(msg, True)
|
||||
|
||||
def destroy_status(self):
|
||||
pass
|
||||
|
||||
class ChatBuffer(BaseBuffer):
|
||||
"""Buffer for displaying messages in a conversation, similar to Mastodon's ConversationBuffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_chat_message"
|
||||
super(ChatBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "chat_messages"
|
||||
self.convo_id = kwargs.get("convo_id")
|
||||
self.sound = "dm_received.ogg"
|
||||
self._member_map_loaded = False
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.ChatMessagePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def _update_member_map(self):
|
||||
"""Fetch conversation members to build a DID-to-name map for sender resolution."""
|
||||
try:
|
||||
convo = self.session.get_convo(self.convo_id)
|
||||
if not convo:
|
||||
return
|
||||
member_map = {}
|
||||
for m in getattr(convo, "members", []) or []:
|
||||
did = getattr(m, "did", None)
|
||||
if did:
|
||||
name = getattr(m, "display_name", None) or getattr(m, "handle", None) or "unknown"
|
||||
member_map[did] = name
|
||||
self.session.db[self.name + "_members"] = member_map
|
||||
except Exception:
|
||||
log.exception("Error fetching conversation members for DID resolution")
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.convo_id:
|
||||
return 0
|
||||
if not self._member_map_loaded:
|
||||
self._update_member_map()
|
||||
self._member_map_loaded = True
|
||||
count = self.get_max_items()
|
||||
try:
|
||||
res = self.session.get_convo_messages(self.convo_id, limit=count)
|
||||
items = res.get("items", [])
|
||||
items = list(reversed(items))
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception:
|
||||
log.exception("Error fetching chat messages")
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
output.speak(_("This action is not supported for this buffer"), True)
|
||||
|
||||
def fav(self):
|
||||
pass
|
||||
|
||||
def unfav(self):
|
||||
pass
|
||||
|
||||
def can_share(self):
|
||||
return False
|
||||
|
||||
def destroy_status(self):
|
||||
pass
|
||||
|
||||
def url(self, *args, **kwargs):
|
||||
"""Enter key opens reply dialog in chat."""
|
||||
self.on_reply(None)
|
||||
|
||||
def on_reply(self, evt):
|
||||
"""Open dialog to send a message in this conversation."""
|
||||
if not self.convo_id:
|
||||
output.speak(_("Cannot send message: no conversation selected."), True)
|
||||
return
|
||||
|
||||
# Get conversation title from buffer name or use generic
|
||||
title = _("Send Message")
|
||||
if self.name and self.name.startswith(_("Chat: ")):
|
||||
title = self.name
|
||||
caption = _("Write your message here")
|
||||
|
||||
post = blueski_messages.post(session=self.session, title=title, caption=caption, text="")
|
||||
if post.message.ShowModal() == wx.ID_OK:
|
||||
text, files, cw_text, langs = post.get_data()
|
||||
if text:
|
||||
def do_send():
|
||||
try:
|
||||
self.session.send_chat_message(self.convo_id, text)
|
||||
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
|
||||
wx.CallAfter(output.speak, _("Message sent."))
|
||||
wx.CallAfter(self.start_stream, True, False)
|
||||
except Exception:
|
||||
log.exception("Error sending chat message")
|
||||
wx.CallAfter(output.speak, _("Failed to send message."), True)
|
||||
call_threaded(do_send)
|
||||
if hasattr(post.message, "Destroy"):
|
||||
post.message.Destroy()
|
||||
|
||||
def reply(self, *args, **kwargs):
|
||||
"""Handle reply action (from menu or keyboard shortcut)."""
|
||||
self.on_reply(None)
|
||||
|
||||
def send_message(self, *args, **kwargs):
|
||||
"""Global shortcut for DM."""
|
||||
self.on_reply(None)
|
||||
|
||||
def get_message(self):
|
||||
"""Return a readable summary for the selected chat message."""
|
||||
item = self.get_item()
|
||||
if item is None:
|
||||
return None
|
||||
composed = self.compose_function(
|
||||
item, self.session.db, self.session.settings,
|
||||
self.session.settings["general"].get("relative_times", False),
|
||||
self.session.settings["general"].get("show_screen_names", False),
|
||||
)
|
||||
return " ".join(composed)
|
||||
|
||||
def get_formatted_message(self):
|
||||
"""Return the text content of the selected chat message."""
|
||||
item = self.get_item()
|
||||
if item is None:
|
||||
return None
|
||||
composed = self.compose_function(
|
||||
item, self.session.db, self.session.settings,
|
||||
self.session.settings["general"].get("relative_times", False),
|
||||
self.session.settings["general"].get("show_screen_names", False),
|
||||
)
|
||||
return composed[1]
|
||||
|
||||
def view_item(self, item=None):
|
||||
"""View the selected chat message in a dialog."""
|
||||
msg = self.get_formatted_message()
|
||||
if not msg:
|
||||
output.speak(_("No message selected."), True)
|
||||
return
|
||||
viewer = blueski_messages.text(title=_("Chat message"), text=msg)
|
||||
viewer.message.ShowModal()
|
||||
viewer.message.Destroy()
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
"""Allow removing this buffer."""
|
||||
from wxUI import commonMessageDialogs
|
||||
if force == False:
|
||||
dlg = commonMessageDialogs.remove_buffer()
|
||||
else:
|
||||
dlg = widgetUtils.YES
|
||||
if dlg == widgetUtils.YES:
|
||||
if self.name in self.session.db:
|
||||
self.session.db.pop(self.name)
|
||||
return True
|
||||
elif dlg == widgetUtils.NO:
|
||||
return False
|
||||
602
src/controller/buffers/blueski/timeline.py
Normal file
602
src/controller/buffers/blueski/timeline.py
Normal file
@@ -0,0 +1,602 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import output
|
||||
from .base import BaseBuffer
|
||||
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||
|
||||
log = logging.getLogger("controller.buffers.blueski.timeline")
|
||||
|
||||
|
||||
class HomeTimeline(BaseBuffer):
|
||||
"""Discover feed buffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(HomeTimeline, self).__init__(*args, **kwargs)
|
||||
self.type = "home_timeline"
|
||||
self.feed_uri = None
|
||||
self.next_cursor = None
|
||||
self.sound = "tweet_received.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not self.feed_uri:
|
||||
self.feed_uri = self._resolve_discover_feed(api)
|
||||
try:
|
||||
if self.feed_uri:
|
||||
res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count})
|
||||
else:
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
except Exception as e:
|
||||
log.error("Error fetching home timeline: %s", e)
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
if self.feed_uri:
|
||||
res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count, "cursor": self.next_cursor})
|
||||
else:
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count, "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more home timeline: %s", e)
|
||||
|
||||
def _resolve_discover_feed(self, api):
|
||||
cached = self.session.db.get("discover_feed_uri")
|
||||
if cached:
|
||||
return cached
|
||||
try:
|
||||
res = api.app.bsky.feed.get_suggested_feeds({"limit": 50})
|
||||
for feed in getattr(res, "feeds", []):
|
||||
dn = getattr(feed, "displayName", "") or getattr(feed, "display_name", "")
|
||||
if "discover" in dn.lower():
|
||||
uri = getattr(feed, "uri", "")
|
||||
self.session.db["discover_feed_uri"] = uri
|
||||
return uri
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class FollowingTimeline(BaseBuffer):
|
||||
"""Following-only timeline (reverse-chronological)."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FollowingTimeline, self).__init__(*args, **kwargs)
|
||||
self.type = "following_timeline"
|
||||
self.next_cursor = None
|
||||
self.sound = "tweet_received.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
except Exception as e:
|
||||
log.error("Error fetching following timeline: %s", e)
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological", "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more following timeline: %s", e)
|
||||
|
||||
|
||||
class NotificationBuffer(BaseBuffer):
|
||||
"""Notifications buffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_notification"
|
||||
super(NotificationBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "notifications"
|
||||
self.sound = "notification_received.ogg"
|
||||
self.next_cursor = None
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def _hydrate_notifications(self, notifications):
|
||||
"""Fetch subject post text for like/repost notifications."""
|
||||
if not notifications:
|
||||
return notifications
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# Collect URIs for likes/reposts that need subject post text
|
||||
uris_to_fetch = []
|
||||
for notif in notifications:
|
||||
reason = g(notif, "reason", "")
|
||||
if reason in ("like", "repost"):
|
||||
reason_subject = g(notif, "reasonSubject") or g(notif, "reason_subject")
|
||||
if reason_subject and isinstance(reason_subject, str):
|
||||
uris_to_fetch.append(reason_subject)
|
||||
|
||||
if not uris_to_fetch:
|
||||
return notifications
|
||||
|
||||
# Fetch posts in batch
|
||||
posts_map = {}
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
if api and uris_to_fetch:
|
||||
# getPosts accepts up to 25 URIs at a time
|
||||
for i in range(0, len(uris_to_fetch), 25):
|
||||
batch = uris_to_fetch[i:i+25]
|
||||
res = api.app.bsky.feed.get_posts({"uris": batch})
|
||||
for post in getattr(res, "posts", []):
|
||||
uri = g(post, "uri")
|
||||
if uri:
|
||||
record = g(post, "record", {})
|
||||
text = g(record, "text", "")
|
||||
posts_map[uri] = text
|
||||
except Exception as e:
|
||||
log.error("Error fetching subject posts for notifications: %s", e)
|
||||
|
||||
# Attach subject post text to notifications
|
||||
enriched = []
|
||||
for notif in notifications:
|
||||
reason = g(notif, "reason", "")
|
||||
if reason in ("like", "repost"):
|
||||
reason_subject = g(notif, "reasonSubject") or g(notif, "reason_subject")
|
||||
if reason_subject and reason_subject in posts_map:
|
||||
# Create a modified notification with subject post text
|
||||
if isinstance(notif, dict):
|
||||
notif = dict(notif)
|
||||
notif["_subject_text"] = posts_map[reason_subject]
|
||||
else:
|
||||
# For ATProto model objects, add as attribute
|
||||
try:
|
||||
notif._subject_text = posts_map[reason_subject]
|
||||
except AttributeError:
|
||||
pass
|
||||
enriched.append(notif)
|
||||
|
||||
return enriched
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return 0
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count})
|
||||
notifications = list(getattr(res, "notifications", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
if not notifications:
|
||||
return 0
|
||||
notifications = self._hydrate_notifications(notifications)
|
||||
return self.process_items(notifications, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching notifications: %s", e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor})
|
||||
notifications = list(getattr(res, "notifications", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
notifications = self._hydrate_notifications(notifications)
|
||||
added = self.process_items(notifications, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more notifications: %s", e)
|
||||
|
||||
def add_new_item(self, notification):
|
||||
notifications = self._hydrate_notifications([notification])
|
||||
return self.process_items(notifications, play_sound=True)
|
||||
|
||||
|
||||
class Conversation(BaseBuffer):
|
||||
"""Thread/conversation view."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Conversation, self).__init__(*args, **kwargs)
|
||||
self.type = "conversation"
|
||||
self.root_uri = kwargs.get("uri")
|
||||
self.sound = "search_updated.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.root_uri:
|
||||
return 0
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_post_thread({"uri": self.root_uri, "depth": 100, "parentHeight": 100})
|
||||
thread = getattr(res, "thread", None)
|
||||
if not thread:
|
||||
return 0
|
||||
|
||||
def g(obj, key, default=None):
|
||||
return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default)
|
||||
|
||||
final_items = []
|
||||
# Add ancestors
|
||||
ancestors = []
|
||||
parent = g(thread, "parent")
|
||||
while parent:
|
||||
ppost = g(parent, "post")
|
||||
if ppost:
|
||||
ancestors.insert(0, ppost)
|
||||
parent = g(parent, "parent")
|
||||
final_items.extend(ancestors)
|
||||
|
||||
# Traverse thread
|
||||
def traverse(node):
|
||||
if not node:
|
||||
return
|
||||
post = g(node, "post")
|
||||
if post:
|
||||
final_items.append(post)
|
||||
for r in (g(node, "replies") or []):
|
||||
traverse(r)
|
||||
|
||||
traverse(thread)
|
||||
self.session.db[self.name] = []
|
||||
self.buffer.list.clear()
|
||||
# Don't use process_items() because it applies reverse logic.
|
||||
# Conversations should always be chronological (oldest first).
|
||||
return self._add_items_chronological(final_items, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching thread: %s", e)
|
||||
return 0
|
||||
|
||||
def _add_items_chronological(self, items, play_sound=True):
|
||||
"""Add items in chronological order (oldest first) without reverse logic."""
|
||||
if not items:
|
||||
return 0
|
||||
|
||||
safe = True
|
||||
relative_times = self.session.settings["general"].get("relative_times", False)
|
||||
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
|
||||
|
||||
for item in items:
|
||||
self.session.db[self.name].append(item)
|
||||
post = self.compose_function(item, self.session.db, self.session.settings,
|
||||
relative_times=relative_times,
|
||||
show_screen_names=show_screen_names,
|
||||
safe=safe)
|
||||
self.buffer.list.insert_item(False, *post)
|
||||
|
||||
# Select the root post (first item after ancestors, or just the first)
|
||||
total = self.buffer.list.get_count()
|
||||
if total > 0:
|
||||
self.buffer.list.select_item(0)
|
||||
|
||||
if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]:
|
||||
self.session.sound.play(self.sound)
|
||||
|
||||
return len(items)
|
||||
|
||||
|
||||
class LikesBuffer(BaseBuffer):
|
||||
"""User's liked posts."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LikesBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "likes"
|
||||
self.next_cursor = None
|
||||
self.sound = "favourite.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
try:
|
||||
res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count})
|
||||
items = list(getattr(res, "feed", None) or getattr(res, "items", None) or [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
except Exception as e:
|
||||
log.error("Error fetching likes: %s", e)
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count, "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", None) or getattr(res, "items", None) or [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more likes: %s", e)
|
||||
|
||||
|
||||
class MentionsBuffer(BaseBuffer):
|
||||
"""Mentions, replies and quotes."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["compose_func"] = "compose_notification"
|
||||
super(MentionsBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "mentions"
|
||||
self.sound = "mention_received.ogg"
|
||||
self.next_cursor = None
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return 0
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count})
|
||||
notifications = getattr(res, "notifications", [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
mentions = [n for n in notifications if getattr(n, "reason", "") in ("mention", "reply", "quote")]
|
||||
if not mentions:
|
||||
return 0
|
||||
return self.process_items(mentions, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching mentions: %s", e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor})
|
||||
notifications = getattr(res, "notifications", [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
mentions = [n for n in notifications if getattr(n, "reason", "") in ("mention", "reply", "quote")]
|
||||
if mentions:
|
||||
added = self.process_items(mentions, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more mentions: %s", e)
|
||||
|
||||
def add_new_item(self, notification):
|
||||
if getattr(notification, "reason", "") in ("mention", "reply", "quote"):
|
||||
return self.process_items([notification], play_sound=True)
|
||||
return 0
|
||||
|
||||
|
||||
class SentBuffer(BaseBuffer):
|
||||
"""User's sent posts."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(SentBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "sent"
|
||||
self.next_cursor = None
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api or not api.me:
|
||||
return 0
|
||||
try:
|
||||
res = api.app.bsky.feed.get_author_feed({"actor": api.me.did, "limit": count, "filter": "posts_no_replies"})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
if not items:
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching sent posts: %s", e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api or not api.me:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.feed.get_author_feed({"actor": api.me.did, "limit": count, "filter": "posts_no_replies", "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more sent posts: %s", e)
|
||||
|
||||
|
||||
class UserTimeline(BaseBuffer):
|
||||
"""Timeline for a specific user."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.actor = kwargs.get("actor")
|
||||
self.handle = kwargs.get("handle")
|
||||
super(UserTimeline, self).__init__(*args, **kwargs)
|
||||
self.type = "user_timeline"
|
||||
self.next_cursor = None
|
||||
self._resolved_actor = None
|
||||
self.sound = "tweet_timeline.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.actor:
|
||||
return 0
|
||||
count = self.get_max_items()
|
||||
actor = self.actor.strip().lstrip("@") if isinstance(self.actor, str) else self.actor
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return 0
|
||||
try:
|
||||
if isinstance(actor, str) and not actor.startswith("did:"):
|
||||
profile = self.session.get_profile(actor)
|
||||
if profile:
|
||||
did = profile.get("did") if isinstance(profile, dict) else getattr(profile, "did", None)
|
||||
if did:
|
||||
actor = did
|
||||
self._resolved_actor = actor
|
||||
res = api.app.bsky.feed.get_author_feed({"actor": actor, "limit": count})
|
||||
items = list(getattr(res, "feed", []) or [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
except Exception as e:
|
||||
log.error("Error fetching user timeline: %s", e)
|
||||
return 0
|
||||
return self.process_items(items, play_sound)
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor or not self._resolved_actor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.feed.get_author_feed({"actor": self._resolved_actor, "limit": count, "cursor": self.next_cursor})
|
||||
items = list(getattr(res, "feed", []) or [])
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more user timeline: %s", e)
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
if commonMessageDialogs.remove_buffer() != widgetUtils.YES:
|
||||
return False
|
||||
self.session.db.pop(self.name, None)
|
||||
timelines = self.session.settings["other_buffers"].get("timelines") or []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
for key in (self.actor or "", self.handle or ""):
|
||||
if key in timelines:
|
||||
timelines.remove(key)
|
||||
self.session.settings["other_buffers"]["timelines"] = timelines
|
||||
self.session.settings.write()
|
||||
return True
|
||||
|
||||
|
||||
class SearchBuffer(BaseBuffer):
|
||||
"""Search results buffer."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.search_query = kwargs.pop("query", "")
|
||||
super(SearchBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "search"
|
||||
self.next_cursor = None
|
||||
self.sound = "search_updated.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.search_query:
|
||||
return 0
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return 0
|
||||
try:
|
||||
res = api.app.bsky.feed.search_posts({"q": self.search_query, "limit": count})
|
||||
posts = list(getattr(res, "posts", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
if not posts:
|
||||
return 0
|
||||
self.session.db[self.name] = []
|
||||
self.buffer.list.clear()
|
||||
return self.process_items(posts, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error searching posts: %s", e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.next_cursor or not self.search_query:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
try:
|
||||
res = api.app.bsky.feed.search_posts({"q": self.search_query, "limit": count, "cursor": self.next_cursor})
|
||||
posts = list(getattr(res, "posts", []))
|
||||
self.next_cursor = getattr(res, "cursor", None)
|
||||
added = self.process_items(posts, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % added, True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more search results: %s", e)
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
if commonMessageDialogs.remove_buffer() != widgetUtils.YES:
|
||||
return False
|
||||
self.session.db.pop(self.name, None)
|
||||
searches = self.session.settings["other_buffers"].get("searches") or []
|
||||
if isinstance(searches, str):
|
||||
searches = [s for s in searches.split(",") if s]
|
||||
if self.search_query in searches:
|
||||
searches.remove(self.search_query)
|
||||
self.session.settings["other_buffers"]["searches"] = searches
|
||||
self.session.settings.write()
|
||||
return True
|
||||
289
src/controller/buffers/blueski/user.py
Normal file
289
src/controller/buffers/blueski/user.py
Normal file
@@ -0,0 +1,289 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import output
|
||||
from .base import BaseBuffer
|
||||
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||
from sessions.blueski import compose
|
||||
|
||||
log = logging.getLogger("controller.buffers.blueski.user")
|
||||
|
||||
class UserBuffer(BaseBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
# We need compose_user for this buffer
|
||||
kwargs["compose_func"] = "compose_user"
|
||||
super(UserBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "user"
|
||||
self.next_cursor = None
|
||||
self.sound = "new_event.ogg"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = BlueskiPanels.UserPanel(parent, name)
|
||||
self.buffer.session = self.session
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
api_method = self.kwargs.get("api_method")
|
||||
if not api_method: return 0
|
||||
|
||||
count = self.get_max_items()
|
||||
actor = (
|
||||
self.kwargs.get("actor")
|
||||
or self.kwargs.get("did")
|
||||
or self.kwargs.get("handle")
|
||||
or self.kwargs.get("id")
|
||||
)
|
||||
|
||||
try:
|
||||
if api_method in ("get_followers", "get_follows"):
|
||||
res = getattr(self.session, api_method)(actor=actor, limit=count)
|
||||
else:
|
||||
res = getattr(self.session, api_method)(limit=count)
|
||||
items = self._hydrate_profiles(res.get("items", []) or [])
|
||||
self.next_cursor = res.get("cursor")
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching user list for %s: %s", self.name, e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
api_method = self.kwargs.get("api_method")
|
||||
if not api_method or not self.next_cursor:
|
||||
return
|
||||
|
||||
count = self.get_max_items()
|
||||
actor = (
|
||||
self.kwargs.get("actor")
|
||||
or self.kwargs.get("did")
|
||||
or self.kwargs.get("handle")
|
||||
or self.kwargs.get("id")
|
||||
)
|
||||
try:
|
||||
if api_method in ("get_followers", "get_follows"):
|
||||
res = getattr(self.session, api_method)(actor=actor, limit=count, cursor=self.next_cursor)
|
||||
else:
|
||||
res = getattr(self.session, api_method)(limit=count, cursor=self.next_cursor)
|
||||
items = self._hydrate_profiles(res.get("items", []) or [])
|
||||
self.next_cursor = res.get("cursor")
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % (str(added)), True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more user list items for %s: %s", self.name, e)
|
||||
|
||||
def _hydrate_profiles(self, items):
|
||||
if not items:
|
||||
return []
|
||||
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
def resolve_profile(obj):
|
||||
if g(obj, "handle") or g(obj, "did"):
|
||||
return obj
|
||||
for key in ("subject", "actor", "profile", "user"):
|
||||
nested = g(obj, key)
|
||||
if nested and (g(nested, "handle") or g(nested, "did")):
|
||||
return nested
|
||||
return obj
|
||||
|
||||
actors = []
|
||||
for item in items:
|
||||
profile = resolve_profile(item)
|
||||
did = g(profile, "did")
|
||||
handle = g(profile, "handle")
|
||||
if did:
|
||||
actors.append(did)
|
||||
elif handle:
|
||||
actors.append(handle)
|
||||
|
||||
if not actors:
|
||||
return items
|
||||
|
||||
profiles = []
|
||||
if actors and hasattr(self.session, "get_profiles"):
|
||||
try:
|
||||
res = self.session.get_profiles(actors)
|
||||
profiles = res.get("items", []) or []
|
||||
except Exception:
|
||||
profiles = []
|
||||
# If batch profiles lack counts, hydrate with detailed profiles.
|
||||
if hasattr(self.session, "get_profile"):
|
||||
def counts_missing(profile_obj):
|
||||
p1 = g(profile_obj, "followersCount") or g(profile_obj, "followers_count")
|
||||
p2 = g(profile_obj, "followsCount") or g(profile_obj, "follows_count")
|
||||
p3 = g(profile_obj, "postsCount") or g(profile_obj, "posts_count")
|
||||
if p1 is None and p2 is None and p3 is None:
|
||||
return True
|
||||
return (p1 or 0) == 0 and (p2 or 0) == 0 and (p3 or 0) == 0
|
||||
|
||||
if not profiles:
|
||||
for actor in actors:
|
||||
try:
|
||||
p = self.session.get_profile(actor)
|
||||
if p:
|
||||
profiles.append(p)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
for idx, p in enumerate(profiles):
|
||||
if counts_missing(p):
|
||||
did = g(p, "did") or g(p, "handle")
|
||||
if not did:
|
||||
continue
|
||||
try:
|
||||
detailed = self.session.get_profile(did)
|
||||
if detailed:
|
||||
profiles[idx] = detailed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
profile_map = {}
|
||||
for p in profiles:
|
||||
did = g(p, "did")
|
||||
handle = g(p, "handle")
|
||||
if did:
|
||||
profile_map[did] = p
|
||||
if handle and handle not in profile_map:
|
||||
profile_map[handle] = p
|
||||
|
||||
def needs_replace(item, profile):
|
||||
if profile is None:
|
||||
return False
|
||||
base = resolve_profile(item)
|
||||
f1 = g(base, "followersCount") or g(base, "followers_count")
|
||||
f2 = g(base, "followsCount") or g(base, "follows_count")
|
||||
f3 = g(base, "postsCount") or g(base, "posts_count")
|
||||
p1 = g(profile, "followersCount") or g(profile, "followers_count")
|
||||
p2 = g(profile, "followsCount") or g(profile, "follows_count")
|
||||
p3 = g(profile, "postsCount") or g(profile, "posts_count")
|
||||
if f1 is None and f2 is None and f3 is None:
|
||||
return True
|
||||
if (f1 or 0) == 0 and (f2 or 0) == 0 and (f3 or 0) == 0:
|
||||
return (p1 or 0) != 0 or (p2 or 0) != 0 or (p3 or 0) != 0
|
||||
return False
|
||||
|
||||
enriched = []
|
||||
for item in items:
|
||||
base = resolve_profile(item)
|
||||
did = g(base, "did")
|
||||
handle = g(base, "handle")
|
||||
profile = profile_map.get(did) or profile_map.get(handle)
|
||||
if needs_replace(item, profile):
|
||||
enriched.append(profile)
|
||||
else:
|
||||
enriched.append(item)
|
||||
return enriched
|
||||
|
||||
class FollowersBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["api_method"] = "get_followers"
|
||||
super(FollowersBuffer, self).__init__(*args, **kwargs)
|
||||
self.sound = "update_followers.ogg"
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
dlg = commonMessageDialogs.remove_buffer()
|
||||
if dlg != widgetUtils.YES:
|
||||
return False
|
||||
try:
|
||||
self.session.db.pop(self.name, None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
key = self.kwargs.get("actor") or self.kwargs.get("handle") or self.kwargs.get("id")
|
||||
timelines = self.session.settings["other_buffers"].get("followers_timelines") or []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
if key in timelines:
|
||||
timelines.remove(key)
|
||||
self.session.settings["other_buffers"]["followers_timelines"] = timelines
|
||||
self.session.settings.write()
|
||||
except Exception as e:
|
||||
log.error("Error updating Bluesky followers timelines settings: %s", e)
|
||||
return True
|
||||
|
||||
class FollowingBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["api_method"] = "get_follows"
|
||||
super(FollowingBuffer, self).__init__(*args, **kwargs)
|
||||
self.sound = "update_followers.ogg"
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
dlg = commonMessageDialogs.remove_buffer()
|
||||
if dlg != widgetUtils.YES:
|
||||
return False
|
||||
try:
|
||||
self.session.db.pop(self.name, None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
key = self.kwargs.get("actor") or self.kwargs.get("handle") or self.kwargs.get("id")
|
||||
timelines = self.session.settings["other_buffers"].get("following_timelines") or []
|
||||
if isinstance(timelines, str):
|
||||
timelines = [t for t in timelines.split(",") if t]
|
||||
if key in timelines:
|
||||
timelines.remove(key)
|
||||
self.session.settings["other_buffers"]["following_timelines"] = timelines
|
||||
self.session.settings.write()
|
||||
except Exception as e:
|
||||
log.error("Error updating Bluesky following timelines settings: %s", e)
|
||||
return True
|
||||
|
||||
class BlocksBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["api_method"] = "get_blocks"
|
||||
super(BlocksBuffer, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class PostUserListBuffer(UserBuffer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.post_uri = kwargs.get("post_uri")
|
||||
self.api_method = kwargs.get("api_method")
|
||||
super(PostUserListBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "post_user_list"
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True):
|
||||
if not self.api_method or not self.post_uri:
|
||||
return 0
|
||||
count = self.get_max_items()
|
||||
try:
|
||||
res = getattr(self.session, self.api_method)(self.post_uri, limit=count)
|
||||
items = res.get("items", [])
|
||||
self.next_cursor = res.get("cursor")
|
||||
return self.process_items(items, play_sound)
|
||||
except Exception as e:
|
||||
log.error("Error fetching post user list for %s: %s", self.name, e)
|
||||
return 0
|
||||
|
||||
def get_more_items(self):
|
||||
if not self.api_method or not self.post_uri or not self.next_cursor:
|
||||
return
|
||||
count = self.get_max_items()
|
||||
try:
|
||||
res = getattr(self.session, self.api_method)(self.post_uri, limit=count, cursor=self.next_cursor)
|
||||
items = res.get("items", [])
|
||||
self.next_cursor = res.get("cursor")
|
||||
added = self.process_items(items, play_sound=False)
|
||||
if added:
|
||||
output.speak(_(u"%s items retrieved") % (str(added)), True)
|
||||
except Exception as e:
|
||||
log.error("Error fetching more post user list items for %s: %s", self.name, e)
|
||||
|
||||
def remove_buffer(self, force=False):
|
||||
if not force:
|
||||
from wxUI import commonMessageDialogs
|
||||
import widgetUtils
|
||||
dlg = commonMessageDialogs.remove_buffer()
|
||||
if dlg != widgetUtils.YES:
|
||||
return False
|
||||
try:
|
||||
self.session.db.pop(self.name, None)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
@@ -6,3 +6,4 @@ from .users import UserBuffer
|
||||
from .notifications import NotificationsBuffer
|
||||
from .search import SearchBuffer
|
||||
from .community import CommunityBuffer
|
||||
from .announcements import AnnouncementsBuffer
|
||||
165
src/controller/buffers/mastodon/announcements.py
Normal file
165
src/controller/buffers/mastodon/announcements.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import time
|
||||
import logging
|
||||
import arrow
|
||||
import widgetUtils
|
||||
import wx
|
||||
import output
|
||||
import languageHandler
|
||||
import config
|
||||
from pubsub import pub
|
||||
from controller.buffers.mastodon.base import BaseBuffer
|
||||
from sessions.mastodon import compose, templates
|
||||
from wxUI import buffers
|
||||
from wxUI.dialogs.mastodon import menus
|
||||
from mysc.thread_utils import call_threaded
|
||||
|
||||
log = logging.getLogger("controller.buffers.mastodon.announcements")
|
||||
|
||||
class AnnouncementsBuffer(BaseBuffer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# We enforce compose_func="compose_announcement"
|
||||
kwargs["compose_func"] = "compose_announcement"
|
||||
super(AnnouncementsBuffer, self).__init__(*args, **kwargs)
|
||||
self.type = "announcementsBuffer"
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = buffers.mastodon.announcementsPanel(parent, name)
|
||||
|
||||
def get_buffer_name(self):
|
||||
return _("Announcements")
|
||||
|
||||
def bind_events(self):
|
||||
self.buffer.set_focus_function(self.onFocus)
|
||||
widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event)
|
||||
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.dismiss_announcement, self.buffer.dismiss)
|
||||
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_ITEM_RIGHT_CLICK, self.show_menu)
|
||||
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_KEY_DOWN, self.show_menu_by_key)
|
||||
|
||||
def dismiss_announcement(self, event=None, item=None, *args, **kwargs):
|
||||
index = self.buffer.list.get_selected()
|
||||
if index == -1: return
|
||||
item = self.session.db[self.name][index]
|
||||
|
||||
# Optimistic UI update or wait for API? Let's wait for API to be safe, but run threaded.
|
||||
# We need a custom call because 'announcements_dismiss' returns None on success usually.
|
||||
def _do_dismiss():
|
||||
try:
|
||||
self.session.api_call(call_name="announcements_dismiss", id=str(item.id))
|
||||
# If success, update UI in main thread
|
||||
wx.CallAfter(self._on_dismiss_success, index)
|
||||
except Exception as e:
|
||||
log.exception("Error dismissing announcement")
|
||||
self.session.sound.play("error.ogg")
|
||||
|
||||
call_threaded(_do_dismiss)
|
||||
|
||||
def _on_dismiss_success(self, index):
|
||||
if index < len(self.session.db[self.name]):
|
||||
self.session.db[self.name].pop(index)
|
||||
self.buffer.list.remove_item(index)
|
||||
output.speak(_("Announcement dismissed."))
|
||||
|
||||
def show_menu(self, ev, pos=0, *args, **kwargs):
|
||||
if self.buffer.list.get_count() == 0:
|
||||
return
|
||||
# Create a simple menu
|
||||
menu = wx.Menu()
|
||||
dismiss_item = menu.Append(wx.ID_ANY, _("Dismiss"))
|
||||
copy_item = menu.Append(wx.ID_ANY, _("Copy text"))
|
||||
|
||||
self.buffer.Bind(wx.EVT_MENU, self.dismiss_announcement, dismiss_item)
|
||||
self.buffer.Bind(wx.EVT_MENU, self.copy, copy_item)
|
||||
|
||||
if pos != 0:
|
||||
self.buffer.PopupMenu(menu, pos)
|
||||
else:
|
||||
self.buffer.PopupMenu(menu, self.buffer.list.list.GetPosition())
|
||||
|
||||
def url(self, *args, **kwargs):
|
||||
self.dismiss_announcement()
|
||||
|
||||
def get_more_items(self): output.speak(_("This buffer does not support loading more items."), True)
|
||||
|
||||
# Disable social interactions not applicable to announcements
|
||||
def reply(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def share_item(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def toggle_favorite(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def add_to_favorites(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def remove_from_favorites(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def toggle_bookmark(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def mute_conversation(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def vote(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def send_message(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def user_details(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def view_item(self, *args, **kwargs):
|
||||
# We could implement a specific viewer for announcements if needed,
|
||||
# but the default one expects a status object structure.
|
||||
pass
|
||||
|
||||
def copy(self, event=None):
|
||||
item = self.get_item()
|
||||
if item:
|
||||
pub.sendMessage("execute-action", action="copy_to_clipboard")
|
||||
|
||||
def onFocus(self, *args, **kwargs):
|
||||
# Similar logic to BaseBuffer but adapted if needed.
|
||||
# BaseBuffer.onFocus handles reading long posts.
|
||||
if config.app["app-settings"]["read_long_posts_in_gui"] == True and self.buffer.list.list.HasFocus():
|
||||
wx.CallLater(40, output.speak, self.get_message(), interrupt=True)
|
||||
|
||||
def get_message(self):
|
||||
# Override to use announcement template
|
||||
announcement = self.get_item()
|
||||
if announcement == None:
|
||||
return
|
||||
template = self.session.settings.get("templates", {}).get("announcement", templates.announcement_default_template)
|
||||
t = templates.render_announcement(announcement, template, self.session.settings, relative_times=self.session.settings["general"]["relative_times"], offset_hours=self.session.db["utc_offset"])
|
||||
return t
|
||||
|
||||
def start_stream(self, mandatory=False, play_sound=True, avoid_autoreading=False):
|
||||
current_time = time.time()
|
||||
if self.execution_time == 0 or current_time-self.execution_time >= 300 or mandatory==True:
|
||||
self.execution_time = current_time
|
||||
log.debug("Starting stream for announcements buffer")
|
||||
try:
|
||||
# The announcements API does not accept min_id or limit parameters
|
||||
results = self.session.api.announcements()
|
||||
# Reverse the list so order_buffer processes them according to user preference
|
||||
results.reverse()
|
||||
except Exception as e:
|
||||
log.exception("Error retrieving announcements: %s" % (str(e)))
|
||||
return 0
|
||||
|
||||
# order_buffer handles duplication filtering by ID internally
|
||||
number_of_items = self.session.order_buffer(self.name, results)
|
||||
log.debug("Number of new announcements retrieved: %d" % (number_of_items,))
|
||||
|
||||
self.put_items_on_list(number_of_items)
|
||||
|
||||
if number_of_items > 0 and play_sound == True and self.sound != None and self.session.settings["sound"]["session_mute"] == False:
|
||||
self.session.sound.play(self.sound)
|
||||
|
||||
return number_of_items
|
||||
return 0
|
||||
@@ -40,9 +40,31 @@ class BaseBuffer(base.Buffer):
|
||||
self.buffer.account = account
|
||||
self.bind_events()
|
||||
self.sound = sound
|
||||
pub.subscribe(self.on_mute_cleanup, "mastodon.mute_cleanup")
|
||||
if "-timeline" in self.name or "-followers" in self.name or "-following" in self.name or "searchterm" in self.name:
|
||||
self.finished_timeline = False
|
||||
|
||||
def on_mute_cleanup(self, conversation_id, session_name):
|
||||
if self.name != "home_timeline":
|
||||
return
|
||||
if session_name != self.session.get_name():
|
||||
return
|
||||
items_to_remove = []
|
||||
for index, item in enumerate(self.session.db[self.name]):
|
||||
c_id = None
|
||||
if hasattr(item, "conversation_id"):
|
||||
c_id = item.conversation_id
|
||||
elif isinstance(item, dict):
|
||||
c_id = item.get("conversation_id")
|
||||
|
||||
if c_id == conversation_id:
|
||||
items_to_remove.append(index)
|
||||
|
||||
items_to_remove.sort(reverse=True)
|
||||
for index in items_to_remove:
|
||||
self.session.db[self.name].pop(index)
|
||||
self.buffer.list.remove_item(index)
|
||||
|
||||
def create_buffer(self, parent, name):
|
||||
self.buffer = buffers.mastodon.basePanel(parent, name)
|
||||
|
||||
@@ -104,14 +126,16 @@ class BaseBuffer(base.Buffer):
|
||||
min_id = None
|
||||
# toDo: Implement reverse timelines properly here.
|
||||
if (self.name != "favorites" and self.name != "bookmarks") and self.name in self.session.db and len(self.session.db[self.name]) > 0:
|
||||
if self.session.settings["general"]["reverse_timelines"]:
|
||||
min_id = self.session.db[self.name][0].id
|
||||
else:
|
||||
min_id = self.session.db[self.name][-1].id
|
||||
# We use the maximum ID present in the buffer to ensure we only request posts
|
||||
# that are newer than our most recent chronological post.
|
||||
# This prevents old pinned posts from pulling in hundreds of previous statuses.
|
||||
min_id = max(item.id for item in self.session.db[self.name])
|
||||
# loads pinned posts from user accounts.
|
||||
# Load those posts only when there are no items previously loaded.
|
||||
if "-timeline" in self.name and "account_statuses" in self.function and len(self.session.db.get(self.name, [])) == 0:
|
||||
pinned_posts = self.session.api.account_statuses(pinned=True, limit=count, *self.args, **self.kwargs)
|
||||
for p in pinned_posts:
|
||||
p["pinned"] = True
|
||||
pinned_posts.reverse()
|
||||
else:
|
||||
pinned_posts = None
|
||||
@@ -160,10 +184,17 @@ class BaseBuffer(base.Buffer):
|
||||
|
||||
def get_more_items(self):
|
||||
elements = []
|
||||
if self.session.settings["general"]["reverse_timelines"] == False:
|
||||
max_id = self.session.db[self.name][0].id
|
||||
if len(self.session.db[self.name]) == 0:
|
||||
return
|
||||
# We use the minimum ID in the buffer to correctly request the next page of older items.
|
||||
# This prevents old pinned posts from causing us to skip chronological posts.
|
||||
# We try to exclude pinned posts from this calculation as they are usually outliers at the top.
|
||||
unpinned_ids = [item.id for item in self.session.db[self.name] if not getattr(item, "pinned", False)]
|
||||
if unpinned_ids:
|
||||
max_id = min(unpinned_ids)
|
||||
else:
|
||||
max_id = self.session.db[self.name][-1].id
|
||||
max_id = min(item.id for item in self.session.db[self.name])
|
||||
|
||||
try:
|
||||
items = getattr(self.session.api, self.function)(max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], *self.args, **self.kwargs)
|
||||
except Exception as e:
|
||||
@@ -280,13 +311,22 @@ class BaseBuffer(base.Buffer):
|
||||
return
|
||||
menu = menus.base()
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
|
||||
# Enable/disable edit based on whether the post belongs to the user
|
||||
item = self.get_item()
|
||||
if item and item.account.id == self.session.db["user_id"] and item.reblog == None:
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.edit_status, menuitem=menu.edit)
|
||||
else:
|
||||
menu.edit.Enable(False)
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
|
||||
if self.can_share() == True:
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.quote, menuitem=menu.quote)
|
||||
else:
|
||||
menu.boost.Enable(False)
|
||||
menu.quote.Enable(False)
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.fav, menuitem=menu.fav)
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.unfav, menuitem=menu.unfav)
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.mute_conversation, menuitem=menu.mute)
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.url_, menuitem=menu.openUrl)
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.audio, menuitem=menu.play)
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.view, menuitem=menu.view)
|
||||
@@ -413,11 +453,30 @@ class BaseBuffer(base.Buffer):
|
||||
id = item.id
|
||||
if self.session.settings["general"]["boost_mode"] == "ask":
|
||||
answer = mastodon_dialogs.boost_question()
|
||||
if answer == True:
|
||||
if answer == 1:
|
||||
self._direct_boost(id)
|
||||
elif answer == 2:
|
||||
self.quote(item=item)
|
||||
else:
|
||||
self._direct_boost(id)
|
||||
|
||||
def quote(self, event=None, item=None, *args, **kwargs):
|
||||
if item == None:
|
||||
item = self.get_item()
|
||||
if self.can_share(item=item) == False:
|
||||
return output.speak(_("This action is not supported on conversations."))
|
||||
|
||||
title = _("Quote post")
|
||||
caption = _("Write your comment here")
|
||||
post = messages.post(session=self.session, title=title, caption=caption)
|
||||
|
||||
response = post.message.ShowModal()
|
||||
if response == wx.ID_OK:
|
||||
post_data = post.get_data()
|
||||
call_threaded(self.session.send_post, quote_id=item.id, posts=post_data, visibility=post.get_visibility(), language=post.get_language(), **kwargs)
|
||||
if hasattr(post.message, "destroy"):
|
||||
post.message.destroy()
|
||||
|
||||
def _direct_boost(self, id):
|
||||
item = self.session.api_call(call_name="status_reblog", _sound="retweet_send.ogg", id=id)
|
||||
|
||||
@@ -501,6 +560,49 @@ class BaseBuffer(base.Buffer):
|
||||
log.exception("")
|
||||
self.session.db[self.name] = items
|
||||
|
||||
def edit_status(self, event=None, item=None, *args, **kwargs):
|
||||
if item == None:
|
||||
item = self.get_item()
|
||||
# Check if the post belongs to the current user
|
||||
if item.account.id != self.session.db["user_id"] or item.reblog != None:
|
||||
output.speak(_("You can only edit your own posts."))
|
||||
return
|
||||
# Check if post has a poll with votes - warn user before proceeding
|
||||
if hasattr(item, 'poll') and item.poll is not None:
|
||||
votes_count = item.poll.votes_count if hasattr(item.poll, 'votes_count') else 0
|
||||
if votes_count > 0:
|
||||
# Show confirmation dialog
|
||||
warning_title = _("Warning: Poll with votes")
|
||||
warning_message = _("This post contains a poll with {votes} votes.\n\n"
|
||||
"According to Mastodon's API, editing this post will reset ALL votes to zero, "
|
||||
"even if you don't modify the poll itself.\n\n"
|
||||
"Do you want to continue editing?").format(votes=votes_count)
|
||||
dialog = wx.MessageDialog(self.buffer, warning_message, warning_title,
|
||||
wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING)
|
||||
result = dialog.ShowModal()
|
||||
dialog.Destroy()
|
||||
if result != wx.ID_YES:
|
||||
output.speak(_("Edit cancelled"))
|
||||
return
|
||||
# Log item info for debugging
|
||||
log.debug("Editing status: id={}, has_media_attachments={}, media_count={}".format(
|
||||
item.id,
|
||||
hasattr(item, 'media_attachments'),
|
||||
len(item.media_attachments) if hasattr(item, 'media_attachments') else 0
|
||||
))
|
||||
# Create edit dialog with existing post data
|
||||
title = _("Edit post")
|
||||
caption = _("Edit your post here")
|
||||
post = messages.editPost(session=self.session, item=item, title=title, caption=caption)
|
||||
response = post.message.ShowModal()
|
||||
if response == wx.ID_OK:
|
||||
post_data = post.get_data()
|
||||
# Call edit_post method in session
|
||||
# Note: visibility and language cannot be changed when editing per Mastodon API
|
||||
call_threaded(self.session.edit_post, post_id=post.post_id, posts=post_data)
|
||||
if hasattr(post.message, "destroy"):
|
||||
post.message.destroy()
|
||||
|
||||
def user_details(self):
|
||||
item = self.get_item()
|
||||
pass
|
||||
@@ -563,6 +665,22 @@ class BaseBuffer(base.Buffer):
|
||||
else:
|
||||
call_threaded(self.session.api_call, call_name="status_unbookmark", preexec_message=_("Removing from bookmarks..."), _sound="favourite.ogg", id=item.id)
|
||||
|
||||
def mute_conversation(self, event=None, item=None, *args, **kwargs):
|
||||
if item == None:
|
||||
item = self.get_item()
|
||||
if item.reblog != None:
|
||||
item = item.reblog
|
||||
try:
|
||||
item = self.session.api.status(item.id)
|
||||
except MastodonNotFoundError:
|
||||
output.speak(_("No status found with that ID"))
|
||||
return
|
||||
if item.muted == False:
|
||||
call_threaded(self.session.api_call, call_name="status_mute", preexec_message=_("Muting conversation..."), _sound="favourite.ogg", id=item.id)
|
||||
pub.sendMessage("mastodon.mute_cleanup", conversation_id=item.conversation_id, session_name=self.session.get_name())
|
||||
else:
|
||||
call_threaded(self.session.api_call, call_name="status_unmute", preexec_message=_("Unmuting conversation..."), _sound="favourite.ogg", id=item.id)
|
||||
|
||||
def view_item(self, item=None):
|
||||
if item == None:
|
||||
item = self.get_item()
|
||||
|
||||
@@ -33,10 +33,7 @@ class CommunityBuffer(base.BaseBuffer):
|
||||
min_id = None
|
||||
# toDo: Implement reverse timelines properly here.
|
||||
if self.name in self.session.db and len(self.session.db[self.name]) > 0:
|
||||
if self.session.settings["general"]["reverse_timelines"]:
|
||||
min_id = self.session.db[self.name][0].id
|
||||
else:
|
||||
min_id = self.session.db[self.name][-1].id
|
||||
min_id = max(item.id for item in self.session.db[self.name])
|
||||
try:
|
||||
results = self.community_api.timeline(timeline=self.timeline, min_id=min_id, limit=count, *self.args, **self.kwargs)
|
||||
results.reverse()
|
||||
@@ -55,10 +52,15 @@ class CommunityBuffer(base.BaseBuffer):
|
||||
|
||||
def get_more_items(self):
|
||||
elements = []
|
||||
if self.session.settings["general"]["reverse_timelines"] == False:
|
||||
max_id = self.session.db[self.name][0].id
|
||||
if len(self.session.db[self.name]) == 0:
|
||||
return
|
||||
|
||||
unpinned_ids = [item.id for item in self.session.db[self.name] if not getattr(item, "pinned", False)]
|
||||
if unpinned_ids:
|
||||
max_id = min(unpinned_ids)
|
||||
else:
|
||||
max_id = self.session.db[self.name][-1].id
|
||||
max_id = min(item.id for item in self.session.db[self.name])
|
||||
|
||||
try:
|
||||
items = self.community_api.timeline(timeline=self.timeline, max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], *self.args, **self.kwargs)
|
||||
except Exception as e:
|
||||
|
||||
@@ -42,10 +42,22 @@ class MentionsBuffer(BaseBuffer):
|
||||
|
||||
def get_more_items(self):
|
||||
elements = []
|
||||
if self.session.settings["general"]["reverse_timelines"] == False:
|
||||
max_id = self.session.db[self.name][0].id
|
||||
else:
|
||||
max_id = self.session.db[self.name][-1].id
|
||||
if len(self.session.db[self.name]) == 0:
|
||||
return
|
||||
|
||||
# In mentions buffer, items are notification objects which don't have 'pinned' attribute directly.
|
||||
# But we check the status attached to the notification if it exists.
|
||||
# However, notifications are strictly chronological usually. Pinned mentions don't exist?
|
||||
# But let's stick to the safe ID extraction.
|
||||
# The logic here is tricky because self.session.db stores notification objects, but sometimes just dicts?
|
||||
# Let's assume they are objects with 'id' attribute.
|
||||
# Notifications don't have 'pinned', so we just take the min ID.
|
||||
# But wait, did I change this file previously to use min()? Yes.
|
||||
# Is there any case where a notification ID is "pinned" (old)? No.
|
||||
# So min() should be fine here. But for consistency with other buffers if any weird logic exists...
|
||||
# Actually, let's keep min() as notifications don't support pinning.
|
||||
|
||||
max_id = min(item.id for item in self.session.db[self.name])
|
||||
try:
|
||||
items = getattr(self.session.api, self.function)(max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], types=["mention"], *self.args, **self.kwargs)
|
||||
except Exception as e:
|
||||
|
||||
@@ -161,6 +161,13 @@ class NotificationsBuffer(BaseBuffer):
|
||||
menu = menus.notification(notification.type)
|
||||
if self.is_post():
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
|
||||
# Enable/disable edit based on whether the post belongs to the user
|
||||
if hasattr(menu, 'edit'):
|
||||
status = self.get_post()
|
||||
if status and status.account.id == self.session.db["user_id"] and status.reblog == None:
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.edit_status, menuitem=menu.edit)
|
||||
else:
|
||||
menu.edit.Enable(False)
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
|
||||
if self.can_share() == True:
|
||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
|
||||
|
||||
@@ -33,10 +33,7 @@ class SearchBuffer(BaseBuffer):
|
||||
self.execution_time = current_time
|
||||
min_id = None
|
||||
if self.name in self.session.db and len(self.session.db[self.name]) > 0:
|
||||
if self.session.settings["general"]["reverse_timelines"]:
|
||||
min_id = self.session.db[self.name][0].id
|
||||
else:
|
||||
min_id = self.session.db[self.name][-1].id
|
||||
min_id = max(item.id for item in self.session.db[self.name])
|
||||
try:
|
||||
results = getattr(self.session.api, self.function)(min_id=min_id, **self.kwargs)
|
||||
except Exception as mess:
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import webbrowser
|
||||
import wx
|
||||
import requests
|
||||
import asyncio
|
||||
import keystrokeEditor
|
||||
import sessions
|
||||
import widgetUtils
|
||||
@@ -25,6 +26,7 @@ from mysc import localization
|
||||
from mysc.thread_utils import call_threaded
|
||||
from mysc.repeating_timer import RepeatingTimer
|
||||
from controller.mastodon import handler as MastodonHandler
|
||||
from controller.blueski import handler as BlueskiHandler # Added import
|
||||
from . import settings, userAlias
|
||||
|
||||
log = logging.getLogger("mainController")
|
||||
@@ -93,6 +95,17 @@ class Controller(object):
|
||||
[results.append(self.search_buffer(i.name, i.account)) for i in buffers if i.account == account and (i.type != "account")]
|
||||
return results
|
||||
|
||||
def get_handler(self, type):
|
||||
"""Return the controller handler for a given session type."""
|
||||
try:
|
||||
if type == "mastodon":
|
||||
return MastodonHandler.Handler()
|
||||
if type == "blueski":
|
||||
return BlueskiHandler.Handler()
|
||||
except Exception:
|
||||
log.exception("Error creating handler for type %s", type)
|
||||
return None
|
||||
|
||||
def bind_other_events(self):
|
||||
""" Binds the local application events with their functions."""
|
||||
log.debug("Binding other application events...")
|
||||
@@ -107,6 +120,7 @@ class Controller(object):
|
||||
pub.subscribe(self.invisible_shorcuts_changed, "invisible-shorcuts-changed")
|
||||
pub.subscribe(self.create_account_buffer, "core.create_account")
|
||||
pub.subscribe(self.change_buffer_title, "core.change_buffer_title")
|
||||
pub.subscribe(self.handle_compose_dialog_send, "compose_dialog.send_post") # For new compose dialog
|
||||
|
||||
# Mastodon specific events.
|
||||
pub.subscribe(self.mastodon_new_item, "mastodon.new_item")
|
||||
@@ -114,6 +128,9 @@ class Controller(object):
|
||||
pub.subscribe(self.mastodon_new_conversation, "mastodon.conversation_received")
|
||||
pub.subscribe(self.mastodon_error_post, "mastodon.error_post")
|
||||
|
||||
# Bluesky specific events.
|
||||
pub.subscribe(self.blueski_new_item, "blueski.new_item")
|
||||
|
||||
# connect application events to GUI
|
||||
widgetUtils.connect_event(self.view, widgetUtils.CLOSE_EVENT, self.exit_)
|
||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.show_hide, menuitem=self.view.show_hide)
|
||||
@@ -191,10 +208,12 @@ class Controller(object):
|
||||
|
||||
def get_handler(self, type):
|
||||
handler = self.handlers.get(type)
|
||||
if handler == None:
|
||||
if handler is None:
|
||||
if type == "mastodon":
|
||||
handler = MastodonHandler.Handler()
|
||||
self.handlers[type]=handler
|
||||
elif type == "blueski":
|
||||
handler = BlueskiHandler.Handler()
|
||||
self.handlers[type] = handler
|
||||
return handler
|
||||
|
||||
def __init__(self):
|
||||
@@ -204,7 +223,7 @@ class Controller(object):
|
||||
# main window
|
||||
self.view = view.mainFrame()
|
||||
# buffers list.
|
||||
self.buffers = []
|
||||
self.buffers: list[buffers.base.Buffer] = [] # Added type hint
|
||||
self.started = False
|
||||
# accounts list.
|
||||
self.accounts = []
|
||||
@@ -235,14 +254,26 @@ class Controller(object):
|
||||
for i in sessions.sessions:
|
||||
log.debug("Working on session %s" % (i,))
|
||||
if sessions.sessions[i].is_logged == False:
|
||||
self.create_ignored_session_buffer(sessions.sessions[i])
|
||||
continue
|
||||
# Valid types currently are mastodon (Work in progress)
|
||||
# More can be added later.
|
||||
valid_session_types = ["mastodon"]
|
||||
if sessions.sessions[i].session_id in config.app["sessions"]["ignored_sessions"]:
|
||||
self.create_ignored_session_buffer(sessions.sessions[i])
|
||||
continue
|
||||
# Try auto-login for sessions if credentials exist
|
||||
try:
|
||||
sessions.sessions[i].login()
|
||||
except Exception:
|
||||
log.exception("Auto-login attempt failed for session %s", i)
|
||||
if sessions.sessions[i].is_logged == False:
|
||||
self.create_ignored_session_buffer(sessions.sessions[i])
|
||||
continue
|
||||
# Supported session types
|
||||
valid_session_types = ["mastodon", "blueski"]
|
||||
if sessions.sessions[i].type in valid_session_types:
|
||||
handler = self.get_handler(type=sessions.sessions[i].type)
|
||||
handler.create_buffers(sessions.sessions[i], controller=self)
|
||||
try:
|
||||
handler = self.get_handler(type=sessions.sessions[i].type)
|
||||
if handler is not None:
|
||||
handler.create_buffers(sessions.sessions[i], controller=self)
|
||||
except Exception:
|
||||
log.exception("Error creating buffers for session %s (%s)", i, sessions.sessions[i].type)
|
||||
log.debug("Setting updates to buffers every %d seconds..." % (60*config.app["app-settings"]["update_period"],))
|
||||
self.update_buffers_function = RepeatingTimer(60*config.app["app-settings"]["update_period"], self.update_buffers)
|
||||
self.update_buffers_function.start()
|
||||
@@ -251,29 +282,83 @@ class Controller(object):
|
||||
""" Starts all buffer objects. Loads their items."""
|
||||
for i in sessions.sessions:
|
||||
if sessions.sessions[i].is_logged == False: continue
|
||||
self.start_buffers(sessions.sessions[i])
|
||||
self.set_buffer_positions(sessions.sessions[i])
|
||||
if hasattr(sessions.sessions[i], "start_streaming"):
|
||||
sessions.sessions[i].start_streaming()
|
||||
if config.app["app-settings"]["play_ready_sound"] == True:
|
||||
call_threaded(self._start_session_buffers, sessions.sessions[i])
|
||||
if len(sessions.sessions) > 0 and config.app["app-settings"]["play_ready_sound"] == True:
|
||||
sessions.sessions[list(sessions.sessions.keys())[0]].sound.play("ready.ogg")
|
||||
if config.app["app-settings"]["speak_ready_msg"] == True:
|
||||
output.speak(_(u"Ready"))
|
||||
self.started = True
|
||||
if len(self.accounts) > 0:
|
||||
b = self.get_first_buffer(self.accounts[0])
|
||||
self.menubar_current_handler = b.session.type
|
||||
self.update_menus(handler=self.get_handler(b.session.type))
|
||||
|
||||
def _start_session_buffers(self, session):
|
||||
"""Helper to start buffers for a session in a background thread."""
|
||||
try:
|
||||
self.start_buffers(session)
|
||||
self.set_buffer_positions(session)
|
||||
if hasattr(session, "start_streaming"):
|
||||
session.start_streaming()
|
||||
except Exception:
|
||||
log.exception("Error starting buffers for session %s", session.session_id)
|
||||
|
||||
def create_ignored_session_buffer(self, session):
|
||||
pub.sendMessage("core.create_account", name=session.get_name(), session_id=session.session_id)
|
||||
|
||||
def login_account(self, session_id):
|
||||
session = None
|
||||
for i in sessions.sessions:
|
||||
if sessions.sessions[i].session_id == session_id: session = sessions.sessions[i]
|
||||
session.login()
|
||||
if sessions.sessions[i].session_id == session_id:
|
||||
session = sessions.sessions[i]
|
||||
break
|
||||
if not session:
|
||||
return
|
||||
|
||||
old_name = session.get_name()
|
||||
try:
|
||||
session.login()
|
||||
except Exception as e:
|
||||
log.exception("Login failed for session %s", session_id)
|
||||
output.speak(_("Login failed for {0}: {1}").format(old_name, str(e)), True)
|
||||
return
|
||||
|
||||
if not session.logged:
|
||||
output.speak(_("Login failed for {0}. Please check your credentials.").format(old_name), True)
|
||||
return
|
||||
|
||||
new_name = session.get_name()
|
||||
if old_name != new_name:
|
||||
log.info(f"Account name changed from {old_name} to {new_name} after login")
|
||||
if self.current_account == old_name:
|
||||
self.current_account = new_name
|
||||
if old_name in self.accounts:
|
||||
idx = self.accounts.index(old_name)
|
||||
self.accounts[idx] = new_name
|
||||
else:
|
||||
self.accounts.append(new_name)
|
||||
|
||||
# Update root buffer name and account
|
||||
for b in self.buffers:
|
||||
if b.account == old_name:
|
||||
b.account = new_name
|
||||
if hasattr(b, "buffer"):
|
||||
b.buffer.account = new_name
|
||||
# If this is the root node, its name matches old_name (e.g. "Bluesky")
|
||||
if b.name == old_name:
|
||||
b.name = new_name
|
||||
if hasattr(b, "buffer"):
|
||||
b.buffer.name = new_name
|
||||
|
||||
# Update tree node label
|
||||
self.change_buffer_title(old_name, old_name, new_name)
|
||||
|
||||
handler = self.get_handler(type=session.type)
|
||||
if handler != None and hasattr(handler, "create_buffers"):
|
||||
handler.create_buffers(session=session, controller=self, createAccounts=False)
|
||||
try:
|
||||
handler.create_buffers(session=session, controller=self, createAccounts=False)
|
||||
except Exception:
|
||||
log.exception("Error creating buffers after login for session %s (%s)", session.session_id, session.type)
|
||||
self.start_buffers(session)
|
||||
if hasattr(session, "start_streaming"):
|
||||
session.start_streaming()
|
||||
@@ -287,31 +372,91 @@ class Controller(object):
|
||||
self.view.add_buffer(account.buffer , name=name)
|
||||
|
||||
def create_buffer(self, buffer_type="baseBuffer", session_type="twitter", buffer_title="", parent_tab=None, start=False, kwargs={}):
|
||||
# Copy kwargs to avoid mutating a shared dict across calls
|
||||
if not isinstance(kwargs, dict):
|
||||
kwargs = {}
|
||||
else:
|
||||
kwargs = dict(kwargs)
|
||||
log.debug("Creating buffer of type {0} with parent_tab of {2} arguments {1}".format(buffer_type, kwargs, parent_tab))
|
||||
if kwargs.get("parent") == None:
|
||||
kwargs["parent"] = self.view.nb
|
||||
if not hasattr(buffers, session_type):
|
||||
if not hasattr(buffers, session_type) and session_type != "blueski": # Allow blueski to be handled separately
|
||||
raise AttributeError("Session type %s does not exist yet." % (session_type))
|
||||
available_buffers = getattr(buffers, session_type)
|
||||
if not hasattr(available_buffers, buffer_type):
|
||||
raise AttributeError("Specified buffer type does not exist: %s" % (buffer_type,))
|
||||
buffer = getattr(available_buffers, buffer_type)(**kwargs)
|
||||
if start:
|
||||
if kwargs.get("function") == "user_timeline":
|
||||
|
||||
try:
|
||||
buffer_panel_class = None
|
||||
if session_type == "blueski":
|
||||
from controller.buffers.blueski import timeline as BlueskiTimelines
|
||||
from controller.buffers.blueski import user as BlueskiUsers
|
||||
from controller.buffers.blueski import chat as BlueskiChats
|
||||
|
||||
if "user_id" in kwargs and "session" not in kwargs:
|
||||
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
||||
|
||||
if "name" not in kwargs: kwargs["name"] = buffer_title
|
||||
|
||||
buffer_map = {
|
||||
"home_timeline": BlueskiTimelines.HomeTimeline,
|
||||
"following_timeline": BlueskiTimelines.FollowingTimeline,
|
||||
"notifications": BlueskiTimelines.NotificationBuffer,
|
||||
"conversation": BlueskiTimelines.Conversation,
|
||||
"likes": BlueskiTimelines.LikesBuffer,
|
||||
"MentionsBuffer": BlueskiTimelines.MentionsBuffer,
|
||||
"mentions": BlueskiTimelines.MentionsBuffer,
|
||||
"SentBuffer": BlueskiTimelines.SentBuffer,
|
||||
"sent": BlueskiTimelines.SentBuffer,
|
||||
"SearchBuffer": BlueskiTimelines.SearchBuffer,
|
||||
"search": BlueskiTimelines.SearchBuffer,
|
||||
"UserBuffer": BlueskiUsers.UserBuffer,
|
||||
"FollowersBuffer": BlueskiUsers.FollowersBuffer,
|
||||
"FollowingBuffer": BlueskiUsers.FollowingBuffer,
|
||||
"BlocksBuffer": BlueskiUsers.BlocksBuffer,
|
||||
"PostUserListBuffer": BlueskiUsers.PostUserListBuffer,
|
||||
"ConversationListBuffer": BlueskiChats.ConversationListBuffer,
|
||||
"ChatMessageBuffer": BlueskiChats.ChatBuffer,
|
||||
"chat_messages": BlueskiChats.ChatBuffer,
|
||||
"UserTimeline": BlueskiTimelines.UserTimeline,
|
||||
"user_timeline": BlueskiTimelines.UserTimeline,
|
||||
}
|
||||
|
||||
buffer_panel_class = buffer_map.get(buffer_type)
|
||||
if buffer_panel_class is None:
|
||||
# Fallback for others including user_timeline to HomeTimeline for now
|
||||
log.warning(f"Unsupported Blueski buffer type: {buffer_type}. Falling back to HomeTimeline.")
|
||||
buffer_panel_class = BlueskiTimelines.HomeTimeline
|
||||
else: # Existing logic for other session types
|
||||
available_buffers = getattr(buffers, session_type)
|
||||
if not hasattr(available_buffers, buffer_type):
|
||||
raise AttributeError("Specified buffer type does not exist: %s" % (buffer_type,))
|
||||
buffer_panel_class = getattr(available_buffers, buffer_type)
|
||||
|
||||
# Instantiate the panel
|
||||
# Ensure 'parent' kwarg is correctly set if not already
|
||||
if "parent" not in kwargs:
|
||||
kwargs["parent"] = self.view.nb # self.view.nb is the wx.Treebook
|
||||
|
||||
buffer = buffer_panel_class(**kwargs) # This is the wx.Panel instance
|
||||
buffer.controller = self
|
||||
|
||||
if start:
|
||||
try:
|
||||
buffer.start_stream(play_sound=False)
|
||||
if hasattr(buffer, "start_stream"):
|
||||
if kwargs.get("function") == "user_timeline":
|
||||
buffer.start_stream(mandatory=True, play_sound=False)
|
||||
else:
|
||||
buffer.start_stream(mandatory=True, play_sound=True)
|
||||
except ValueError:
|
||||
commonMessageDialogs.unauthorized()
|
||||
return
|
||||
self.buffers.append(buffer)
|
||||
if parent_tab == None:
|
||||
log.debug("Appending buffer {}...".format(buffer,))
|
||||
self.view.add_buffer(buffer.buffer, buffer_title)
|
||||
else:
|
||||
call_threaded(buffer.start_stream)
|
||||
self.buffers.append(buffer)
|
||||
if parent_tab == None:
|
||||
log.debug("Appending buffer {}...".format(buffer,))
|
||||
self.view.add_buffer(buffer.buffer, buffer_title)
|
||||
else:
|
||||
self.view.insert_buffer(buffer.buffer, buffer_title, parent_tab)
|
||||
log.debug("Inserting buffer {0} into control {1}".format(buffer, parent_tab))
|
||||
self.view.insert_buffer(buffer.buffer, buffer_title, parent_tab)
|
||||
log.debug("Inserting buffer {0} into control {1}".format(buffer, parent_tab))
|
||||
except Exception:
|
||||
log.exception("Error creating buffer '%s' for session_type '%s'", buffer_type, session_type)
|
||||
|
||||
def set_buffer_positions(self, session):
|
||||
"Sets positions for buffers if values exist in the database."
|
||||
@@ -449,6 +594,15 @@ class Controller(object):
|
||||
buffer = self.search_buffer(buffer.name, buffer.account)
|
||||
buffer.destroy_status()
|
||||
|
||||
def edit_post(self, *args, **kwargs):
|
||||
""" Edits a post in the current buffer.
|
||||
Users can only edit their own posts."""
|
||||
buffer = self.view.get_current_buffer()
|
||||
if hasattr(buffer, "account"):
|
||||
buffer = self.search_buffer(buffer.name, buffer.account)
|
||||
if hasattr(buffer, "edit_status"):
|
||||
buffer.edit_status()
|
||||
|
||||
def exit(self, *args, **kwargs):
|
||||
if config.app["app-settings"]["ask_at_exit"] == True:
|
||||
answer = commonMessageDialogs.exit_dialog(self.view)
|
||||
@@ -494,30 +648,216 @@ class Controller(object):
|
||||
if hasattr(buffer, "post_status"):
|
||||
buffer.post_status()
|
||||
|
||||
def handle_compose_dialog_send(self, session, text, files, reply_to, cw_text, is_sensitive, kwargs, dialog_instance):
|
||||
"""Handles the actual sending of a post after ComposeDialog publishes data."""
|
||||
async def do_send_post():
|
||||
try:
|
||||
wx.CallAfter(dialog_instance.send_btn.Disable)
|
||||
wx.CallAfter(wx.BeginBusyCursor)
|
||||
|
||||
post_uri = await session.send_message(
|
||||
message=text,
|
||||
files=files,
|
||||
reply_to=reply_to,
|
||||
cw_text=cw_text,
|
||||
is_sensitive=is_sensitive,
|
||||
**kwargs
|
||||
)
|
||||
if post_uri:
|
||||
output.speak(_("Post sent successfully!"), True)
|
||||
wx.CallAfter(dialog_instance.EndModal, wx.ID_OK)
|
||||
# Optionally, add to relevant buffer or update UI
|
||||
# This might involve fetching the new post and adding to message_cache and posts_buffer
|
||||
# new_post_data = await session.util.get_post_by_uri(post_uri) # Assuming such a util method
|
||||
# if new_post_data:
|
||||
# await self.check_buffers(new_post_data) # check_buffers needs to handle PostView or dict
|
||||
else:
|
||||
# This case should ideally be handled by send_message raising an error
|
||||
output.speak(_("Failed to send post. The server did not confirm the post creation."), True)
|
||||
wx.CallAfter(dialog_instance.send_btn.Enable, True)
|
||||
|
||||
except NotificationError as e:
|
||||
logger.error(f"NotificationError sending post via dialog: {e}", exc_info=True)
|
||||
output.speak(_("Error sending post: {error}").format(error=str(e)), True)
|
||||
wx.CallAfter(wx.MessageBox, str(e), _("Post Error"), wx.OK | wx.ICON_ERROR, dialog_instance)
|
||||
if not dialog_instance.IsBeingDeleted(): wx.CallAfter(dialog_instance.send_btn.Enable, True)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error sending post via dialog: {e}", exc_info=True)
|
||||
output.speak(_("An unexpected error occurred: {error}").format(error=str(e)), True)
|
||||
wx.CallAfter(wx.MessageBox, str(e), _("Error"), wx.OK | wx.ICON_ERROR, dialog_instance)
|
||||
if not dialog_instance.IsBeingDeleted(): wx.CallAfter(dialog_instance.send_btn.Enable, True)
|
||||
finally:
|
||||
if not dialog_instance.IsBeingDeleted(): wx.CallAfter(wx.EndBusyCursor)
|
||||
|
||||
asyncio.create_task(do_send_post())
|
||||
|
||||
|
||||
def post_reply(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
if hasattr(buffer, "reply"):
|
||||
return buffer.reply()
|
||||
|
||||
|
||||
def send_dm(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
if buffer is None:
|
||||
output.speak(_("No buffer selected."), True)
|
||||
return
|
||||
if hasattr(buffer, "send_message"):
|
||||
buffer.send_message()
|
||||
else:
|
||||
output.speak(_("Cannot send messages from this buffer."), True)
|
||||
|
||||
def post_retweet(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
if hasattr(buffer, "share_item"):
|
||||
return buffer.share_item()
|
||||
session = getattr(buffer, "session", None)
|
||||
if not session:
|
||||
return
|
||||
if getattr(session, "type", "") == "blueski":
|
||||
item_uri = None
|
||||
if hasattr(buffer, "get_selected_item_id"):
|
||||
item_uri = buffer.get_selected_item_id()
|
||||
if not item_uri:
|
||||
output.speak(_("No item selected."), True)
|
||||
return
|
||||
|
||||
if self.showing == False:
|
||||
dlg = wx.TextEntryDialog(None, _("Write your quote (optional):"), _("Quote"))
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
text = dlg.GetValue().strip()
|
||||
dlg.Destroy()
|
||||
try:
|
||||
if text:
|
||||
uri = session.send_message(text, quote_uri=item_uri)
|
||||
if uri:
|
||||
output.speak(_("Quote posted."), True)
|
||||
else:
|
||||
output.speak(_("Failed to send quote."), True)
|
||||
else:
|
||||
# Confirm repost (share) depending on preference (boost_mode)
|
||||
ask = True
|
||||
try:
|
||||
ask = session.settings["general"].get("boost_mode", "ask") == "ask"
|
||||
except Exception:
|
||||
ask = True
|
||||
if ask:
|
||||
confirm = wx.MessageDialog(None, _("Would you like to share this post?"), _("Boost"), wx.YES_NO|wx.ICON_QUESTION)
|
||||
if confirm.ShowModal() != wx.ID_YES:
|
||||
confirm.Destroy()
|
||||
return
|
||||
confirm.Destroy()
|
||||
r_uri = session.repost(item_uri)
|
||||
if r_uri:
|
||||
output.speak(_("Post shared."), True)
|
||||
else:
|
||||
output.speak(_("Failed to share post."), True)
|
||||
except Exception:
|
||||
log.exception("Error sharing/quoting Bluesky post (invisible)")
|
||||
output.speak(_("An error occurred while sharing the post."), True)
|
||||
else:
|
||||
dlg.Destroy()
|
||||
return
|
||||
|
||||
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
|
||||
dlg = ATPostDialog(caption=_("Quote post"))
|
||||
if dlg.ShowModal() == wx.ID_OK:
|
||||
text, files, cw_text, langs = dlg.get_payload()
|
||||
dlg.Destroy()
|
||||
try:
|
||||
if text or files or cw_text:
|
||||
uri = session.send_message(text, files=files, cw_text=cw_text, is_sensitive=bool(cw_text), languages=langs, quote_uri=item_uri)
|
||||
if uri:
|
||||
output.speak(_("Quote posted."), True)
|
||||
try:
|
||||
if hasattr(buffer, "start_stream"):
|
||||
buffer.start_stream(mandatory=False, play_sound=False)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
output.speak(_("Failed to send quote."), True)
|
||||
else:
|
||||
# Confirm repost without comment depending on preference
|
||||
ask = True
|
||||
try:
|
||||
ask = session.settings["general"].get("boost_mode", "ask") == "ask"
|
||||
except Exception:
|
||||
ask = True
|
||||
if ask:
|
||||
confirm = wx.MessageDialog(self.view, _("Would you like to share this post?"), _("Boost"), wx.YES_NO|wx.ICON_QUESTION)
|
||||
if confirm.ShowModal() != wx.ID_YES:
|
||||
confirm.Destroy()
|
||||
return
|
||||
confirm.Destroy()
|
||||
r_uri = session.repost(item_uri)
|
||||
if r_uri:
|
||||
output.speak(_("Post shared."), True)
|
||||
else:
|
||||
output.speak(_("Failed to share post."), True)
|
||||
except Exception:
|
||||
log.exception("Error sharing/quoting Bluesky post (dialog)")
|
||||
output.speak(_("An error occurred while sharing the post."), True)
|
||||
else:
|
||||
dlg.Destroy()
|
||||
return
|
||||
|
||||
def add_to_favourites(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
if hasattr(buffer, "add_to_favorites"):
|
||||
if hasattr(buffer, "add_to_favorites"): # Generic buffer method
|
||||
return buffer.add_to_favorites()
|
||||
elif hasattr(buffer, "toggle_favorite"):
|
||||
return buffer.toggle_favorite()
|
||||
elif buffer.session and buffer.session.KIND == "blueski":
|
||||
# Fallback if buffer doesn't have the method but session is blueski (e.g. ChatBuffer)
|
||||
# Chat messages can't be liked yet in this implementation, or handled by specific buffer
|
||||
output.speak(_("This item cannot be liked."), True)
|
||||
return
|
||||
|
||||
|
||||
def remove_from_favourites(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
if hasattr(buffer, "remove_from_favorites"):
|
||||
if hasattr(buffer, "remove_from_favorites"): # Generic buffer method
|
||||
return buffer.remove_from_favorites()
|
||||
elif buffer.session and buffer.session.KIND == "blueski":
|
||||
item_uri = buffer.get_selected_item_id()
|
||||
if not item_uri:
|
||||
output.speak(_("No item selected to unlike."), True)
|
||||
return
|
||||
|
||||
like_uri = None
|
||||
# Check viewer state from message_cache first, then panel's internal viewer_states
|
||||
if buffer.session and hasattr(buffer.session, "message_cache") and item_uri in buffer.session.message_cache:
|
||||
cached_post = buffer.session.message_cache[item_uri]
|
||||
if isinstance(cached_post, dict) and isinstance(cached_post.get("viewer"), dict):
|
||||
like_uri = cached_post["viewer"].get("like")
|
||||
elif hasattr(cached_post, "viewer") and cached_post.viewer: # SDK model
|
||||
like_uri = cached_post.viewer.like
|
||||
|
||||
if not like_uri and hasattr(buffer, "get_item_viewer_state"): # Fallback to panel's state if any
|
||||
like_uri = buffer.get_item_viewer_state(item_uri, "like_uri")
|
||||
|
||||
if not like_uri:
|
||||
output.speak(_("Could not find the original like record for this post, or it's already unliked."), True)
|
||||
logger.warning(f"Attempted to unlike post {item_uri} but its like_uri was not found.")
|
||||
return
|
||||
|
||||
social_handler = self.get_handler(buffer.session.KIND)
|
||||
async def _unlike():
|
||||
result = await social_handler.unlike_item(buffer.session, like_uri)
|
||||
wx.CallAfter(output.speak, result["message"], True)
|
||||
if result.get("status") == "success":
|
||||
if hasattr(buffer, "store_item_viewer_state"):
|
||||
wx.CallAfter(buffer.store_item_viewer_state, item_uri, "like_uri", None)
|
||||
# Also update the item in message_cache
|
||||
if buffer.session and hasattr(buffer.session, "message_cache") and item_uri in buffer.session.message_cache:
|
||||
cached_post = buffer.session.message_cache[item_uri]
|
||||
if isinstance(cached_post, dict) and isinstance(cached_post.get("viewer"), dict):
|
||||
cached_post["viewer"]["like"] = None
|
||||
elif hasattr(cached_post, "viewer") and cached_post.viewer:
|
||||
cached_post.viewer.like = None
|
||||
asyncio.create_task(_unlike())
|
||||
|
||||
|
||||
def toggle_like(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
@@ -604,6 +944,8 @@ class Controller(object):
|
||||
|
||||
def buffer_changed(self, *args, **kwargs):
|
||||
buffer = self.get_current_buffer()
|
||||
if buffer is None:
|
||||
return
|
||||
old_account = self.current_account
|
||||
new_account = buffer.account
|
||||
if new_account != old_account:
|
||||
@@ -624,18 +966,81 @@ class Controller(object):
|
||||
self.view.check_menuitem("autoread", autoread)
|
||||
|
||||
def update_menus(self, handler):
|
||||
# Initialize storage for hidden menu items if not present
|
||||
if not hasattr(self, "_hidden_menu_items"):
|
||||
self._hidden_menu_items = {}
|
||||
if not hasattr(self, "_original_menu_labels"):
|
||||
self._original_menu_labels = {}
|
||||
|
||||
if hasattr(handler, "menus"):
|
||||
for m in list(handler.menus.keys()):
|
||||
if hasattr(self.view, m):
|
||||
menu_item = getattr(self.view, m)
|
||||
if handler.menus[m] == None:
|
||||
# Store original label on first encounter
|
||||
if m not in self._original_menu_labels:
|
||||
self._original_menu_labels[m] = menu_item.GetItemLabel()
|
||||
|
||||
if handler.menus[m] == "HIDE":
|
||||
# Actually hide the menu item by removing it from parent menu
|
||||
if m not in self._hidden_menu_items:
|
||||
try:
|
||||
parent_menu = menu_item.GetMenu()
|
||||
if parent_menu:
|
||||
# Store menu item info for restoration
|
||||
position = self._find_menu_item_position(parent_menu, menu_item)
|
||||
item_id = menu_item.GetId()
|
||||
# Remove returns the removed item - store that reference
|
||||
removed_item = parent_menu.Remove(item_id)
|
||||
if removed_item:
|
||||
self._hidden_menu_items[m] = {
|
||||
"menu": parent_menu,
|
||||
"item": removed_item,
|
||||
"position": position
|
||||
}
|
||||
except Exception as e:
|
||||
log.error(f"Error hiding menu item {m}: {e}")
|
||||
elif handler.menus[m] == None:
|
||||
# Restore if hidden, then disable
|
||||
self._restore_menu_item(m)
|
||||
menu_item.Enable(False)
|
||||
else:
|
||||
# Restore if hidden, then enable with new label
|
||||
self._restore_menu_item(m)
|
||||
menu_item.Enable(True)
|
||||
menu_item.SetItemLabel(handler.menus[m])
|
||||
if hasattr(handler, "item_menu"):
|
||||
self.view.menubar.SetMenuLabel(1, handler.item_menu)
|
||||
|
||||
def _find_menu_item_position(self, menu, item):
|
||||
"""Find the position of a menu item within its parent menu."""
|
||||
for i, menu_item in enumerate(menu.GetMenuItems()):
|
||||
if menu_item.GetId() == item.GetId():
|
||||
return i
|
||||
return -1
|
||||
|
||||
def _restore_menu_item(self, name):
|
||||
"""Restore a previously hidden menu item."""
|
||||
if not hasattr(self, "_hidden_menu_items"):
|
||||
return
|
||||
if name not in self._hidden_menu_items:
|
||||
return
|
||||
info = self._hidden_menu_items[name]
|
||||
parent_menu = info["menu"]
|
||||
item = info["item"]
|
||||
position = info["position"]
|
||||
try:
|
||||
# Re-insert at original position
|
||||
if position >= 0 and position < parent_menu.GetMenuItemCount():
|
||||
parent_menu.Insert(position, item)
|
||||
else:
|
||||
parent_menu.Append(item)
|
||||
# Restore original label if available
|
||||
if hasattr(self, "_original_menu_labels") and name in self._original_menu_labels:
|
||||
item.SetItemLabel(self._original_menu_labels[name])
|
||||
except Exception as e:
|
||||
log.error(f"Error restoring menu item {name}: {e}")
|
||||
del self._hidden_menu_items[name]
|
||||
|
||||
def fix_wrong_buffer(self):
|
||||
buf = self.get_best_buffer()
|
||||
if buf == None:
|
||||
@@ -724,6 +1129,9 @@ class Controller(object):
|
||||
output.speak(msg, True)
|
||||
|
||||
def next_account(self, *args, **kwargs):
|
||||
if not self.accounts:
|
||||
output.speak(_("No accounts available."), True)
|
||||
return
|
||||
try:
|
||||
index = self.accounts.index(self.current_account)
|
||||
except ValueError:
|
||||
@@ -736,11 +1144,11 @@ class Controller(object):
|
||||
self.current_account = account
|
||||
buffer_object = self.get_first_buffer(account)
|
||||
if buffer_object == None:
|
||||
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
|
||||
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
|
||||
return
|
||||
buff = self.view.search(buffer_object.name, account)
|
||||
if buff == None:
|
||||
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
|
||||
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
|
||||
return
|
||||
self.view.change_buffer(buff)
|
||||
buffer = self.get_current_buffer()
|
||||
@@ -752,6 +1160,9 @@ class Controller(object):
|
||||
output.speak(msg, True)
|
||||
|
||||
def previous_account(self, *args, **kwargs):
|
||||
if not self.accounts:
|
||||
output.speak(_("No accounts available."), True)
|
||||
return
|
||||
try:
|
||||
index = self.accounts.index(self.current_account)
|
||||
except ValueError:
|
||||
@@ -764,11 +1175,11 @@ class Controller(object):
|
||||
self.current_account = account
|
||||
buffer_object = self.get_first_buffer(account)
|
||||
if buffer_object == None:
|
||||
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
|
||||
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
|
||||
return
|
||||
buff = self.view.search(buffer_object.name, account)
|
||||
if buff == None:
|
||||
output.speak(_(u"{0}: This account is not logged into twitter.").format(account), True)
|
||||
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
|
||||
return
|
||||
self.view.change_buffer(buff)
|
||||
buffer = self.get_current_buffer()
|
||||
@@ -1014,14 +1425,125 @@ class Controller(object):
|
||||
log.exception("Error %s starting buffer %s on account %s, with args %r and kwargs %r." % (str(err), i.name, i.account, i.args, i.kwargs))
|
||||
|
||||
def update_buffer(self, *args, **kwargs):
|
||||
bf = self.get_current_buffer()
|
||||
if not hasattr(bf, "start_stream"):
|
||||
output.speak(_(u"Unable to update this buffer."))
|
||||
"""Handles the 'Update buffer' menu command to fetch newest items."""
|
||||
bf = self.get_current_buffer() # bf is the buffer panel instance
|
||||
if not bf or not hasattr(bf, "session") or not bf.session:
|
||||
output.speak(_(u"No active session for this buffer."), True)
|
||||
return
|
||||
output.speak(_(u"Updating buffer..."))
|
||||
n = bf.start_stream(mandatory=True, avoid_autoreading=True)
|
||||
if n != None:
|
||||
output.speak(_(u"{0} items retrieved").format(n,))
|
||||
|
||||
output.speak(_(u"Updating buffer..."), True)
|
||||
session = bf.session
|
||||
|
||||
output.speak(_(u"Updating buffer..."), True)
|
||||
session = bf.session
|
||||
|
||||
import threading
|
||||
is_blueski = (getattr(session, "KIND", None) == "blueski" or getattr(session, "type", None) == "blueski")
|
||||
|
||||
def do_update_sync():
|
||||
new_ids = []
|
||||
try:
|
||||
if is_blueski:
|
||||
if hasattr(bf, "start_stream"):
|
||||
count = bf.start_stream(mandatory=True)
|
||||
if count: new_ids = [str(x) for x in range(count)]
|
||||
else:
|
||||
wx.CallAfter(output.speak, _(u"This buffer type cannot be updated."), True)
|
||||
return
|
||||
else: # Generic fallback for other sessions
|
||||
# If they are async, this might be tricky in a thread without a loop
|
||||
# But most old sessions in TWBlue are sync (using threads)
|
||||
if hasattr(bf, "start_stream"):
|
||||
count = bf.start_stream(mandatory=True, avoid_autoreading=True)
|
||||
if count: new_ids = [str(x) for x in range(count)]
|
||||
else:
|
||||
wx.CallAfter(output.speak, _(u"Unable to update this buffer."), True)
|
||||
return
|
||||
|
||||
# Generic feedback
|
||||
if bf.type in ["home_timeline", "user_timeline", "notifications", "mentions"]:
|
||||
wx.CallAfter(output.speak, _("{0} new items.").format(len(new_ids)), True)
|
||||
|
||||
except Exception as e:
|
||||
log.exception("Error updating buffer %s", bf.name)
|
||||
wx.CallAfter(output.speak, _("An error occurred while updating the buffer."), True)
|
||||
|
||||
if is_blueski:
|
||||
threading.Thread(target=do_update_sync).start()
|
||||
else:
|
||||
# Original async logic for others if needed, but likely they are sync too.
|
||||
# Assuming TWBlue architecture is mostly thread-based for legacy sessions.
|
||||
# If we have an async loop running, we could use it for async-capable sessions.
|
||||
# For safety, let's use the thread approach generally if we are not sure about the loop state.
|
||||
threading.Thread(target=do_update_sync).start()
|
||||
|
||||
|
||||
def get_more_items(self, *args, **kwargs):
|
||||
"""Handles 'Load previous items' menu command."""
|
||||
bf = self.get_current_buffer() # bf is the buffer panel instance
|
||||
if not bf or not hasattr(bf, "session") or not bf.session:
|
||||
output.speak(_(u"No active session for this buffer."), True)
|
||||
return
|
||||
|
||||
session = bf.session
|
||||
# The buffer panel (bf) needs to store its own cursor for pagination of older items
|
||||
# e.g., bf.pagination_cursor or bf.older_items_cursor
|
||||
# This cursor should be set by the result of previous fetch_..._timeline(new_only=False) calls.
|
||||
|
||||
# For Blueski, session methods like fetch_home_timeline store their own cursor (e.g., session.home_timeline_cursor)
|
||||
# The panel (bf) itself should manage its own cursor for "load more"
|
||||
|
||||
current_cursor = None
|
||||
can_load_more_natively = False
|
||||
|
||||
if getattr(session, "KIND", None) == "blueski":
|
||||
if hasattr(bf, "load_more_posts"): # For BlueskiUserTimelinePanel & BlueskiHomeTimelinePanel
|
||||
can_load_more_natively = True
|
||||
if hasattr(bf, "load_more_posts"):
|
||||
can_load_more_natively = True
|
||||
elif hasattr(bf, "load_more_users"):
|
||||
can_load_more_natively = True
|
||||
elif bf.type == "notifications" and hasattr(bf, "load_more_notifications"): # Check for specific load_more
|
||||
can_load_more_natively = True
|
||||
elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"): # Fallback for notifications to refresh
|
||||
# If load_more_notifications doesn't exist, 'Load More' will just refresh.
|
||||
can_load_more_natively = True # It will call refresh_notifications via the final 'else'
|
||||
else:
|
||||
if hasattr(bf, "get_more_items"):
|
||||
return bf.get_more_items()
|
||||
else:
|
||||
output.speak(_(u"This buffer does not support loading more items in this way."), True)
|
||||
return
|
||||
else: # For other non-Blueski session types
|
||||
if hasattr(bf, "get_more_items"):
|
||||
return bf.get_more_items()
|
||||
else:
|
||||
output.speak(_(u"This buffer does not support loading more items."), True)
|
||||
return
|
||||
|
||||
output.speak(_(u"Loading more items..."), True)
|
||||
|
||||
async def do_load_more():
|
||||
try:
|
||||
if session.KIND == "blueski":
|
||||
if hasattr(bf, "load_more_posts"):
|
||||
await bf.load_more_posts(limit=config.app["app-settings"].get("items_per_request", 20))
|
||||
elif hasattr(bf, "load_more_users"):
|
||||
await bf.load_more_users(limit=config.app["app-settings"].get("items_per_request", 30))
|
||||
elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"):
|
||||
# This will re-fetch recent, not older. A true "load_more_notifications(cursor=...)" is needed for that.
|
||||
wx.CallAfter(output.speak, _("Refreshing notifications..."), True)
|
||||
await bf.refresh_notifications(limit=config.app["app-settings"].get("items_per_request", 20))
|
||||
# Feedback is handled by panel methods for consistency
|
||||
|
||||
except NotificationError as e:
|
||||
wx.CallAfter(output.speak, str(e), True)
|
||||
except Exception as e_general:
|
||||
logger.error(f"Error loading more items for buffer {bf.name}: {e_general}", exc_info=True)
|
||||
output.speak(_("An error occurred while loading more items."), True)
|
||||
|
||||
wx.CallAfter(asyncio.create_task, do_load_more())
|
||||
|
||||
|
||||
def buffer_title_changed(self, buffer):
|
||||
if buffer.name.endswith("-timeline"):
|
||||
@@ -1088,6 +1610,33 @@ class Controller(object):
|
||||
# if "direct_messages" not in buffer.session.settings["other_buffers"]["muted_buffers"]:
|
||||
# self.notify(buffer.session, sound_to_play)
|
||||
|
||||
def blueski_new_item(self, item, session_name, _buffers):
|
||||
"""Handle new items from Bluesky polling."""
|
||||
sound_to_play = None
|
||||
for buff in _buffers:
|
||||
buffer = self.search_buffer(buff, session_name)
|
||||
if buffer is None or buffer.session.get_name() != session_name:
|
||||
continue
|
||||
if hasattr(buffer, "add_new_item"):
|
||||
buffer.add_new_item(item)
|
||||
# Determine sound to play
|
||||
if buff == "notifications":
|
||||
sound_to_play = "notification_received.ogg"
|
||||
elif buff == "home_timeline":
|
||||
sound_to_play = "tweet_received.ogg"
|
||||
elif "timeline" in buff:
|
||||
sound_to_play = "tweet_timeline.ogg"
|
||||
else:
|
||||
sound_to_play = None
|
||||
# Play sound if buffer is not muted
|
||||
if sound_to_play is not None:
|
||||
try:
|
||||
muted = buffer.session.settings["other_buffers"].get("muted_buffers", [])
|
||||
if buff not in muted:
|
||||
self.notify(buffer.session, sound_to_play)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def mastodon_error_post(self, name, reply_to, visibility, posts, language):
|
||||
home = self.search_buffer("home_timeline", name)
|
||||
if home != None:
|
||||
@@ -1114,21 +1663,57 @@ class Controller(object):
|
||||
def user_details(self, *args):
|
||||
"""Displays a user's profile."""
|
||||
log.debug("Showing user profile...")
|
||||
buffer = self.get_best_buffer()
|
||||
buffer = self.get_current_buffer() # Use current buffer to get context if item is selected
|
||||
if not buffer or not buffer.session:
|
||||
buffer = self.get_best_buffer() # Fallback if current buffer has no session
|
||||
|
||||
if not buffer or not buffer.session:
|
||||
output.speak(_("No active session to view user details."), True)
|
||||
return
|
||||
|
||||
handler = self.get_handler(type=buffer.session.type)
|
||||
if handler and hasattr(handler, 'user_details'):
|
||||
handler.user_details(buffer)
|
||||
# The handler's user_details method is responsible for extracting context
|
||||
# (e.g., selected user) from the buffer and displaying the profile.
|
||||
# For Blueski, handler.user_details calls the ShowUserProfileDialog.
|
||||
result = handler.user_details(buffer)
|
||||
if asyncio.iscoroutine(result):
|
||||
call_threaded(asyncio.run, result)
|
||||
else:
|
||||
output.speak(_("This session type does not support viewing user details in this way."), True)
|
||||
|
||||
|
||||
def openPostTimeline(self, *args, user=None): # "user" here is often the user object from selected item
|
||||
"""Opens selected user's posts timeline. Renamed to open_user_timeline in handlers for clarity."""
|
||||
current_buffer = self.get_current_buffer()
|
||||
if not current_buffer or not current_buffer.session:
|
||||
current_buffer = self.get_best_buffer()
|
||||
|
||||
if not current_buffer or not current_buffer.session:
|
||||
output.speak(_("No active session available."), True)
|
||||
return
|
||||
|
||||
session_to_use = current_buffer.session
|
||||
handler = self.get_handler(type=session_to_use.type)
|
||||
|
||||
# Prefer the new standardized 'open_user_timeline'
|
||||
if hasattr(handler, 'open_user_timeline'):
|
||||
user_payload = user # Use passed 'user' if available
|
||||
if user_payload is None and hasattr(current_buffer, 'get_selected_item_author_details'):
|
||||
author_details = current_buffer.get_selected_item_author_details()
|
||||
if author_details:
|
||||
user_payload = author_details
|
||||
|
||||
result = handler.open_user_timeline(main_controller=self, session=session_to_use, user_payload=user_payload)
|
||||
if asyncio.iscoroutine(result):
|
||||
call_threaded(asyncio.run, result)
|
||||
|
||||
elif hasattr(handler, 'openPostTimeline'): # Fallback for older handler structure
|
||||
# This path might not correctly pass main_controller if the old handler expects it differently
|
||||
handler.openPostTimeline(self, current_buffer, user)
|
||||
else:
|
||||
output.speak(_("This session type does not support opening user timelines directly."), True)
|
||||
|
||||
def openPostTimeline(self, *args, user=None):
|
||||
"""Opens selected user's posts timeline
|
||||
Parameters:
|
||||
args: Other argument. Useful when binding to widgets.
|
||||
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
|
||||
"""
|
||||
buffer = self.get_best_buffer()
|
||||
handler = self.get_handler(type=buffer.session.type)
|
||||
if handler and hasattr(handler, 'openPostTimeline'):
|
||||
handler.openPostTimeline(self, buffer, user)
|
||||
|
||||
def openFollowersTimeline(self, *args, user=None):
|
||||
"""Opens selected user's followers timeline
|
||||
@@ -1136,10 +1721,30 @@ class Controller(object):
|
||||
args: Other argument. Useful when binding to widgets.
|
||||
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
|
||||
"""
|
||||
buffer = self.get_best_buffer()
|
||||
handler = self.get_handler(type=buffer.session.type)
|
||||
if handler and hasattr(handler, 'openFollowersTimeline'):
|
||||
handler.openFollowersTimeline(self, buffer, user)
|
||||
current_buffer = self.get_current_buffer()
|
||||
if not current_buffer or not current_buffer.session:
|
||||
current_buffer = self.get_best_buffer()
|
||||
|
||||
if not current_buffer or not current_buffer.session:
|
||||
output.speak(_("No active session available."), True)
|
||||
return
|
||||
|
||||
session_to_use = current_buffer.session
|
||||
handler = self.get_handler(type=session_to_use.type)
|
||||
|
||||
if user is None and hasattr(current_buffer, 'get_selected_item_author_details'):
|
||||
author_details = current_buffer.get_selected_item_author_details()
|
||||
if author_details: user = author_details
|
||||
|
||||
if handler and hasattr(handler, 'open_followers_timeline'):
|
||||
result = handler.open_followers_timeline(main_controller=self, session=session_to_use, user_payload=user)
|
||||
if asyncio.iscoroutine(result):
|
||||
call_threaded(asyncio.run, result)
|
||||
elif handler and hasattr(handler, 'openFollowersTimeline'): # Fallback
|
||||
handler.openFollowersTimeline(self, current_buffer, user)
|
||||
else:
|
||||
output.speak(_("This session type does not support opening followers list."), True)
|
||||
|
||||
|
||||
def openFollowingTimeline(self, *args, user=None):
|
||||
"""Opens selected user's following timeline
|
||||
@@ -1147,12 +1752,32 @@ class Controller(object):
|
||||
args: Other argument. Useful when binding to widgets.
|
||||
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
|
||||
"""
|
||||
buffer = self.get_best_buffer()
|
||||
handler = self.get_handler(type=buffer.session.type)
|
||||
if handler and hasattr(handler, 'openFollowingTimeline'):
|
||||
handler.openFollowingTimeline(self, buffer, user)
|
||||
current_buffer = self.get_current_buffer()
|
||||
if not current_buffer or not current_buffer.session:
|
||||
current_buffer = self.get_best_buffer()
|
||||
|
||||
def community_timeline(self, *args, user=None):
|
||||
if not current_buffer or not current_buffer.session:
|
||||
output.speak(_("No active session available."), True)
|
||||
return
|
||||
|
||||
session_to_use = current_buffer.session
|
||||
handler = self.get_handler(type=session_to_use.type)
|
||||
|
||||
if user is None and hasattr(current_buffer, 'get_selected_item_author_details'):
|
||||
author_details = current_buffer.get_selected_item_author_details()
|
||||
if author_details: user = author_details
|
||||
|
||||
if handler and hasattr(handler, 'open_following_timeline'):
|
||||
result = handler.open_following_timeline(main_controller=self, session=session_to_use, user_payload=user)
|
||||
if asyncio.iscoroutine(result):
|
||||
call_threaded(asyncio.run, result)
|
||||
elif handler and hasattr(handler, 'openFollowingTimeline'): # Fallback
|
||||
handler.openFollowingTimeline(self, current_buffer, user)
|
||||
else:
|
||||
output.speak(_("This session type does not support opening following list."), True)
|
||||
|
||||
|
||||
def community_timeline(self, *args, user=None): # user param seems unused here based on mastodon impl
|
||||
buffer = self.get_best_buffer()
|
||||
handler = self.get_handler(type=buffer.session.type)
|
||||
if handler and hasattr(handler, 'community_timeline'):
|
||||
@@ -1168,4 +1793,4 @@ class Controller(object):
|
||||
buffer = self.get_best_buffer()
|
||||
handler = self.get_handler(type=buffer.session.type)
|
||||
if handler and hasattr(handler, 'manage_filters'):
|
||||
handler.manage_filters(self, buffer)
|
||||
handler.manage_filters(self, buffer)
|
||||
|
||||
@@ -35,6 +35,7 @@ class Handler(object):
|
||||
compose=_("&Post"),
|
||||
reply=_("Re&ply"),
|
||||
share=_("&Boost"),
|
||||
quote=_("&Quote"),
|
||||
fav=_("&Add to favorites"),
|
||||
unfav=_("Remove from favorites"),
|
||||
view=_("&Show post"),
|
||||
@@ -48,7 +49,7 @@ class Handler(object):
|
||||
addAlias=_("Add a&lias"),
|
||||
addToList=None,
|
||||
removeFromList=None,
|
||||
details=_("Show user profile"),
|
||||
details=_("S&how user profile"),
|
||||
favs=None,
|
||||
# In buffer Menu.
|
||||
community_timeline =_("Create c&ommunity timeline"),
|
||||
@@ -92,6 +93,8 @@ class Handler(object):
|
||||
pub.sendMessage("createBuffer", buffer_type="UserBuffer", session_type=session.type, buffer_title=_("Blocked users"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, compose_func="compose_user", function="blocks", name="blocked", sessionObject=session, account=name))
|
||||
elif i == 'notifications':
|
||||
pub.sendMessage("createBuffer", buffer_type="NotificationsBuffer", session_type=session.type, buffer_title=_("Notifications"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, compose_func="compose_notification", function="notifications", name="notifications", sessionObject=session, account=name))
|
||||
elif i == 'announcements':
|
||||
pub.sendMessage("createBuffer", buffer_type="AnnouncementsBuffer", session_type=session.type, buffer_title=_("Announcements"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, function="announcements", name="announcements", sessionObject=session, account=name, sound="new_event.ogg"))
|
||||
pub.sendMessage("createBuffer", buffer_type="EmptyBuffer", session_type="base", buffer_title=_("Timelines"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, name="timelines", account=name))
|
||||
timelines_position =controller.view.search("timelines", name)
|
||||
for i in session.settings["other_buffers"]["timelines"]:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import os
|
||||
import re
|
||||
import wx
|
||||
import logging
|
||||
import widgetUtils
|
||||
import config
|
||||
import output
|
||||
@@ -9,11 +10,13 @@ import languageHandler
|
||||
from twitter_text import parse_tweet, config
|
||||
from mastodon import MastodonError
|
||||
from controller import messages
|
||||
from sessions.mastodon import templates
|
||||
from sessions.mastodon import templates, utils
|
||||
from wxUI.dialogs.mastodon import postDialogs
|
||||
from extra.autocompletionUsers import completion
|
||||
from . import userList
|
||||
|
||||
log = logging.getLogger("controller.mastodon.messages")
|
||||
|
||||
def character_count(post_text, post_cw, character_limit=500):
|
||||
# We will use text for counting character limit only.
|
||||
full_text = post_text+post_cw
|
||||
@@ -62,6 +65,13 @@ class post(messages.basicMessage):
|
||||
postdata = dict(text=text, attachments=attachments, sensitive=self.message.sensitive.GetValue(), spoiler_text=None)
|
||||
if postdata.get("sensitive") == True:
|
||||
postdata.update(spoiler_text=self.message.spoiler.GetValue())
|
||||
|
||||
# Check for scheduled post
|
||||
if hasattr(self.message, 'get_scheduled_at'):
|
||||
scheduled_at = self.message.get_scheduled_at()
|
||||
if scheduled_at:
|
||||
postdata['scheduled_at'] = scheduled_at
|
||||
|
||||
self.thread.append(postdata)
|
||||
self.attachments = []
|
||||
if update_gui:
|
||||
@@ -262,6 +272,105 @@ class post(messages.basicMessage):
|
||||
visibility_setting = visibility_settings.index(setting)
|
||||
self.message.visibility.SetSelection(setting)
|
||||
|
||||
class editPost(post):
|
||||
def __init__(self, session, item, title, caption, *args, **kwargs):
|
||||
""" Initialize edit dialog with existing post data.
|
||||
|
||||
Note: Per Mastodon API, visibility and language cannot be changed when editing.
|
||||
These fields will be displayed but disabled in the UI.
|
||||
"""
|
||||
# Extract text from post
|
||||
if item.reblog != None:
|
||||
item = item.reblog
|
||||
text = utils.html_filter(item.content)
|
||||
# Initialize parent class
|
||||
super(editPost, self).__init__(session, title, caption, text=text, *args, **kwargs)
|
||||
# Store the post ID for editing
|
||||
self.post_id = item.id
|
||||
# Set visibility (read-only, cannot be changed)
|
||||
visibility_settings = dict(public=0, unlisted=1, private=2, direct=3)
|
||||
self.message.visibility.SetSelection(visibility_settings.get(item.visibility, 0))
|
||||
self.message.visibility.Enable(False) # Disable as it cannot be edited
|
||||
# Set language (read-only, cannot be changed)
|
||||
if item.language:
|
||||
self.set_language(item.language)
|
||||
self.message.language.Enable(False) # Disable as it cannot be edited
|
||||
# Set sensitive content and spoiler
|
||||
if item.sensitive:
|
||||
self.message.sensitive.SetValue(True)
|
||||
if item.spoiler_text:
|
||||
self.message.spoiler.ChangeValue(item.spoiler_text)
|
||||
self.message.on_sensitivity_changed()
|
||||
# Load existing poll (if any)
|
||||
# Note: You cannot have both media and a poll, so check poll first
|
||||
if hasattr(item, 'poll') and item.poll is not None:
|
||||
log.debug("Loading existing poll for post {}".format(self.post_id))
|
||||
poll = item.poll
|
||||
# Extract poll options (just the text, not the votes)
|
||||
poll_options = [option.title for option in poll.options]
|
||||
# Calculate expires_in based on current time and expires_at
|
||||
# For editing, we need to provide a new expiration time
|
||||
# Since we can't get the original expires_in, use a default or let user configure
|
||||
# For now, use 1 day (86400 seconds) as default
|
||||
expires_in = 86400
|
||||
if hasattr(poll, 'expires_at') and poll.expires_at and not poll.expired:
|
||||
# Calculate remaining time if poll hasn't expired
|
||||
from dateutil import parser as date_parser
|
||||
import datetime
|
||||
try:
|
||||
expires_at = poll.expires_at
|
||||
if isinstance(expires_at, str):
|
||||
expires_at = date_parser.parse(expires_at)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
remaining = (expires_at - now).total_seconds()
|
||||
if remaining > 0:
|
||||
expires_in = int(remaining)
|
||||
except Exception as e:
|
||||
log.warning("Could not calculate poll expiration: {}".format(e))
|
||||
|
||||
poll_info = {
|
||||
"type": "poll",
|
||||
"file": "",
|
||||
"description": _("Poll with {} options").format(len(poll_options)),
|
||||
"options": poll_options,
|
||||
"expires_in": expires_in,
|
||||
"multiple": poll.multiple if hasattr(poll, 'multiple') else False,
|
||||
"hide_totals": poll.voters_count == 0 if hasattr(poll, 'voters_count') else False
|
||||
}
|
||||
self.attachments.append(poll_info)
|
||||
self.message.add_item(item=[poll_info["file"], poll_info["type"], poll_info["description"]])
|
||||
log.debug("Loaded poll with {} options. WARNING: Editing will reset all votes!".format(len(poll_options)))
|
||||
# Load existing media attachments (only if no poll)
|
||||
elif hasattr(item, 'media_attachments'):
|
||||
log.debug("Loading existing media attachments for post {}".format(self.post_id))
|
||||
log.debug("Item has media_attachments attribute, count: {}".format(len(item.media_attachments)))
|
||||
if len(item.media_attachments) > 0:
|
||||
for media in item.media_attachments:
|
||||
log.debug("Processing media: id={}, type={}, url={}".format(media.id, media.type, media.url))
|
||||
media_info = {
|
||||
"id": media.id, # Keep the existing media ID
|
||||
"type": media.type,
|
||||
"file": media.url, # URL of existing media
|
||||
"description": media.description or ""
|
||||
}
|
||||
# Include focus point if available
|
||||
if hasattr(media, 'meta') and media.meta and 'focus' in media.meta:
|
||||
focus = media.meta['focus']
|
||||
media_info["focus"] = (focus.get('x'), focus.get('y'))
|
||||
log.debug("Added focus point: {}".format(media_info["focus"]))
|
||||
self.attachments.append(media_info)
|
||||
# Display in the attachment list
|
||||
display_name = media.url.split('/')[-1]
|
||||
log.debug("Adding item to UI: name={}, type={}, desc={}".format(display_name, media.type, media.description or ""))
|
||||
self.message.add_item(item=[display_name, media.type, media.description or ""])
|
||||
log.debug("Total attachments loaded: {}".format(len(self.attachments)))
|
||||
else:
|
||||
log.debug("media_attachments list is empty")
|
||||
else:
|
||||
log.debug("Item has no poll or media attachments")
|
||||
# Update text processor to reflect the loaded content
|
||||
self.text_processor()
|
||||
|
||||
class viewPost(post):
|
||||
def __init__(self, session, post, offset_hours=0, date="", item_url=""):
|
||||
self.session = session
|
||||
@@ -347,4 +456,4 @@ class text(messages.basicMessage):
|
||||
self.message = postDialogs.viewText(title=title, text=text, *args, **kwargs)
|
||||
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
||||
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||
|
||||
@@ -52,10 +52,12 @@ class accountSettingsController(globalSettingsController):
|
||||
post_template = self.config["templates"]["post"]
|
||||
conversation_template = self.config["templates"]["conversation"]
|
||||
person_template = self.config["templates"]["person"]
|
||||
self.dialog.create_templates(post_template=post_template, conversation_template=conversation_template, person_template=person_template)
|
||||
announcement_template = self.config.get("templates", {}).get("announcement", "$text. Published $published_at. $read")
|
||||
self.dialog.create_templates(post_template=post_template, conversation_template=conversation_template, person_template=person_template, announcement_template=announcement_template)
|
||||
widgetUtils.connect_event(self.dialog.templates.post, widgetUtils.BUTTON_PRESSED, self.edit_post_template)
|
||||
widgetUtils.connect_event(self.dialog.templates.conversation, widgetUtils.BUTTON_PRESSED, self.edit_conversation_template)
|
||||
widgetUtils.connect_event(self.dialog.templates.person, widgetUtils.BUTTON_PRESSED, self.edit_person_template)
|
||||
widgetUtils.connect_event(self.dialog.templates.announcement, widgetUtils.BUTTON_PRESSED, self.edit_announcement_template)
|
||||
self.dialog.create_other_buffers()
|
||||
buffer_values = self.get_buffers_list()
|
||||
self.dialog.buffers.insert_buffers(buffer_values)
|
||||
@@ -109,6 +111,15 @@ class accountSettingsController(globalSettingsController):
|
||||
self.config.write()
|
||||
self.dialog.templates.person.SetLabel(_("Edit template for persons. Current template: {}").format(result))
|
||||
|
||||
def edit_announcement_template(self, *args, **kwargs):
|
||||
template = self.config.get("templates", {}).get("announcement", "$text. Published $published_at. $read")
|
||||
control = EditTemplate(template=template, type="announcement")
|
||||
result = control.run_dialog()
|
||||
if result != "": # Template has been saved.
|
||||
self.config["templates"]["announcement"] = result
|
||||
self.config.write()
|
||||
self.dialog.templates.announcement.SetLabel(_("Edit template for announcements. Current template: {}").format(result))
|
||||
|
||||
def save_configuration(self):
|
||||
if self.config["general"]["relative_times"] != self.dialog.get_value("general", "relative_time"):
|
||||
self.needs_restart = True
|
||||
@@ -204,6 +215,7 @@ class accountSettingsController(globalSettingsController):
|
||||
all_buffers['blocked']=_("Blocked users")
|
||||
all_buffers['muted']=_("Muted users")
|
||||
all_buffers['notifications']=_("Notifications")
|
||||
all_buffers['announcements']=_("Announcements")
|
||||
list_buffers = []
|
||||
hidden_buffers=[]
|
||||
all_buffers_keys = list(all_buffers.keys())
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import re
|
||||
import wx
|
||||
from typing import List
|
||||
from sessions.mastodon.templates import post_variables, conversation_variables, person_variables
|
||||
from sessions.mastodon.templates import post_variables, conversation_variables, person_variables, announcement_variables
|
||||
from wxUI.dialogs import templateDialogs
|
||||
|
||||
class EditTemplate(object):
|
||||
@@ -13,6 +13,8 @@ class EditTemplate(object):
|
||||
self.variables = post_variables
|
||||
elif type == "conversation":
|
||||
self.variables = conversation_variables
|
||||
elif type == "announcement":
|
||||
self.variables = announcement_variables
|
||||
else:
|
||||
self.variables = person_variables
|
||||
self.template: str = template
|
||||
@@ -37,4 +39,4 @@ class EditTemplate(object):
|
||||
else:
|
||||
return dialog.template.GetValue()
|
||||
else:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
@@ -13,8 +13,8 @@ class autocompletionManageDialog(widgetUtils.BaseDialog):
|
||||
self.users = widgets.list(panel, _(u"Username"), _(u"Name"), style=wx.LC_REPORT)
|
||||
sizer.Add(label, 0, wx.ALL, 5)
|
||||
sizer.Add(self.users.list, 0, wx.ALL, 5)
|
||||
self.add = wx.Button(panel, -1, _(u"Add user"))
|
||||
self.remove = wx.Button(panel, -1, _(u"Remove user"))
|
||||
self.add = wx.Button(panel, -1, _(u"&Add user"))
|
||||
self.remove = wx.Button(panel, -1, _(u"&Remove user"))
|
||||
optionsBox = wx.BoxSizer(wx.HORIZONTAL)
|
||||
optionsBox.Add(self.add, 0, wx.ALL, 5)
|
||||
optionsBox.Add(self.remove, 0, wx.ALL, 5)
|
||||
|
||||
@@ -36,4 +36,13 @@ update_buffer = string(default="control+win+shift+u")
|
||||
ocr_image = string(default="win+alt+o")
|
||||
open_in_browser = string(default="alt+control+win+return")
|
||||
add_alias=string(default="")
|
||||
vote=string(default="alt+win+shift+v")
|
||||
vote=string(default="alt+win+shift+v")
|
||||
edit_post=string(default="")
|
||||
open_favs_timeline=string(default="")
|
||||
community_timeline=string(default="")
|
||||
seekLeft=string(default="")
|
||||
seekRight=string(default="")
|
||||
manage_aliases=string(default="")
|
||||
create_filter=string(default="")
|
||||
manage_filters=string(default="")
|
||||
manage_accounts=string(default="")
|
||||
@@ -55,4 +55,13 @@ update_buffer = string(default="control+win+shift+u")
|
||||
ocr_image = string(default="win+alt+o")
|
||||
open_in_browser = string(default="alt+control+win+return")
|
||||
add_alias=string(default="")
|
||||
vote=string(default="alt+win+shift+v")
|
||||
vote=string(default="alt+win+shift+v")
|
||||
edit_post=string(default="")
|
||||
open_favs_timeline=string(default="")
|
||||
community_timeline=string(default="")
|
||||
seekLeft=string(default="")
|
||||
seekRight=string(default="")
|
||||
manage_aliases=string(default="")
|
||||
create_filter=string(default="")
|
||||
manage_filters=string(default="")
|
||||
manage_accounts=string(default="")
|
||||
@@ -56,5 +56,15 @@ update_buffer = string(default="control+alt+shift+u")
|
||||
ocr_image = string(default="win+alt+o")
|
||||
open_in_browser = string(default="alt+control+win+return")
|
||||
add_alias=string(default="")
|
||||
mute_conversation=string(default="control+alt+win+back")
|
||||
find = string(default="control+win+{")
|
||||
vote=string(default="alt+win+shift+v")
|
||||
vote=string(default="alt+win+shift+v")
|
||||
edit_post=string(default="")
|
||||
open_favs_timeline=string(default="")
|
||||
community_timeline=string(default="")
|
||||
seekLeft=string(default="")
|
||||
seekRight=string(default="")
|
||||
manage_aliases=string(default="")
|
||||
create_filter=string(default="")
|
||||
manage_filters=string(default="")
|
||||
manage_accounts=string(default="")
|
||||
@@ -56,5 +56,15 @@ update_buffer = string(default="control+alt+shift+u")
|
||||
ocr_image = string(default="win+alt+o")
|
||||
open_in_browser = string(default="alt+control+win+return")
|
||||
add_alias=string(default="")
|
||||
mute_conversation=string(default="control+alt+win+back")
|
||||
find = string(default="control+win+{")
|
||||
vote=string(default="alt+win+shift+v")
|
||||
vote=string(default="alt+win+shift+v")
|
||||
edit_post=string(default="")
|
||||
open_favs_timeline=string(default="")
|
||||
community_timeline=string(default="")
|
||||
seekLeft=string(default="")
|
||||
seekRight=string(default="")
|
||||
manage_aliases=string(default="")
|
||||
create_filter=string(default="")
|
||||
manage_filters=string(default="")
|
||||
manage_accounts=string(default="")
|
||||
@@ -56,4 +56,15 @@ configuration = string(default="control+win+o")
|
||||
accountConfiguration = string(default="control+win+shift+o")
|
||||
update_buffer = string(default="control+win+shift+u")
|
||||
open_in_browser = string(default="alt+control+win+return")
|
||||
add_alias=string(default="")
|
||||
add_alias=string(default="")
|
||||
mute_conversation=string(default="alt+win+shift+delete")
|
||||
vote=string(default="alt+win+shift+v")
|
||||
edit_post=string(default="")
|
||||
open_favs_timeline=string(default="")
|
||||
community_timeline=string(default="")
|
||||
seekLeft=string(default="")
|
||||
seekRight=string(default="")
|
||||
manage_aliases=string(default="")
|
||||
create_filter=string(default="")
|
||||
manage_filters=string(default="")
|
||||
manage_accounts=string(default="")
|
||||
@@ -58,4 +58,14 @@ update_buffer = string(default="control+win+shift+u")
|
||||
ocr_image = string(default="win+alt+o")
|
||||
open_in_browser = string(default="alt+control+win+return")
|
||||
add_alias=string(default="")
|
||||
vote=string(default="alt+win+shift+v")
|
||||
mute_conversation=string(default="alt+win+shift+delete")
|
||||
vote=string(default="alt+win+shift+v")
|
||||
edit_post=string(default="")
|
||||
open_favs_timeline=string(default="")
|
||||
community_timeline=string(default="")
|
||||
seekLeft=string(default="")
|
||||
seekRight=string(default="")
|
||||
manage_aliases=string(default="")
|
||||
create_filter=string(default="")
|
||||
manage_filters=string(default="")
|
||||
manage_accounts=string(default="")
|
||||
@@ -1 +1,2 @@
|
||||
from . import mastodon
|
||||
from . import mastodon
|
||||
from . import blueski
|
||||
52
src/keystrokeEditor/actions/blueski.py
Normal file
52
src/keystrokeEditor/actions/blueski.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
actions = {
|
||||
"up": _(u"Go up in the current buffer"),
|
||||
"down": _(u"Go down in the current buffer"),
|
||||
"left": _(u"Go to the previous buffer"),
|
||||
"right": _(u"Go to the next buffer"),
|
||||
"next_account": _(u"Focus the next session"),
|
||||
"previous_account": _(u"Focus the previous session"),
|
||||
"show_hide": _(u"Show or hide the GUI"),
|
||||
"post_tweet": _("Make a new post"),
|
||||
"post_reply": _(u"Reply"),
|
||||
"post_retweet": _(u"Repost"),
|
||||
"send_dm": _(u"Send direct message"),
|
||||
"add_to_favourites": _("Add post to likes"),
|
||||
"remove_from_favourites": _(u"Remove post from likes"),
|
||||
"toggle_like": _("Add/remove post from likes"),
|
||||
"follow": _(u"Open the user actions dialogue"),
|
||||
"user_details": _(u"See user details"),
|
||||
"view_item": _(u"Show post"),
|
||||
"exit": _(u"Quit"),
|
||||
"open_timeline": _(u"Open user timeline"),
|
||||
"remove_buffer": _(u"Destroy buffer"),
|
||||
"url": _(u"Open URL"),
|
||||
"open_in_browser": _(u"View in browser"),
|
||||
"volume_up": _(u"Increase volume by 5%"),
|
||||
"volume_down": _(u"Decrease volume by 5%"),
|
||||
"go_home": _(u"Jump to the first element of a buffer"),
|
||||
"go_end": _(u"Jump to the last element of the current buffer"),
|
||||
"go_page_up": _(u"Jump 20 elements up in the current buffer"),
|
||||
"go_page_down": _(u"Jump 20 elements down in the current buffer"),
|
||||
"delete": _("Delete post"),
|
||||
"clear_buffer": _(u"Empty the current buffer"),
|
||||
"repeat_item": _(u"Repeat last item"),
|
||||
"copy_to_clipboard": _(u"Copy to clipboard"),
|
||||
"toggle_buffer_mute": _(u"Mute/unmute the active buffer"),
|
||||
"toggle_session_mute": _(u"Mute/unmute the current session"),
|
||||
"toggle_autoread": _(u"Toggle the automatic reading of incoming posts in the active buffer"),
|
||||
"search": _(u"Search"),
|
||||
"find": _(u"Find a string in the currently focused buffer"),
|
||||
"edit_keystrokes": _(u"Show the keystroke editor"),
|
||||
"get_more_items": _(u"Load previous items"),
|
||||
"open_conversation": _(u"View conversation"),
|
||||
"check_for_updates": _(u"Check and download updates"),
|
||||
"configuration": _(u"Opens the global settings dialogue"),
|
||||
"accountConfiguration": _(u"Opens the account settings dialogue"),
|
||||
"audio": _(u"Try to play a media file"),
|
||||
"update_buffer": _(u"Updates the buffer and retrieves possible lost items there."),
|
||||
"ocr_image": _(u"Extracts the text from a picture and displays the result in a dialog."),
|
||||
"seekLeft": _(u"Seek media backward"),
|
||||
"seekRight": _(u"Seek media forward"),
|
||||
"manage_accounts": _(u"Manage accounts"),
|
||||
}
|
||||
@@ -29,7 +29,7 @@ actions = {
|
||||
"go_end": _(u"Jump to the last element of the current buffer"),
|
||||
"go_page_up": _(u"Jump 20 elements up in the current buffer"),
|
||||
"go_page_down": _(u"Jump 20 elements down in the current buffer"),
|
||||
# "update_profile": _(u"Edit profile"),
|
||||
"update_profile": _(u"Edit profile"),
|
||||
"delete": _("Delete post"),
|
||||
"clear_buffer": _(u"Empty the current buffer"),
|
||||
"repeat_item": _(u"Repeat last item"),
|
||||
@@ -54,4 +54,15 @@ actions = {
|
||||
"update_buffer": _(u"Updates the buffer and retrieves possible lost items there."),
|
||||
"ocr_image": _(u"Extracts the text from a picture and displays the result in a dialog."),
|
||||
"add_alias": _("Adds an alias to an user"),
|
||||
"mute_conversation": _("Mute/Unmute conversation"),
|
||||
"edit_post": _(u"Edit the selected post"),
|
||||
"vote": _(u"Vote in the selected poll"),
|
||||
"open_favs_timeline": _(u"Open favorites timeline"),
|
||||
"community_timeline": _(u"Open local/federated timeline"),
|
||||
"seekLeft": _(u"Seek media backward"),
|
||||
"seekRight": _(u"Seek media forward"),
|
||||
"manage_aliases": _(u"Manage user aliases"),
|
||||
"create_filter": _(u"Create a new filter"),
|
||||
"manage_filters": _(u"Manage filters"),
|
||||
"manage_accounts": _(u"Manage accounts"),
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ persist_size = integer(default=0)
|
||||
load_cache_in_memory=boolean(default=True)
|
||||
show_screen_names = boolean(default=False)
|
||||
hide_emojis = boolean(default=False)
|
||||
buffer_order = list(default=list('home', 'local', 'mentions', 'direct_messages', 'sent', 'favorites', 'bookmarks', 'followers', 'following', 'blocked', 'muted', 'notifications'))
|
||||
buffer_order = list(default=list('home', 'local', 'mentions', 'direct_messages', 'sent', 'favorites', 'bookmarks', 'followers', 'following', 'blocked', 'muted', 'notifications', 'announcements'))
|
||||
boost_mode = string(default="ask")
|
||||
disable_streaming = boolean(default=False)
|
||||
|
||||
@@ -54,6 +54,7 @@ post = string(default="$display_name, $safe_text $image_descriptions $date. $vis
|
||||
person = string(default="$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.")
|
||||
conversation = string(default="Conversation with $users. Last message: $last_post")
|
||||
notification = string(default="$display_name $text, $date")
|
||||
announcement = string(default="$text. Published $published_at. $read")
|
||||
|
||||
[filters]
|
||||
|
||||
|
||||
@@ -63,13 +63,17 @@ class list(object):
|
||||
|
||||
def get_selected(self):
|
||||
if self.system == "Windows":
|
||||
return self.list.GetFocusedItem()
|
||||
item = self.list.GetFocusedItem()
|
||||
if item == -1:
|
||||
item = self.list.GetFirstSelected()
|
||||
return item
|
||||
else:
|
||||
return self.list.GetSelection()
|
||||
|
||||
def select_item(self, pos):
|
||||
if self.system == "Windows":
|
||||
self.list.Focus(pos)
|
||||
self.list.Select(pos)
|
||||
else:
|
||||
self.list.SetSelection(pos)
|
||||
|
||||
|
||||
@@ -11,10 +11,13 @@ import paths
|
||||
import config_utils
|
||||
import config
|
||||
import application
|
||||
import asyncio # For async event handling
|
||||
import wx
|
||||
from pubsub import pub
|
||||
from controller import settings
|
||||
from sessions.mastodon import session as MastodonSession
|
||||
from sessions.gotosocial import session as GotosocialSession
|
||||
from sessions.blueski import session as BlueskiSession # Import Blueski session
|
||||
from . import manager
|
||||
from . import wxUI as view
|
||||
|
||||
@@ -35,6 +38,7 @@ class sessionManagerController(object):
|
||||
# Initialize the manager, responsible for storing session objects.
|
||||
manager.setup()
|
||||
self.view = view.sessionManagerWindow()
|
||||
# Handle new account synchronously on the UI thread
|
||||
pub.subscribe(self.manage_new_account, "sessionmanager.new_account")
|
||||
pub.subscribe(self.remove_account, "sessionmanager.remove_account")
|
||||
if self.started == False:
|
||||
@@ -67,12 +71,44 @@ class sessionManagerController(object):
|
||||
continue
|
||||
if config_test.get("mastodon") != None:
|
||||
name = _("{account_name}@{instance} (Mastodon)").format(account_name=config_test["mastodon"]["user_name"], instance=config_test["mastodon"]["instance"].replace("https://", ""))
|
||||
if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "":
|
||||
if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "": # Basic validation
|
||||
sessionsList.append(name)
|
||||
self.sessions.append(dict(type=config_test["mastodon"].get("type", "mastodon"), id=i))
|
||||
else:
|
||||
elif config_test.get("blueski") != None: # Check for Blueski config
|
||||
handle = config_test["blueski"].get("handle")
|
||||
did = config_test["blueski"].get("did") # DID confirms it was authorized
|
||||
if handle and did:
|
||||
name = _("{handle} (Bluesky)").format(handle=handle)
|
||||
sessionsList.append(name)
|
||||
self.sessions.append(dict(type="blueski", id=i))
|
||||
else: # Incomplete config, might be an old attempt or error
|
||||
log.warning(f"Incomplete Blueski session config found for {i}, skipping.")
|
||||
# Optionally delete malformed config here too
|
||||
try:
|
||||
log.debug("Deleting incomplete Blueski session %s" % (i,))
|
||||
shutil.rmtree(os.path.join(paths.config_path(), i))
|
||||
except Exception as e:
|
||||
log.exception(f"Error deleting incomplete Blueski session {i}: {e}")
|
||||
continue
|
||||
elif config_test.get("atprotosocial") != None: # Legacy config namespace
|
||||
handle = config_test["atprotosocial"].get("handle")
|
||||
did = config_test["atprotosocial"].get("did")
|
||||
if handle and did:
|
||||
name = _("{handle} (Bluesky)").format(handle=handle)
|
||||
sessionsList.append(name)
|
||||
self.sessions.append(dict(type="blueski", id=i))
|
||||
else: # Incomplete config, might be an old attempt or error
|
||||
log.warning(f"Incomplete Blueski session config found for {i}, skipping.")
|
||||
# Optionally delete malformed config here too
|
||||
try:
|
||||
log.debug("Deleting incomplete Blueski session %s" % (i,))
|
||||
shutil.rmtree(os.path.join(paths.config_path(), i))
|
||||
except Exception as e:
|
||||
log.exception(f"Error deleting incomplete Blueski session {i}: {e}")
|
||||
continue
|
||||
else: # Unknown or other session type not explicitly handled here for display
|
||||
try:
|
||||
log.debug("Deleting session %s" % (i,))
|
||||
log.debug("Deleting session %s with unknown type" % (i,))
|
||||
shutil.rmtree(os.path.join(paths.config_path(), i))
|
||||
except:
|
||||
output.speak("An exception was raised while attempting to clean malformed session data. See the error log for details. If this message persists, contact the developers.",True)
|
||||
@@ -97,36 +133,112 @@ class sessionManagerController(object):
|
||||
s = MastodonSession.Session(i.get("id"))
|
||||
elif i.get("type") == "gotosocial":
|
||||
s = GotosocialSession.Session(i.get("id"))
|
||||
s.get_configuration()
|
||||
if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
|
||||
try:
|
||||
elif i.get("type") == "blueski": # Handle Blueski session type
|
||||
s = BlueskiSession.Session(i.get("id"))
|
||||
else:
|
||||
log.warning(f"Unknown session type '{i.get('type')}' for ID {i.get('id')}. Skipping.")
|
||||
continue
|
||||
|
||||
s.get_configuration() # Load per-session configuration
|
||||
# For Blueski, this loads from its specific config file.
|
||||
|
||||
# Login is now primarily handled by session.start() via mainController,
|
||||
# which calls _ensure_dependencies_ready().
|
||||
# Explicit s.login() here might be redundant or premature if full app context isn't ready.
|
||||
# We'll rely on the mainController to call session.start() which handles login.
|
||||
# if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
|
||||
# try:
|
||||
# # For Blueski, login is async and handled by session.start()
|
||||
# # if not s.is_ready(): # Only attempt login if not already ready
|
||||
# # log.info(f"Session {s.uid} ({s.kind}) not ready, login will be attempted by start().")
|
||||
# pass
|
||||
# except Exception as e:
|
||||
# log.exception(f"Exception during pre-emptive login check for session {s.uid} ({s.kind}).")
|
||||
# continue
|
||||
# Try to auto-login for Blueski so the app starts with buffers ready
|
||||
try:
|
||||
if i.get("type") == "blueski":
|
||||
s.login()
|
||||
except Exception as e:
|
||||
log.exception("Exception during login on a TWBlue session.")
|
||||
continue
|
||||
sessions.sessions[i.get("id")] = s
|
||||
self.new_sessions[i.get("id")] = s
|
||||
except Exception:
|
||||
log.exception("Auto-login failed for Blueski session %s", i.get("id"))
|
||||
|
||||
sessions.sessions[i.get("id")] = s # Add to global session store
|
||||
self.new_sessions[i.get("id")] = s # Track as a new session for this manager instance
|
||||
# self.view.destroy()
|
||||
|
||||
def show_auth_error(self):
|
||||
error = view.auth_error()
|
||||
error = view.auth_error() # This seems to be a generic auth error display
|
||||
|
||||
def manage_new_account(self, type):
|
||||
# Generic settings for all account types.
|
||||
location = (str(time.time())[-6:])
|
||||
location = (str(time.time())[-6:]) # Unique ID for the session config directory
|
||||
log.debug("Creating %s session in the %s path" % (type, location))
|
||||
|
||||
s: sessions.base.baseSession | None = None # Type hint for session object
|
||||
|
||||
if type == "mastodon":
|
||||
s = MastodonSession.Session(location)
|
||||
result = s.authorise()
|
||||
if result == True:
|
||||
self.sessions.append(dict(id=location, type=s.settings["mastodon"].get("type")))
|
||||
self.view.add_new_session_to_list()
|
||||
elif type == "blueski":
|
||||
s = BlueskiSession.Session(location)
|
||||
# Add other session types here if needed (e.g., gotosocial)
|
||||
# elif type == "gotosocial":
|
||||
# s = GotosocialSession.Session(location)
|
||||
|
||||
if not s:
|
||||
log.error(f"Unsupported session type for creation: {type}")
|
||||
self.view.show_unauthorised_error() # Or a more generic "cannot create" error
|
||||
return
|
||||
|
||||
try:
|
||||
result = s.authorise()
|
||||
if result == True:
|
||||
# Session config (handle, did for atproto) should be saved by authorise/login.
|
||||
# Here we just update the session manager's internal list and UI.
|
||||
session_type_for_dict = type # Store the actual type string
|
||||
if hasattr(s, 'settings') and s.settings and s.settings.get(type) and s.settings[type].get("type"):
|
||||
# Mastodon might have a more specific type in its settings (e.g. gotosocial)
|
||||
session_type_for_dict = s.settings[type].get("type")
|
||||
|
||||
self.sessions.append(dict(id=location, type=session_type_for_dict))
|
||||
self.view.add_new_session_to_list() # This should update the UI list
|
||||
# The session object 's' itself isn't stored in self.new_sessions until do_ok if app is restarting
|
||||
# But for immediate use if not restarting, it might need to be added to sessions.sessions
|
||||
sessions.sessions[location] = s # Make it globally available immediately
|
||||
self.new_sessions[location] = s
|
||||
# Sync with global config
|
||||
if location not in config.app["sessions"]["sessions"]:
|
||||
config.app["sessions"]["sessions"].append(location)
|
||||
config.app.write()
|
||||
|
||||
|
||||
else: # Authorise returned False or None
|
||||
self.view.show_unauthorised_error()
|
||||
# Clean up the directory if authorization failed and nothing was saved
|
||||
if os.path.exists(os.path.join(paths.config_path(), location)):
|
||||
try:
|
||||
shutil.rmtree(os.path.join(paths.config_path(), location))
|
||||
log.info(f"Cleaned up directory for failed auth: {location}")
|
||||
except Exception as e_rm:
|
||||
log.error(f"Error cleaning up directory {location} after failed auth: {e_rm}")
|
||||
except Exception as e:
|
||||
log.error(f"Error during new account authorization for type {type}: {e}", exc_info=True)
|
||||
self.view.show_unauthorised_error() # Show generic error
|
||||
# Clean up
|
||||
if os.path.exists(os.path.join(paths.config_path(), location)):
|
||||
try:
|
||||
shutil.rmtree(os.path.join(paths.config_path(), location))
|
||||
except Exception as e_rm:
|
||||
log.error(f"Error cleaning up directory {location} after exception: {e_rm}")
|
||||
|
||||
|
||||
def remove_account(self, index):
|
||||
selected_account = self.sessions[index]
|
||||
self.view.remove_session(index)
|
||||
self.removed_sessions.append(selected_account.get("id"))
|
||||
self.sessions.remove(selected_account)
|
||||
if selected_account.get("id") in config.app["sessions"]["sessions"]:
|
||||
config.app["sessions"]["sessions"].remove(selected_account.get("id"))
|
||||
config.app.write()
|
||||
shutil.rmtree(path=os.path.join(paths.config_path(), selected_account.get("id")), ignore_errors=True)
|
||||
|
||||
def configuration(self):
|
||||
|
||||
@@ -53,6 +53,10 @@ class sessionManagerWindow(wx.Dialog):
|
||||
menu = wx.Menu()
|
||||
mastodon = menu.Append(wx.ID_ANY, _("Mastodon"))
|
||||
menu.Bind(wx.EVT_MENU, self.on_new_mastodon_account, mastodon)
|
||||
|
||||
blueski = menu.Append(wx.ID_ANY, _("Bluesky"))
|
||||
menu.Bind(wx.EVT_MENU, self.on_new_blueski_account, blueski)
|
||||
|
||||
self.PopupMenu(menu, self.new.GetPosition())
|
||||
|
||||
def on_new_mastodon_account(self, *args, **kwargs):
|
||||
@@ -62,6 +66,13 @@ class sessionManagerWindow(wx.Dialog):
|
||||
if response == wx.ID_YES:
|
||||
pub.sendMessage("sessionmanager.new_account", type="mastodon")
|
||||
|
||||
def on_new_blueski_account(self, *args, **kwargs):
|
||||
dlg = wx.MessageDialog(self, _("You will be prompted for your Bluesky data (user handle and App Password) to authorize TWBlue. Would you like to authorize your account now?"), _(u"Bluesky Authorization"), wx.YES_NO)
|
||||
response = dlg.ShowModal()
|
||||
dlg.Destroy()
|
||||
if response == wx.ID_YES:
|
||||
pub.sendMessage("sessionmanager.new_account", type="blueski")
|
||||
|
||||
def add_new_session_to_list(self):
|
||||
total = self.list.get_count()
|
||||
name = _(u"Authorized account %d") % (total+1)
|
||||
|
||||
@@ -59,7 +59,9 @@ class baseSession(object):
|
||||
if not os.path.exists(path):
|
||||
log.debug("Creating %s path" % (os.path.join(paths.config_path(), path),))
|
||||
os.mkdir(path)
|
||||
config.app["sessions"]["sessions"].append(id)
|
||||
if self.session_id not in config.app["sessions"]["sessions"]:
|
||||
config.app["sessions"]["sessions"].append(self.session_id)
|
||||
config.app.write()
|
||||
|
||||
def get_configuration(self):
|
||||
""" Get settings for a session."""
|
||||
|
||||
3
src/sessions/blueski/__init__.py
Normal file
3
src/sessions/blueski/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .session import Session
|
||||
|
||||
__all__ = ["Session"]
|
||||
478
src/sessions/blueski/compose.py
Normal file
478
src/sessions/blueski/compose.py
Normal file
@@ -0,0 +1,478 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Compose functions for Bluesky content display in TWBlue.
|
||||
|
||||
These functions format API data into user-readable strings for display in
|
||||
list controls. They follow the TWBlue compose function pattern:
|
||||
compose_function(item, db, relative_times, show_screen_names, session)
|
||||
Returns a list of strings for display columns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import arrow
|
||||
import languageHandler
|
||||
from sessions.blueski import utils
|
||||
|
||||
log = logging.getLogger("sessions.blueski.compose")
|
||||
|
||||
|
||||
def compose_post(post, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky post into a list of strings for display.
|
||||
Format matches Mastodon: [user+", ", text, date+", ", source]
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
"""Helper to get attribute from dict or object."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# Resolve Post View or Feed View structure
|
||||
actual_post = g(post, "post", post)
|
||||
record = g(actual_post, "record", {})
|
||||
author = g(actual_post, "author", {})
|
||||
|
||||
# Original author info
|
||||
original_handle = g(author, "handle", "")
|
||||
original_display_name = g(author, "displayName") or g(author, "display_name") or original_handle or "Unknown"
|
||||
|
||||
# Check if this is a repost
|
||||
reason = g(post, "reason", None)
|
||||
is_repost = False
|
||||
reposter_handle = ""
|
||||
reposter_display_name = ""
|
||||
|
||||
if reason:
|
||||
rtype = g(reason, "$type") or g(reason, "py_type")
|
||||
if rtype and "reasonRepost" in rtype:
|
||||
is_repost = True
|
||||
by = g(reason, "by", {})
|
||||
reposter_handle = g(by, "handle", "")
|
||||
reposter_display_name = g(by, "displayName") or g(by, "display_name") or reposter_handle
|
||||
|
||||
# User column: show reposter if repost, otherwise original author (like Mastodon)
|
||||
if is_repost and reposter_handle:
|
||||
if show_screen_names:
|
||||
user_str = f"@{reposter_handle}"
|
||||
else:
|
||||
if reposter_display_name and reposter_display_name != reposter_handle:
|
||||
user_str = f"{reposter_display_name} (@{reposter_handle})"
|
||||
else:
|
||||
user_str = f"@{reposter_handle}"
|
||||
else:
|
||||
if show_screen_names:
|
||||
user_str = f"@{original_handle}"
|
||||
else:
|
||||
if original_display_name and original_display_name != original_handle:
|
||||
user_str = f"{original_display_name} (@{original_handle})"
|
||||
else:
|
||||
user_str = f"@{original_handle}"
|
||||
|
||||
# Text
|
||||
original_text = g(record, "text", "")
|
||||
|
||||
# Build text - if repost, format like Mastodon: "Reposted from @original: text"
|
||||
if is_repost:
|
||||
text = _("Reposted from @{}: {}").format(original_handle, original_text)
|
||||
else:
|
||||
text = original_text
|
||||
|
||||
reply_to_handle = utils.extract_reply_to_handle(post)
|
||||
if reply_to_handle:
|
||||
if text:
|
||||
text = _("Replying to @{}: {}").format(reply_to_handle, text)
|
||||
else:
|
||||
text = _("Replying to @{}").format(reply_to_handle)
|
||||
|
||||
# Check facets for links not visible in text and append them
|
||||
facets = g(record, "facets", []) or []
|
||||
hidden_urls = []
|
||||
for facet in facets:
|
||||
features = g(facet, "features", []) or []
|
||||
for feature in features:
|
||||
ftype = g(feature, "$type") or g(feature, "py_type") or ""
|
||||
if "link" in ftype.lower():
|
||||
uri = g(feature, "uri", "")
|
||||
if uri and uri not in text and uri not in hidden_urls:
|
||||
# Check if a truncated version is in text (e.g., "example.com/path...")
|
||||
# by checking if the domain is present
|
||||
domain_match = False
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(uri)
|
||||
domain = parsed.netloc.replace("www.", "")
|
||||
if domain and domain in text:
|
||||
domain_match = True
|
||||
except:
|
||||
pass
|
||||
if not domain_match:
|
||||
hidden_urls.append(uri)
|
||||
|
||||
if hidden_urls:
|
||||
text += " " + " ".join(f"[{url}]" for url in hidden_urls)
|
||||
|
||||
# Labels / Content Warning
|
||||
labels = g(actual_post, "labels", [])
|
||||
cw_text = ""
|
||||
for label in labels:
|
||||
val = g(label, "val", "")
|
||||
if val in ["!warn", "porn", "sexual", "nudity", "gore", "graphic-media", "corpse", "self-harm", "hate", "spam", "impersonation"]:
|
||||
if not cw_text:
|
||||
cw_text = _("Sensitive Content")
|
||||
elif val.startswith("warn:"):
|
||||
cw_text = val.split("warn:", 1)[-1].strip()
|
||||
|
||||
if cw_text:
|
||||
text = f"CW: {cw_text}\n\n{text}"
|
||||
|
||||
# Embeds (Images, Links)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if embed:
|
||||
etype = g(embed, "$type") or g(embed, "py_type")
|
||||
|
||||
# Images
|
||||
if etype and ("images" in etype):
|
||||
images = g(embed, "images", [])
|
||||
if images:
|
||||
text += f" [{len(images)} {_('images')}]"
|
||||
|
||||
if etype and ("recordWithMedia" in etype):
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type")
|
||||
if mtype and "images" in mtype:
|
||||
images = g(media, "images", [])
|
||||
if images:
|
||||
text += f" [{len(images)} {_('images')}]"
|
||||
elif mtype and "external" in mtype:
|
||||
ext = g(media, "external", {})
|
||||
title = g(ext, "title", "")
|
||||
if title:
|
||||
text += f" [{_('Link')}: {title}]"
|
||||
elif etype and ("external" in etype):
|
||||
ext = g(embed, "external", {})
|
||||
title = g(ext, "title", "")
|
||||
if title:
|
||||
text += f" [{_('Link')}: {title}]"
|
||||
|
||||
quote_info = utils.extract_quoted_post_info(post)
|
||||
if quote_info:
|
||||
if quote_info["kind"] == "not_found":
|
||||
text += f" [{_('Quoted post not found')}]"
|
||||
elif quote_info["kind"] == "blocked":
|
||||
text += f" [{_('Quoted post blocked')}]"
|
||||
elif quote_info["kind"] == "feed":
|
||||
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
|
||||
else:
|
||||
q_handle = quote_info.get("handle", "unknown")
|
||||
q_text = quote_info.get("text", "")
|
||||
if q_text:
|
||||
text += " " + _("Quoting @{}: {}").format(q_handle, q_text)
|
||||
else:
|
||||
text += " " + _("Quoting @{}").format(q_handle)
|
||||
|
||||
# Add full URLs from quoted content when they are not visible in text.
|
||||
for uri in quote_info.get("urls", []):
|
||||
if uri and uri not in text:
|
||||
text += f" [{uri}]"
|
||||
|
||||
# Date
|
||||
indexed_at = g(actual_post, "indexed_at", "") or g(actual_post, "indexedAt", "")
|
||||
ts_str = ""
|
||||
if indexed_at:
|
||||
try:
|
||||
ts = arrow.get(indexed_at)
|
||||
if relative_times:
|
||||
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
ts_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
ts_str = str(indexed_at)[:16].replace("T", " ")
|
||||
|
||||
# Source / Client
|
||||
source = "Bluesky"
|
||||
|
||||
# Format like Mastodon: add ", " after user and date
|
||||
return [user_str + ", ", text, ts_str + ", ", source]
|
||||
|
||||
|
||||
def compose_notification(notification, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky notification into a list of strings for display.
|
||||
Format matches Mastodon: [user, text, date]
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
# Author of the notification (who performed the action)
|
||||
author = g(notification, "author", {})
|
||||
handle = g(author, "handle", "unknown")
|
||||
display_name = g(author, "displayName") or g(author, "display_name") or handle
|
||||
|
||||
if show_screen_names:
|
||||
user_str = f"@{handle}"
|
||||
else:
|
||||
if display_name and display_name != handle:
|
||||
user_str = f"{display_name} (@{handle})"
|
||||
else:
|
||||
user_str = f"@{handle}"
|
||||
|
||||
# Notification reason/type
|
||||
reason = g(notification, "reason", "unknown")
|
||||
|
||||
# Get post text - try multiple locations depending on notification type
|
||||
record = g(notification, "record", {})
|
||||
post_text = ""
|
||||
|
||||
# For mentions, replies, quotes: text is in the record itself
|
||||
post_text = g(record, "text", "")
|
||||
|
||||
# For likes and reposts: try to get the subject post text
|
||||
if not post_text and reason in ("like", "repost"):
|
||||
# First check for hydrated subject text (added by NotificationBuffer)
|
||||
post_text = g(notification, "_subject_text", "")
|
||||
|
||||
# Check if there's a reasonSubject with embedded post data
|
||||
if not post_text:
|
||||
reason_subject = g(notification, "reasonSubject") or g(notification, "reason_subject")
|
||||
if reason_subject:
|
||||
# Sometimes the subject post is embedded
|
||||
subject_record = g(reason_subject, "record", {})
|
||||
post_text = g(subject_record, "text", "")
|
||||
|
||||
# Check if there's subject post data in other locations
|
||||
if not post_text:
|
||||
subject = g(record, "subject", {})
|
||||
subject_text = g(subject, "text", "")
|
||||
if subject_text:
|
||||
post_text = subject_text
|
||||
|
||||
# Format: action text without username (username is already in column 0)
|
||||
if reason == "like":
|
||||
if post_text:
|
||||
text = _("has added to favorites: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has added to favorites")
|
||||
elif reason == "repost":
|
||||
if post_text:
|
||||
text = _("has reposted: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has reposted")
|
||||
elif reason == "follow":
|
||||
text = _("has followed you.")
|
||||
elif reason == "mention":
|
||||
if post_text:
|
||||
text = _("has mentioned you: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has mentioned you")
|
||||
elif reason == "reply":
|
||||
if post_text:
|
||||
text = _("has replied: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has replied")
|
||||
elif reason == "quote":
|
||||
if post_text:
|
||||
text = _("has quoted your post: {status}").format(status=post_text)
|
||||
else:
|
||||
text = _("has quoted your post")
|
||||
elif reason == "starterpack-joined":
|
||||
text = _("has joined your starter pack.")
|
||||
else:
|
||||
text = reason
|
||||
|
||||
# Date
|
||||
indexed_at = g(notification, "indexedAt", "") or g(notification, "indexed_at", "")
|
||||
ts_str = ""
|
||||
if indexed_at:
|
||||
try:
|
||||
ts = arrow.get(indexed_at)
|
||||
if relative_times:
|
||||
ts_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
ts_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
ts_str = str(indexed_at)[:16].replace("T", " ")
|
||||
|
||||
return [user_str, text, ts_str]
|
||||
|
||||
|
||||
def compose_user(user, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky user profile for list display.
|
||||
Format matches Mastodon: single string with all info.
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
def resolve_profile(obj):
|
||||
if g(obj, "handle") or g(obj, "did"):
|
||||
return obj
|
||||
for key in ("subject", "actor", "profile", "user"):
|
||||
nested = g(obj, key)
|
||||
if nested and (g(nested, "handle") or g(nested, "did")):
|
||||
return nested
|
||||
return obj
|
||||
|
||||
profile = resolve_profile(user)
|
||||
handle = g(profile, "handle", "unknown")
|
||||
display_name = g(profile, "displayName") or g(profile, "display_name") or handle
|
||||
followers = g(profile, "followersCount") or g(profile, "followers_count") or 0
|
||||
following = g(profile, "followsCount") or g(profile, "follows_count") or 0
|
||||
posts = g(profile, "postsCount") or g(profile, "posts_count") or 0
|
||||
created_at = g(profile, "createdAt") or g(profile, "created_at")
|
||||
|
||||
ts = ""
|
||||
if created_at:
|
||||
try:
|
||||
original_date = arrow.get(created_at)
|
||||
if relative_times:
|
||||
ts = original_date.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
offset = db.get("utc_offset", 0) if isinstance(db, dict) else 0
|
||||
ts = original_date.shift(hours=offset).format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
ts = ""
|
||||
|
||||
# Format like Mastodon: "Name (@handle). X followers, Y following, Z posts. Joined date"
|
||||
# Use the exact same translatable string as Mastodon (sessions/mastodon/compose.py)
|
||||
if not ts:
|
||||
ts = _("unknown")
|
||||
return [_("%s (@%s). %s followers, %s following, %s posts. Joined %s") % (display_name, handle, followers, following, posts, ts)]
|
||||
|
||||
|
||||
def compose_convo(convo, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose a Bluesky chat conversation for list display.
|
||||
|
||||
Args:
|
||||
convo: Conversation dict or ATProto model
|
||||
db: Session database dict
|
||||
settings: Session settings
|
||||
relative_times: If True, use relative time formatting
|
||||
show_screen_names: If True, show only @handle
|
||||
safe: If True, handle exceptions gracefully
|
||||
|
||||
Returns:
|
||||
List of strings: [Participants, Last Message, Date]
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
members = g(convo, "members", [])
|
||||
self_did = db.get("user_id") if isinstance(db, dict) else None
|
||||
|
||||
# Build a local DID→name map from conversation members for sender resolution
|
||||
member_names = {}
|
||||
for m in members:
|
||||
did = g(m, "did", None)
|
||||
if did:
|
||||
name = g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
|
||||
member_names[did] = name
|
||||
|
||||
# Get other participants (exclude self)
|
||||
others = []
|
||||
for m in members:
|
||||
did = g(m, "did", None)
|
||||
if self_did and did == self_did:
|
||||
continue
|
||||
label = member_names.get(did, "unknown") if did else g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
|
||||
others.append(label)
|
||||
|
||||
if not others:
|
||||
others = [member_names.get(g(m, "did"), "unknown") if g(m, "did") else "unknown" for m in members]
|
||||
|
||||
participants = ", ".join(others)
|
||||
|
||||
# Last message
|
||||
last_msg_obj = g(convo, "lastMessage") or g(convo, "last_message")
|
||||
last_text = ""
|
||||
last_sender = ""
|
||||
|
||||
if last_msg_obj:
|
||||
last_text = g(last_msg_obj, "text", "")
|
||||
sender = g(last_msg_obj, "sender", None)
|
||||
if sender:
|
||||
last_sender = g(sender, "display_name") or g(sender, "displayName") or g(sender, "handle")
|
||||
if not last_sender:
|
||||
# Resolve DID via local member map
|
||||
sdid = g(sender, "did")
|
||||
if sdid:
|
||||
last_sender = member_names.get(sdid, "")
|
||||
if not last_sender:
|
||||
last_sender = sdid or ""
|
||||
|
||||
# Date
|
||||
date_str = ""
|
||||
if last_msg_obj:
|
||||
sent_at = g(last_msg_obj, "sentAt") or g(last_msg_obj, "sent_at")
|
||||
if sent_at:
|
||||
try:
|
||||
ts = arrow.get(sent_at)
|
||||
if relative_times:
|
||||
date_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
date_str = str(sent_at)[:16]
|
||||
|
||||
if last_sender and last_text:
|
||||
last_text = _("Last message from {user}: {text}").format(user=last_sender, text=last_text)
|
||||
elif last_text:
|
||||
last_text = _("Last message: {text}").format(text=last_text)
|
||||
|
||||
return [participants, last_text, date_str]
|
||||
|
||||
|
||||
def compose_chat_message(msg, db, settings, relative_times, show_screen_names=False, safe=True):
|
||||
"""
|
||||
Compose an individual chat message for display.
|
||||
|
||||
Args:
|
||||
msg: Chat message dict or ATProto model
|
||||
db: Session database dict
|
||||
settings: Session settings
|
||||
relative_times: If True, use relative time formatting
|
||||
show_screen_names: If True, show only @handle
|
||||
safe: If True, handle exceptions gracefully
|
||||
|
||||
Returns:
|
||||
List of strings: [Sender, Text, Date]
|
||||
"""
|
||||
def g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
sender = g(msg, "sender", {})
|
||||
sender_did = g(sender, "did")
|
||||
handle = g(sender, "display_name") or g(sender, "displayName") or g(sender, "handle")
|
||||
if not handle and sender_did and isinstance(db, dict):
|
||||
# Look up DID in member maps stored by ChatBuffer
|
||||
for key, val in db.items():
|
||||
if key.endswith("_members") and isinstance(val, dict) and sender_did in val:
|
||||
handle = val[sender_did]
|
||||
break
|
||||
if not handle:
|
||||
handle = sender_did or "unknown"
|
||||
|
||||
text = g(msg, "text", "")
|
||||
|
||||
# Date
|
||||
sent_at = g(msg, "sentAt") or g(msg, "sent_at")
|
||||
date_str = ""
|
||||
if sent_at:
|
||||
try:
|
||||
ts = arrow.get(sent_at)
|
||||
if relative_times:
|
||||
date_str = ts.humanize(locale=languageHandler.curLang[:2])
|
||||
else:
|
||||
date_str = ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||
except Exception:
|
||||
date_str = str(sent_at)[:16]
|
||||
|
||||
return [handle, text, date_str]
|
||||
901
src/sessions/blueski/session.py
Normal file
901
src/sessions/blueski/session.py
Normal file
@@ -0,0 +1,901 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import wx
|
||||
|
||||
from pubsub import pub
|
||||
|
||||
from sessions import base
|
||||
from sessions import session_exceptions as Exceptions
|
||||
import output
|
||||
import application
|
||||
import languageHandler
|
||||
|
||||
log = logging.getLogger("sessions.blueskiSession")
|
||||
|
||||
|
||||
class Language:
|
||||
"""Simple language object with code and name attributes, mimicking Mastodon.py format."""
|
||||
def __init__(self, code: str, name: str):
|
||||
self.code = code
|
||||
self.name = name
|
||||
|
||||
def __repr__(self):
|
||||
return f"Language({self.code}, {self.name})"
|
||||
|
||||
|
||||
def get_supported_languages():
|
||||
"""Returns the list of supported languages with translated names."""
|
||||
return [
|
||||
Language("", _("Not set")),
|
||||
Language("en", _("English")),
|
||||
Language("es", _("Spanish")),
|
||||
Language("fr", _("French")),
|
||||
Language("de", _("German")),
|
||||
Language("it", _("Italian")),
|
||||
Language("pt", _("Portuguese")),
|
||||
Language("ja", _("Japanese")),
|
||||
Language("ko", _("Korean")),
|
||||
Language("zh", _("Chinese")),
|
||||
Language("ru", _("Russian")),
|
||||
Language("ar", _("Arabic")),
|
||||
Language("hi", _("Hindi")),
|
||||
Language("nl", _("Dutch")),
|
||||
Language("pl", _("Polish")),
|
||||
Language("tr", _("Turkish")),
|
||||
Language("uk", _("Ukrainian")),
|
||||
Language("ca", _("Catalan")),
|
||||
Language("eu", _("Basque")),
|
||||
Language("gl", _("Galician")),
|
||||
]
|
||||
|
||||
# Optional import of atproto. Code handles absence gracefully.
|
||||
try:
|
||||
from atproto import Client as AtpClient # type: ignore
|
||||
except Exception: # ImportError or missing deps
|
||||
AtpClient = None # type: ignore
|
||||
|
||||
|
||||
class Session(base.baseSession):
|
||||
"""Minimal Bluesky (atproto) session for TWBlue.
|
||||
|
||||
Provides basic authorisation, login, and posting support to unblock
|
||||
the integration while keeping compatibility with TWBlue's session API.
|
||||
"""
|
||||
|
||||
name = "Bluesky"
|
||||
KIND = "blueski"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(Session, self).__init__(*args, **kwargs)
|
||||
self.config_spec = "blueski.defaults"
|
||||
self.type = "blueski"
|
||||
self.char_limit = 300
|
||||
self.api = None
|
||||
self.poller = None
|
||||
self.supported_languages = get_supported_languages()
|
||||
self.default_language = languageHandler.curLang[:2]
|
||||
# Subscribe to pub/sub events from the poller
|
||||
pub.subscribe(self.on_notification, "blueski.notification_received")
|
||||
|
||||
def _ensure_settings_namespace(self) -> None:
|
||||
"""Migrate legacy atprotosocial settings to blueski namespace."""
|
||||
try:
|
||||
if not self.settings:
|
||||
return
|
||||
if self.settings.get("blueski") is None and self.settings.get("atprotosocial") is not None:
|
||||
self.settings["blueski"] = dict(self.settings["atprotosocial"])
|
||||
try:
|
||||
del self.settings["atprotosocial"]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.settings.write()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
log.exception("Failed to migrate legacy Blueski settings")
|
||||
|
||||
def get_name(self):
|
||||
"""Return a human-friendly, stable account name for UI.
|
||||
|
||||
Prefer the user's handle if available so accounts are uniquely
|
||||
identifiable, falling back to a generic network name otherwise.
|
||||
"""
|
||||
self._ensure_settings_namespace()
|
||||
try:
|
||||
# Prefer runtime DB, then persisted settings, then SDK client
|
||||
handle = (
|
||||
self.db.get("user_name")
|
||||
or (self.settings and self.settings.get("blueski", {}).get("handle"))
|
||||
or (self.settings and self.settings.get("atprotosocial", {}).get("handle"))
|
||||
or (getattr(getattr(self, "api", None), "me", None) and self.api.me.handle)
|
||||
)
|
||||
if handle:
|
||||
return handle
|
||||
except Exception:
|
||||
pass
|
||||
return self.name
|
||||
|
||||
def _ensure_client(self):
|
||||
if AtpClient is None:
|
||||
raise RuntimeError(
|
||||
"The 'atproto' package is not installed. Install it to use Bluesky."
|
||||
)
|
||||
if self.api is None:
|
||||
self.api = AtpClient()
|
||||
return self.api
|
||||
|
||||
def login(self, verify_credentials=True):
|
||||
self._ensure_settings_namespace()
|
||||
if self.settings.get("blueski") is None:
|
||||
raise Exceptions.RequireCredentialsSessionError
|
||||
handle = self.settings["blueski"].get("handle")
|
||||
app_password = self.settings["blueski"].get("app_password")
|
||||
session_string = self.settings["blueski"].get("session_string")
|
||||
if not handle or (not app_password and not session_string):
|
||||
self.logged = False
|
||||
raise Exceptions.RequireCredentialsSessionError
|
||||
try:
|
||||
# Ensure db exists (can be set to None on logout paths)
|
||||
if not isinstance(self.db, dict):
|
||||
self.db = {}
|
||||
# Ensure general settings have a default for boost confirmations like Mastodon
|
||||
try:
|
||||
if "general" in self.settings and self.settings["general"].get("boost_mode") is None:
|
||||
self.settings["general"]["boost_mode"] = "ask"
|
||||
except Exception:
|
||||
pass
|
||||
api = self._ensure_client()
|
||||
# Prefer resuming session if we have one
|
||||
if session_string:
|
||||
try:
|
||||
api.import_session_string(session_string)
|
||||
except Exception:
|
||||
# Fall back to login below
|
||||
pass
|
||||
if not getattr(api, "me", None):
|
||||
# Fresh login
|
||||
api.login(handle, app_password)
|
||||
# Cache basics
|
||||
if getattr(api, "me", None) is None:
|
||||
raise RuntimeError("Bluesky SDK client has no 'me' after login")
|
||||
self.db["user_name"] = api.me.handle
|
||||
self.db["user_id"] = api.me.did
|
||||
# Persist DID in settings for session manager display
|
||||
self.settings["blueski"]["did"] = api.me.did
|
||||
# Export session for future reuse
|
||||
try:
|
||||
self.settings["blueski"]["session_string"] = api.export_session_string()
|
||||
except Exception:
|
||||
pass
|
||||
self.settings.write()
|
||||
self.logged = True
|
||||
log.debug("Logged in to Bluesky as %s", api.me.handle)
|
||||
except Exception as e:
|
||||
log.exception("Bluesky login failed")
|
||||
self.logged = False
|
||||
raise e
|
||||
|
||||
def authorise(self):
|
||||
self._ensure_settings_namespace()
|
||||
if self.logged:
|
||||
raise Exceptions.AlreadyAuthorisedError("Already authorised.")
|
||||
# Ask for handle
|
||||
dlg = wx.TextEntryDialog(
|
||||
None,
|
||||
_("Enter your Bluesky handle (e.g., username.bsky.social)"),
|
||||
_("Bluesky Login"),
|
||||
)
|
||||
if dlg.ShowModal() != wx.ID_OK:
|
||||
dlg.Destroy()
|
||||
return
|
||||
handle = dlg.GetValue().strip()
|
||||
dlg.Destroy()
|
||||
# Ask for app password
|
||||
pwd = wx.PasswordEntryDialog(
|
||||
None,
|
||||
_("Enter your Bluesky App Password (from Settings > App passwords)"),
|
||||
_("Bluesky Login"),
|
||||
)
|
||||
if pwd.ShowModal() != wx.ID_OK:
|
||||
pwd.Destroy()
|
||||
return
|
||||
app_password = pwd.GetValue().strip()
|
||||
pwd.Destroy()
|
||||
# Create session folder and config, then attempt login
|
||||
self.create_session_folder()
|
||||
self.get_configuration()
|
||||
self.settings["blueski"]["handle"] = handle
|
||||
self.settings["blueski"]["app_password"] = app_password
|
||||
self.settings.write()
|
||||
try:
|
||||
self.login()
|
||||
except Exceptions.RequireCredentialsSessionError:
|
||||
return
|
||||
except Exception:
|
||||
log.exception("Authorisation failed")
|
||||
wx.MessageBox(
|
||||
_("We could not log in to Bluesky. Please verify your handle and app password."),
|
||||
_("Login error"), wx.ICON_ERROR
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_message_url(self, message_id, context=None):
|
||||
# message_id may be full at:// URI or rkey
|
||||
self._ensure_settings_namespace()
|
||||
handle = self.db.get("user_name") or self.settings["blueski"].get("handle", "")
|
||||
rkey = message_id
|
||||
if isinstance(message_id, str) and message_id.startswith("at://"):
|
||||
parts = message_id.split("/")
|
||||
rkey = parts[-1]
|
||||
return f"https://bsky.app/profile/{handle}/post/{rkey}"
|
||||
|
||||
def send_message(self, message, files=None, reply_to=None, cw_text=None, is_sensitive=False, **kwargs):
|
||||
if not self.logged:
|
||||
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
|
||||
self._ensure_settings_namespace()
|
||||
try:
|
||||
api = self._ensure_client()
|
||||
# Basic text-only post for now. Attachments and CW can be extended later.
|
||||
# Prefer convenience if available
|
||||
uri = None
|
||||
text = message or ""
|
||||
# Naive CW handling: prepend CW label to text if provided
|
||||
if cw_text:
|
||||
text = f"CW: {cw_text}\n\n{text}" if text else f"CW: {cw_text}"
|
||||
|
||||
# Build base record
|
||||
record: dict[str, Any] = {
|
||||
"$type": "app.bsky.feed.post",
|
||||
"text": text,
|
||||
}
|
||||
|
||||
# Facets (Links and Mentions)
|
||||
try:
|
||||
facets = self._get_facets(text, api)
|
||||
if facets:
|
||||
record["facets"] = facets
|
||||
except:
|
||||
pass
|
||||
|
||||
# Labels (CW)
|
||||
if cw_text:
|
||||
record["labels"] = {
|
||||
"$type": "com.atproto.label.defs#selfLabels",
|
||||
"values": [{"val": "warn"}]
|
||||
}
|
||||
|
||||
# createdAt
|
||||
try:
|
||||
record["createdAt"] = api.get_current_time_iso()
|
||||
except Exception:
|
||||
pass
|
||||
# languages
|
||||
langs = kwargs.get("langs") or kwargs.get("languages")
|
||||
if isinstance(langs, (list, tuple)) and langs:
|
||||
record["langs"] = list(langs)
|
||||
|
||||
# Helper to build a StrongRef (uri+cid) for a given post URI
|
||||
def _get_strong_ref(uri: str):
|
||||
try:
|
||||
# Try typed models first
|
||||
posts_res = api.app.bsky.feed.get_posts({"uris": [uri]})
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
except Exception:
|
||||
try:
|
||||
posts_res = api.app.bsky.feed.get_posts(uris=[uri])
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
except Exception:
|
||||
posts = []
|
||||
if posts:
|
||||
post0 = posts[0]
|
||||
post_uri = getattr(post0, "uri", uri)
|
||||
post_cid = getattr(post0, "cid", None) or (post0.get("cid") if isinstance(post0, dict) else None)
|
||||
if post_cid:
|
||||
return {"uri": post_uri, "cid": post_cid}
|
||||
return None
|
||||
|
||||
# Upload images if provided
|
||||
embed_images = []
|
||||
if files:
|
||||
for f in files:
|
||||
path = f
|
||||
alt = ""
|
||||
if isinstance(f, dict):
|
||||
path = f.get("path") or f.get("file")
|
||||
alt = f.get("alt") or f.get("alt_text") or ""
|
||||
if not path:
|
||||
continue
|
||||
try:
|
||||
with open(path, "rb") as fp:
|
||||
data = fp.read()
|
||||
# Try typed upload
|
||||
try:
|
||||
up = api.com.atproto.repo.upload_blob(data)
|
||||
blob_ref = getattr(up, "blob", None) or getattr(up, "data", None) or up
|
||||
except Exception:
|
||||
# Some SDK variants expose upload via api.upload_blob
|
||||
up = api.upload_blob(data)
|
||||
blob_ref = getattr(up, "blob", None) or getattr(up, "data", None) or up
|
||||
if blob_ref:
|
||||
embed_images.append({
|
||||
"image": blob_ref,
|
||||
"alt": alt or "",
|
||||
})
|
||||
except Exception:
|
||||
log.exception("Error uploading media for Bluesky post")
|
||||
continue
|
||||
|
||||
# Quote post (takes precedence over images)
|
||||
quote_uri = kwargs.get("quote_uri") or kwargs.get("quote")
|
||||
if quote_uri:
|
||||
strong = _get_strong_ref(quote_uri)
|
||||
if strong:
|
||||
record["embed"] = {
|
||||
"$type": "app.bsky.embed.record",
|
||||
"record": strong,
|
||||
}
|
||||
embed_images = [] # Ignore images when quoting
|
||||
|
||||
if embed_images and not record.get("embed"):
|
||||
record["embed"] = {
|
||||
"$type": "app.bsky.embed.images",
|
||||
"images": embed_images,
|
||||
}
|
||||
|
||||
# Helper: normalize various incoming identifiers to an at:// URI
|
||||
def _normalize_to_uri(identifier: str) -> str | None:
|
||||
try:
|
||||
if not isinstance(identifier, str):
|
||||
return None
|
||||
if identifier.startswith("at://"):
|
||||
return identifier
|
||||
if "bsky.app/profile/" in identifier and "/post/" in identifier:
|
||||
# Accept full web URL and try to resolve via get_post_thread below
|
||||
return identifier
|
||||
# Accept bare rkey case by constructing a guess using own handle
|
||||
handle = self.db.get("user_name") or self.settings["blueski"].get("handle")
|
||||
did = self.db.get("user_id") or self.settings["blueski"].get("did")
|
||||
if handle and did and len(identifier) in (13, 14, 15):
|
||||
# rkey length is typically ~13 chars base32
|
||||
return f"at://{did}/app.bsky.feed.post/{identifier}"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
# Reply-to handling (sets correct root/parent strong refs)
|
||||
if reply_to:
|
||||
# Resolve to proper at:// uri when possible
|
||||
reply_uri = _normalize_to_uri(reply_to) or reply_to
|
||||
reply_cid = kwargs.get("reply_to_cid")
|
||||
parent_ref = None
|
||||
if reply_uri and reply_cid:
|
||||
parent_ref = {"uri": reply_uri, "cid": reply_cid}
|
||||
if not parent_ref:
|
||||
parent_ref = _get_strong_ref(reply_uri)
|
||||
root_ref = parent_ref
|
||||
# Try to fetch thread to find actual root for deep replies
|
||||
try:
|
||||
# atproto SDK usually exposes get_post_thread
|
||||
thread_res = None
|
||||
try:
|
||||
thread_res = api.app.bsky.feed.get_post_thread({"uri": reply_uri})
|
||||
except Exception:
|
||||
# Try typed model call variant if available
|
||||
from atproto import models as at_models # type: ignore
|
||||
params = at_models.AppBskyFeedGetPostThread.Params(uri=reply_uri)
|
||||
thread_res = api.app.bsky.feed.get_post_thread(params)
|
||||
thread = getattr(thread_res, "thread", None)
|
||||
# Walk to the root if present
|
||||
node = thread
|
||||
while node and getattr(node, "parent", None):
|
||||
node = getattr(node, "parent")
|
||||
root_uri = getattr(node, "post", None)
|
||||
if root_uri:
|
||||
root_uri = getattr(root_uri, "uri", None)
|
||||
if root_uri and isinstance(root_uri, str):
|
||||
maybe_root = _get_strong_ref(root_uri)
|
||||
if maybe_root:
|
||||
root_ref = maybe_root
|
||||
except Exception:
|
||||
# If anything fails, keep parent as root for a simple two-level reply
|
||||
pass
|
||||
if parent_ref:
|
||||
record["reply"] = {
|
||||
"root": root_ref or parent_ref,
|
||||
"parent": parent_ref,
|
||||
}
|
||||
|
||||
# Fallback to convenience if available
|
||||
try:
|
||||
if hasattr(api, "send_post") and not embed_images and not langs and not cw_text:
|
||||
res = api.send_post(text)
|
||||
uri = getattr(res, "uri", None) or getattr(res, "cid", None)
|
||||
else:
|
||||
out = api.com.atproto.repo.create_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"record": record,
|
||||
})
|
||||
uri = getattr(out, "uri", None)
|
||||
except Exception:
|
||||
log.exception("Error creating Bluesky post record")
|
||||
uri = None
|
||||
if not uri:
|
||||
raise RuntimeError("Post did not return a URI")
|
||||
|
||||
return uri
|
||||
except Exception:
|
||||
log.exception("Error sending Bluesky post")
|
||||
output.speak(_("An error occurred while posting to Bluesky."), True)
|
||||
return None
|
||||
|
||||
def _get_facets(self, text, api):
|
||||
facets = []
|
||||
# Mentions
|
||||
for m in re.finditer(r'@([a-zA-Z0-9.-]+)', text):
|
||||
handle = m.group(1)
|
||||
try:
|
||||
# We should probably cache this identity lookup
|
||||
res = api.com.atproto.identity.resolve_handle({'handle': handle})
|
||||
did = res.did
|
||||
facets.append({
|
||||
'index': {
|
||||
'byteStart': len(text[:m.start()].encode('utf-8')),
|
||||
'byteEnd': len(text[:m.end()].encode('utf-8'))
|
||||
},
|
||||
'features': [{'$type': 'app.bsky.richtext.facet#mention', 'did': did}]
|
||||
})
|
||||
except:
|
||||
continue
|
||||
# Links
|
||||
for m in re.finditer(r'(https?://[^\s]+)', text):
|
||||
url = m.group(1)
|
||||
facets.append({
|
||||
'index': {
|
||||
'byteStart': len(text[:m.start()].encode('utf-8')),
|
||||
'byteEnd': len(text[:m.end()].encode('utf-8'))
|
||||
},
|
||||
'features': [{'$type': 'app.bsky.richtext.facet#link', 'uri': url}]
|
||||
})
|
||||
return facets
|
||||
|
||||
def delete_post(self, uri: str) -> bool:
|
||||
"""Delete a post by its AT URI."""
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
# at://did:plc:xxx/app.bsky.feed.post/rkey
|
||||
parts = uri.split("/")
|
||||
rkey = parts[-1]
|
||||
api.com.atproto.repo.delete_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.feed.post",
|
||||
"rkey": rkey
|
||||
})
|
||||
return True
|
||||
except:
|
||||
log.exception("Error deleting Bluesky post")
|
||||
return False
|
||||
|
||||
def block_user(self, did: str) -> bool:
|
||||
"""Block a user by their DID."""
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
api.com.atproto.repo.create_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.graph.block",
|
||||
"record": {
|
||||
"$type": "app.bsky.graph.block",
|
||||
"subject": did,
|
||||
"createdAt": api.get_current_time_iso()
|
||||
}
|
||||
})
|
||||
return True
|
||||
except:
|
||||
log.exception("Error blocking Bluesky user")
|
||||
return False
|
||||
|
||||
def unblock_user(self, block_uri: str) -> bool:
|
||||
"""Unblock a user by the URI of the block record."""
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
parts = block_uri.split("/")
|
||||
rkey = parts[-1]
|
||||
api.com.atproto.repo.delete_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.graph.block",
|
||||
"rkey": rkey
|
||||
})
|
||||
return True
|
||||
except:
|
||||
log.exception("Error unblocking Bluesky user")
|
||||
return False
|
||||
|
||||
def get_profile(self, actor: str) -> Any:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
return api.app.bsky.actor.get_profile({"actor": actor})
|
||||
except Exception:
|
||||
log.exception("Error fetching Bluesky profile for %s", actor)
|
||||
return None
|
||||
|
||||
def get_profiles(self, actors: list[str]) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
if not actors:
|
||||
return {"items": []}
|
||||
# API limit is 25 actors per request, batch if needed
|
||||
all_profiles = []
|
||||
batch_size = 25
|
||||
for i in range(0, len(actors), batch_size):
|
||||
batch = actors[i:i + batch_size]
|
||||
try:
|
||||
res = api.app.bsky.actor.get_profiles({"actors": batch})
|
||||
profiles = getattr(res, "profiles", []) or []
|
||||
all_profiles.extend(profiles)
|
||||
except Exception:
|
||||
log.exception("Error fetching Bluesky profiles batch")
|
||||
return {"items": all_profiles}
|
||||
|
||||
def get_post_likes(self, uri: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
params = {"uri": uri, "limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
res = api.app.bsky.feed.get_likes(params)
|
||||
return {"items": getattr(res, "likes", []) or [], "cursor": getattr(res, "cursor", None)}
|
||||
except Exception:
|
||||
log.exception("Error fetching Bluesky likes for %s", uri)
|
||||
return {"items": [], "cursor": None}
|
||||
|
||||
def get_post_reposts(self, uri: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
params = {"uri": uri, "limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
# SDK uses get_reposted_by (camel or snake)
|
||||
feed = api.app.bsky.feed
|
||||
if hasattr(feed, "get_reposted_by"):
|
||||
res = feed.get_reposted_by(params)
|
||||
else:
|
||||
res = feed.get_repostedBy(params)
|
||||
return {"items": getattr(res, "reposted_by", None) or getattr(res, "repostedBy", None) or getattr(res, "reposted_by", []) or [], "cursor": getattr(res, "cursor", None)}
|
||||
except Exception:
|
||||
log.exception("Error fetching Bluesky reposts for %s", uri)
|
||||
return {"items": [], "cursor": None}
|
||||
|
||||
def follow_user(self, did: str) -> bool:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
api.com.atproto.repo.create_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.graph.follow",
|
||||
"record": {
|
||||
"$type": "app.bsky.graph.follow",
|
||||
"subject": did,
|
||||
"createdAt": api.get_current_time_iso()
|
||||
}
|
||||
})
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("Error following Bluesky user")
|
||||
return False
|
||||
|
||||
def unfollow_user(self, follow_uri: str) -> bool:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
parts = follow_uri.split("/")
|
||||
rkey = parts[-1]
|
||||
api.com.atproto.repo.delete_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.graph.follow",
|
||||
"rkey": rkey
|
||||
})
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("Error unfollowing Bluesky user")
|
||||
return False
|
||||
|
||||
def mute_user(self, did: str) -> bool:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
graph = api.app.bsky.graph
|
||||
if hasattr(graph, "mute_actor"):
|
||||
graph.mute_actor({"actor": did})
|
||||
elif hasattr(graph, "muteActor"):
|
||||
graph.muteActor({"actor": did})
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("Error muting Bluesky user")
|
||||
return False
|
||||
|
||||
def unmute_user(self, did: str) -> bool:
|
||||
api = self._ensure_client()
|
||||
try:
|
||||
graph = api.app.bsky.graph
|
||||
if hasattr(graph, "unmute_actor"):
|
||||
graph.unmute_actor({"actor": did})
|
||||
elif hasattr(graph, "unmuteActor"):
|
||||
graph.unmuteActor({"actor": did})
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("Error unmuting Bluesky user")
|
||||
return False
|
||||
|
||||
def repost(self, post_uri: str, post_cid: str | None = None) -> str | None:
|
||||
"""Create a simple repost of a given post. Returns URI of the repost record or None."""
|
||||
if not self.logged:
|
||||
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
|
||||
try:
|
||||
api = self._ensure_client()
|
||||
|
||||
def _get_strong_ref(uri: str):
|
||||
try:
|
||||
posts_res = api.app.bsky.feed.get_posts({"uris": [uri]})
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
except Exception:
|
||||
try:
|
||||
posts_res = api.app.bsky.feed.get_posts(uris=[uri])
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
except Exception:
|
||||
posts = []
|
||||
if posts:
|
||||
post0 = posts[0]
|
||||
s_uri = getattr(post0, "uri", uri)
|
||||
s_cid = getattr(post0, "cid", None) or (post0.get("cid") if isinstance(post0, dict) else None)
|
||||
if s_cid:
|
||||
return {"uri": s_uri, "cid": s_cid}
|
||||
return None
|
||||
|
||||
if not post_cid:
|
||||
strong = _get_strong_ref(post_uri)
|
||||
if not strong:
|
||||
return None
|
||||
post_uri = strong["uri"]
|
||||
post_cid = strong["cid"]
|
||||
|
||||
out = api.com.atproto.repo.create_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.feed.repost",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.repost",
|
||||
"subject": {"uri": post_uri, "cid": post_cid},
|
||||
"createdAt": getattr(api, "get_current_time_iso", lambda: None)() or None,
|
||||
},
|
||||
})
|
||||
return getattr(out, "uri", None)
|
||||
except Exception:
|
||||
log.exception("Error creating Bluesky repost record")
|
||||
return None
|
||||
|
||||
def like(self, post_uri: str, post_cid: str | None = None) -> str | None:
|
||||
"""Create a like for a given post."""
|
||||
if not self.logged:
|
||||
raise Exceptions.NotLoggedSessionError("You are not logged in yet.")
|
||||
try:
|
||||
api = self._ensure_client()
|
||||
|
||||
# Resolve strong ref if needed
|
||||
def _get_strong_ref(uri: str):
|
||||
try:
|
||||
posts_res = api.app.bsky.feed.get_posts({"uris": [uri]})
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
except Exception:
|
||||
try: posts_res = api.app.bsky.feed.get_posts(uris=[uri])
|
||||
except: posts_res = None
|
||||
posts = getattr(posts_res, "posts", None) or []
|
||||
if posts:
|
||||
p = posts[0]
|
||||
return {"uri": getattr(p, "uri", uri), "cid": getattr(p, "cid", None)}
|
||||
return None
|
||||
|
||||
if not post_cid:
|
||||
strong = _get_strong_ref(post_uri)
|
||||
if not strong: return None
|
||||
post_uri = strong["uri"]
|
||||
post_cid = strong["cid"]
|
||||
|
||||
out = api.com.atproto.repo.create_record({
|
||||
"repo": api.me.did,
|
||||
"collection": "app.bsky.feed.like",
|
||||
"record": {
|
||||
"$type": "app.bsky.feed.like",
|
||||
"subject": {"uri": post_uri, "cid": post_cid},
|
||||
"createdAt": getattr(api, "get_current_time_iso", lambda: None)() or None,
|
||||
},
|
||||
})
|
||||
return getattr(out, "uri", None)
|
||||
except Exception:
|
||||
log.exception("Error creating Bluesky like")
|
||||
return None
|
||||
|
||||
def get_followers(self, actor: str | None = None, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
actor = actor or api.me.did
|
||||
res = api.app.bsky.graph.get_followers({"actor": actor, "limit": limit, "cursor": cursor})
|
||||
return {"items": res.followers, "cursor": res.cursor}
|
||||
|
||||
def get_follows(self, actor: str | None = None, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
actor = actor or api.me.did
|
||||
res = api.app.bsky.graph.get_follows({"actor": actor, "limit": limit, "cursor": cursor})
|
||||
return {"items": res.follows, "cursor": res.cursor}
|
||||
|
||||
def get_blocks(self, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
res = api.app.bsky.graph.get_blocks({"limit": limit, "cursor": cursor})
|
||||
return {"items": res.blocks, "cursor": res.cursor}
|
||||
|
||||
def list_convos(self, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
# Chat API requires using the chat proxy
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
params = {"limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
try:
|
||||
res = dm.list_convos(params)
|
||||
return {"items": res.convos, "cursor": getattr(res, "cursor", None)}
|
||||
except Exception:
|
||||
log.exception("Error listing conversations")
|
||||
return {"items": [], "cursor": None}
|
||||
|
||||
def get_convo(self, convo_id: str):
|
||||
"""Fetch a single conversation by ID, returning the convo object or None."""
|
||||
api = self._ensure_client()
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
try:
|
||||
res = dm.get_convo({"convoId": convo_id})
|
||||
return res.convo
|
||||
except Exception:
|
||||
log.exception("Error fetching conversation %s", convo_id)
|
||||
return None
|
||||
|
||||
def get_convo_messages(self, convo_id: str, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
||||
api = self._ensure_client()
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
params = {"convoId": convo_id, "limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
try:
|
||||
res = dm.get_messages(params)
|
||||
return {"items": res.messages, "cursor": getattr(res, "cursor", None)}
|
||||
except Exception:
|
||||
log.exception("Error getting conversation messages")
|
||||
return {"items": [], "cursor": None}
|
||||
|
||||
def send_chat_message(self, convo_id: str, text: str) -> Any:
|
||||
api = self._ensure_client()
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
try:
|
||||
return dm.send_message({
|
||||
"convoId": convo_id,
|
||||
"message": {
|
||||
"text": text
|
||||
}
|
||||
})
|
||||
except Exception:
|
||||
log.exception("Error sending chat message")
|
||||
raise
|
||||
|
||||
def get_or_create_convo(self, members: list[str]) -> dict[str, Any] | None:
|
||||
"""Get or create a conversation with the given members (DIDs)."""
|
||||
api = self._ensure_client()
|
||||
dm_client = api.with_bsky_chat_proxy()
|
||||
dm = dm_client.chat.bsky.convo
|
||||
try:
|
||||
res = dm.get_convo_for_members({"members": members})
|
||||
return res.convo
|
||||
except Exception:
|
||||
log.exception("Error getting/creating conversation")
|
||||
return None
|
||||
|
||||
# Streaming/Polling methods
|
||||
|
||||
def start_streaming(self):
|
||||
"""Start the background poller for notifications."""
|
||||
if not self.logged:
|
||||
log.debug("Cannot start Bluesky poller: not logged in.")
|
||||
return
|
||||
|
||||
if self.poller is not None and self.poller.is_alive():
|
||||
log.debug("Bluesky poller already running for %s", self.get_name())
|
||||
return
|
||||
|
||||
try:
|
||||
from sessions.blueski.streaming import BlueskyPoller
|
||||
poll_interval = 60
|
||||
try:
|
||||
poll_interval = self.settings["general"].get("update_period", 60)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.poller = BlueskyPoller(
|
||||
session=self,
|
||||
session_name=self.get_name(),
|
||||
poll_interval=poll_interval
|
||||
)
|
||||
self.poller.start()
|
||||
log.info("Started Bluesky poller for session %s", self.get_name())
|
||||
except Exception:
|
||||
log.exception("Failed to start Bluesky poller")
|
||||
|
||||
def stop_streaming(self):
|
||||
"""Stop the background poller."""
|
||||
if self.poller is not None:
|
||||
self.poller.stop()
|
||||
self.poller = None
|
||||
log.info("Stopped Bluesky poller for session %s", self.get_name())
|
||||
|
||||
def on_notification(self, notification, session_name):
|
||||
"""Handle notification received from the poller via pub/sub."""
|
||||
# Discard if notification is for a different session
|
||||
if self.get_name() != session_name:
|
||||
return
|
||||
|
||||
# Add notification to the notifications buffer
|
||||
try:
|
||||
num = self.order_buffer("notifications", [notification])
|
||||
if num > 0:
|
||||
pub.sendMessage(
|
||||
"blueski.new_item",
|
||||
session_name=self.get_name(),
|
||||
item=notification,
|
||||
_buffers=["notifications"]
|
||||
)
|
||||
except Exception:
|
||||
log.exception("Error processing Bluesky notification")
|
||||
|
||||
def order_buffer(self, buffer_name, items):
|
||||
"""Add items to the specified buffer's database.
|
||||
|
||||
Returns the number of new items added.
|
||||
"""
|
||||
if buffer_name not in self.db:
|
||||
self.db[buffer_name] = []
|
||||
|
||||
# Get existing URIs to avoid duplicates
|
||||
existing_uris = set()
|
||||
for item in self.db[buffer_name]:
|
||||
uri = None
|
||||
if isinstance(item, dict):
|
||||
uri = item.get("uri")
|
||||
else:
|
||||
uri = getattr(item, "uri", None)
|
||||
if uri:
|
||||
existing_uris.add(uri)
|
||||
|
||||
# Add new items
|
||||
new_count = 0
|
||||
for item in items:
|
||||
uri = None
|
||||
if isinstance(item, dict):
|
||||
uri = item.get("uri")
|
||||
else:
|
||||
uri = getattr(item, "uri", None)
|
||||
|
||||
if uri and uri in existing_uris:
|
||||
continue
|
||||
|
||||
if uri:
|
||||
existing_uris.add(uri)
|
||||
|
||||
# Insert at beginning (newest first)
|
||||
self.db[buffer_name].insert(0, item)
|
||||
new_count += 1
|
||||
|
||||
return new_count
|
||||
196
src/sessions/blueski/streaming.py
Normal file
196
src/sessions/blueski/streaming.py
Normal file
@@ -0,0 +1,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Bluesky polling-based update system for TWBlue.
|
||||
|
||||
Since Bluesky's Firehose requires complex CAR/CBOR decoding and filtering
|
||||
of millions of events, we use a polling approach instead of true streaming.
|
||||
This matches the existing start_stream() pattern used by buffers.
|
||||
|
||||
Events are published via pub/sub to maintain consistency with Mastodon's
|
||||
streaming implementation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pubsub import pub
|
||||
|
||||
log = logging.getLogger("sessions.blueski.streaming")
|
||||
|
||||
|
||||
class BlueskyPoller:
|
||||
"""
|
||||
Polling-based update system for Bluesky.
|
||||
|
||||
Periodically checks for new notifications and publishes them via pub/sub.
|
||||
This provides a similar interface to Mastodon's StreamListener but uses
|
||||
polling instead of WebSocket streaming.
|
||||
"""
|
||||
|
||||
def __init__(self, session, session_name, poll_interval=60):
|
||||
"""
|
||||
Initialize the poller.
|
||||
|
||||
Args:
|
||||
session: The Bluesky session instance
|
||||
session_name: Unique identifier for this session (for pub/sub routing)
|
||||
poll_interval: Seconds between API polls (default 60, min 30)
|
||||
"""
|
||||
self.session = session
|
||||
self.session_name = session_name
|
||||
self.poll_interval = max(30, poll_interval) # Minimum 30 seconds to respect rate limits
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = None
|
||||
self._last_notification_cursor = None
|
||||
self._last_seen_notification_uri = None
|
||||
|
||||
def start(self):
|
||||
"""Start the polling thread."""
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
log.warning(f"Bluesky poller for {self.session_name} is already running.")
|
||||
return
|
||||
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(
|
||||
target=self._poll_loop,
|
||||
name=f"BlueskyPoller-{self.session_name}",
|
||||
daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
log.info(f"Bluesky poller started for {self.session_name} (interval: {self.poll_interval}s)")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the polling thread."""
|
||||
if self._thread is None:
|
||||
return
|
||||
|
||||
self._stop_event.set()
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
log.info(f"Bluesky poller stopped for {self.session_name}")
|
||||
|
||||
def is_alive(self):
|
||||
"""Check if the polling thread is running."""
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Main polling loop running in background thread."""
|
||||
log.debug(f"Polling loop started for {self.session_name}")
|
||||
|
||||
# Initial delay to let the app fully initialize
|
||||
time.sleep(5)
|
||||
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self._check_notifications()
|
||||
except Exception as e:
|
||||
log.exception(f"Error in Bluesky polling loop for {self.session_name}: {e}")
|
||||
|
||||
# Wait for next poll interval, checking stop event periodically
|
||||
for _ in range(self.poll_interval):
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
time.sleep(1)
|
||||
|
||||
log.debug(f"Polling loop ended for {self.session_name}")
|
||||
|
||||
def _check_notifications(self):
|
||||
"""Check for new notifications and publish events."""
|
||||
if not self.session.logged:
|
||||
return
|
||||
|
||||
try:
|
||||
api = self.session._ensure_client()
|
||||
if not api:
|
||||
return
|
||||
|
||||
# Fetch recent notifications
|
||||
res = api.app.bsky.notification.list_notifications({"limit": 20})
|
||||
notifications = getattr(res, "notifications", [])
|
||||
|
||||
if not notifications:
|
||||
return
|
||||
|
||||
# Track which notifications are new
|
||||
new_notifications = []
|
||||
newest_uri = None
|
||||
|
||||
for notif in notifications:
|
||||
uri = getattr(notif, "uri", None)
|
||||
if not uri:
|
||||
continue
|
||||
|
||||
# First time running - just record the newest and don't flood
|
||||
if self._last_seen_notification_uri is None:
|
||||
newest_uri = uri
|
||||
break
|
||||
|
||||
# Check if we've seen this notification before
|
||||
if uri == self._last_seen_notification_uri:
|
||||
break
|
||||
|
||||
new_notifications.append(notif)
|
||||
if newest_uri is None:
|
||||
newest_uri = uri
|
||||
|
||||
# Update last seen
|
||||
if newest_uri:
|
||||
self._last_seen_notification_uri = newest_uri
|
||||
|
||||
# Publish new notifications (in reverse order so oldest first)
|
||||
for notif in reversed(new_notifications):
|
||||
self._publish_notification(notif)
|
||||
|
||||
except Exception as e:
|
||||
log.debug(f"Error checking notifications for {self.session_name}: {e}")
|
||||
|
||||
def _publish_notification(self, notification):
|
||||
"""Publish a notification event via pub/sub."""
|
||||
try:
|
||||
reason = getattr(notification, "reason", "unknown")
|
||||
log.debug(f"Publishing Bluesky notification: {reason} for {self.session_name}")
|
||||
|
||||
pub.sendMessage(
|
||||
"blueski.notification_received",
|
||||
notification=notification,
|
||||
session_name=self.session_name
|
||||
)
|
||||
|
||||
# Also publish specific events for certain notification types
|
||||
if reason == "mention":
|
||||
pub.sendMessage(
|
||||
"blueski.mention_received",
|
||||
notification=notification,
|
||||
session_name=self.session_name
|
||||
)
|
||||
elif reason == "reply":
|
||||
pub.sendMessage(
|
||||
"blueski.reply_received",
|
||||
notification=notification,
|
||||
session_name=self.session_name
|
||||
)
|
||||
elif reason == "follow":
|
||||
pub.sendMessage(
|
||||
"blueski.follow_received",
|
||||
notification=notification,
|
||||
session_name=self.session_name
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(f"Error publishing notification event: {e}")
|
||||
|
||||
|
||||
def create_poller(session, session_name, poll_interval=60):
|
||||
"""
|
||||
Factory function to create a BlueskyPoller instance.
|
||||
|
||||
Args:
|
||||
session: The Bluesky session instance
|
||||
session_name: Unique identifier for this session
|
||||
poll_interval: Seconds between polls (default 60)
|
||||
|
||||
Returns:
|
||||
BlueskyPoller instance
|
||||
"""
|
||||
return BlueskyPoller(session, session_name, poll_interval)
|
||||
307
src/sessions/blueski/templates.py
Normal file
307
src/sessions/blueski/templates.py
Normal file
@@ -0,0 +1,307 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import arrow
|
||||
import languageHandler
|
||||
from string import Template
|
||||
from sessions.blueski import utils
|
||||
|
||||
|
||||
post_variables = [
|
||||
"date",
|
||||
"display_name",
|
||||
"screen_name",
|
||||
"reply_to",
|
||||
"source",
|
||||
"lang",
|
||||
"safe_text",
|
||||
"text",
|
||||
"image_descriptions",
|
||||
"visibility",
|
||||
"pinned",
|
||||
]
|
||||
person_variables = [
|
||||
"display_name",
|
||||
"screen_name",
|
||||
"description",
|
||||
"followers",
|
||||
"following",
|
||||
"favorites",
|
||||
"posts",
|
||||
"created_at",
|
||||
]
|
||||
notification_variables = ["display_name", "screen_name", "text", "date"]
|
||||
|
||||
|
||||
def _g(obj, key, default=None):
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
|
||||
def _extract_labels(obj):
|
||||
labels = _g(obj, "labels", None)
|
||||
if labels is None:
|
||||
return []
|
||||
if isinstance(labels, dict):
|
||||
return labels.get("values", []) or []
|
||||
if isinstance(labels, list):
|
||||
return labels
|
||||
return []
|
||||
|
||||
|
||||
def _extract_cw_text(post, record):
|
||||
labels = _extract_labels(post) + _extract_labels(record)
|
||||
for label in labels:
|
||||
val = _g(label, "val", "")
|
||||
if val == "warn":
|
||||
return _("Sensitive Content")
|
||||
if isinstance(val, str) and val.startswith("warn:"):
|
||||
return val.split("warn:", 1)[-1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_image_descriptions(post, record):
|
||||
def collect_images(embed):
|
||||
if not embed:
|
||||
return []
|
||||
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
|
||||
if "recordWithMedia" in etype:
|
||||
media = _g(embed, "media")
|
||||
mtype = _g(media, "$type") or _g(media, "py_type") or ""
|
||||
if "images" in mtype:
|
||||
return list(_g(media, "images", []) or [])
|
||||
return []
|
||||
if "images" in etype:
|
||||
return list(_g(embed, "images", []) or [])
|
||||
return []
|
||||
|
||||
images = []
|
||||
images.extend(collect_images(_g(post, "embed")))
|
||||
if not images:
|
||||
images.extend(collect_images(_g(record, "embed")))
|
||||
|
||||
descriptions = []
|
||||
for idx, img in enumerate(images, start=1):
|
||||
alt = _g(img, "alt", "") or ""
|
||||
if alt:
|
||||
descriptions.append(_("Media description {index}: {alt}").format(index=idx, alt=alt))
|
||||
return "\n".join(descriptions)
|
||||
|
||||
|
||||
def process_date(field, relative_times=True, offset_hours=0):
|
||||
original_date = arrow.get(field)
|
||||
if relative_times:
|
||||
return original_date.humanize(locale=languageHandler.curLang[:2])
|
||||
return original_date.shift(hours=offset_hours).format(_("dddd, MMMM D, YYYY H:m:s"), locale=languageHandler.curLang[:2])
|
||||
|
||||
|
||||
def _extract_link_info(post, record):
|
||||
"""Extract link information from post embeds and facets."""
|
||||
embed = _g(post, "embed")
|
||||
if not embed:
|
||||
return None
|
||||
|
||||
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
|
||||
|
||||
# Direct external embed
|
||||
if "external" in etype.lower():
|
||||
ext = _g(embed, "external", {})
|
||||
title = _g(ext, "title", "")
|
||||
if title:
|
||||
return title
|
||||
|
||||
# RecordWithMedia with external
|
||||
if "recordWithMedia" in etype:
|
||||
media = _g(embed, "media", {})
|
||||
mtype = _g(media, "$type") or _g(media, "py_type") or ""
|
||||
if "external" in mtype.lower():
|
||||
ext = _g(media, "external", {})
|
||||
title = _g(ext, "title", "")
|
||||
if title:
|
||||
return title
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def render_post(post, template, settings, relative_times=False, offset_hours=0):
|
||||
actual_post = _g(post, "post", post)
|
||||
record = _g(actual_post, "record") or _g(post, "record") or {}
|
||||
author = _g(actual_post, "author") or _g(post, "author") or {}
|
||||
|
||||
reason = _g(post, "reason")
|
||||
is_repost = False
|
||||
reposter = None
|
||||
if reason:
|
||||
rtype = _g(reason, "$type") or _g(reason, "py_type") or ""
|
||||
if "reasonRepost" in rtype:
|
||||
is_repost = True
|
||||
reposter = _g(reason, "by")
|
||||
|
||||
if is_repost and reposter:
|
||||
display_name = _g(reposter, "displayName") or _g(reposter, "display_name") or _g(reposter, "handle", "")
|
||||
screen_name = _g(reposter, "handle", "")
|
||||
else:
|
||||
display_name = _g(author, "displayName") or _g(author, "display_name") or _g(author, "handle", "")
|
||||
screen_name = _g(author, "handle", "")
|
||||
|
||||
text = _g(record, "text", "") or ""
|
||||
if is_repost:
|
||||
original_handle = _g(author, "handle", "")
|
||||
text = _("Reposted from @{handle}: {text}").format(handle=original_handle, text=text)
|
||||
|
||||
quote_info = utils.extract_quoted_post_info(post)
|
||||
if quote_info:
|
||||
if quote_info["kind"] == "not_found":
|
||||
text += f" [{_('Quoted post not found')}]"
|
||||
elif quote_info["kind"] == "blocked":
|
||||
text += f" [{_('Quoted post blocked')}]"
|
||||
elif quote_info["kind"] == "feed":
|
||||
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
|
||||
else:
|
||||
q_handle = quote_info.get("handle", "unknown")
|
||||
q_text = quote_info.get("text", "")
|
||||
if q_text:
|
||||
text += " " + _("Quoting @{handle}: {text}").format(handle=q_handle, text=q_text)
|
||||
else:
|
||||
text += " " + _("Quoting @{handle}").format(handle=q_handle)
|
||||
|
||||
# Add link indicator for external embeds
|
||||
link_title = _extract_link_info(actual_post, record)
|
||||
if link_title:
|
||||
text += f" [{_('Link')}: {link_title}]"
|
||||
|
||||
reply_to_handle = utils.extract_reply_to_handle(post)
|
||||
reply_to = ""
|
||||
if reply_to_handle:
|
||||
reply_to = _("Replying to @{handle}. ").format(handle=reply_to_handle)
|
||||
|
||||
cw_text = _extract_cw_text(actual_post, record)
|
||||
safe_text = text
|
||||
if cw_text:
|
||||
# Include link info in safe_text even with content warning
|
||||
if link_title:
|
||||
safe_text = _("Content warning: {cw}").format(cw=cw_text) + f" [{_('Link')}: {link_title}]"
|
||||
else:
|
||||
safe_text = _("Content warning: {cw}").format(cw=cw_text)
|
||||
|
||||
# Backward compatibility: older user templates may not include $reply_to.
|
||||
# In that case, prepend the reply marker directly so users still get context.
|
||||
if reply_to and "$reply_to" not in template:
|
||||
text = reply_to + text
|
||||
safe_text = reply_to + safe_text
|
||||
reply_to = ""
|
||||
|
||||
created_at = _g(record, "createdAt") or _g(record, "created_at")
|
||||
indexed_at = _g(actual_post, "indexedAt") or _g(actual_post, "indexed_at")
|
||||
date_field = created_at or indexed_at
|
||||
date = process_date(date_field, relative_times, offset_hours) if date_field else ""
|
||||
|
||||
langs = _g(record, "langs") or _g(record, "languages") or []
|
||||
lang = langs[0] if isinstance(langs, list) and langs else ""
|
||||
|
||||
image_descriptions = _extract_image_descriptions(actual_post, record)
|
||||
|
||||
available_data = dict(
|
||||
date=date,
|
||||
display_name=display_name,
|
||||
screen_name=screen_name,
|
||||
reply_to=reply_to,
|
||||
source="Bluesky",
|
||||
lang=lang,
|
||||
safe_text=safe_text,
|
||||
text=text,
|
||||
image_descriptions=image_descriptions,
|
||||
visibility=_("Public"),
|
||||
pinned="",
|
||||
)
|
||||
return Template(_(template)).safe_substitute(**available_data)
|
||||
|
||||
|
||||
def render_user(user, template, settings, relative_times=True, offset_hours=0):
|
||||
# Resolve nested profile structure (subject, actor, profile, user)
|
||||
def resolve_profile(obj):
|
||||
if _g(obj, "handle") or _g(obj, "did"):
|
||||
return obj
|
||||
for key in ("subject", "actor", "profile", "user"):
|
||||
nested = _g(obj, key)
|
||||
if nested and (_g(nested, "handle") or _g(nested, "did")):
|
||||
return nested
|
||||
return obj
|
||||
|
||||
profile = resolve_profile(user)
|
||||
display_name = _g(profile, "displayName") or _g(profile, "display_name") or _g(profile, "handle", "")
|
||||
screen_name = _g(profile, "handle", "")
|
||||
description = _g(profile, "description", "") or ""
|
||||
followers = _g(profile, "followersCount") or _g(profile, "followers_count") or 0
|
||||
following = _g(profile, "followsCount") or _g(profile, "follows_count") or 0
|
||||
posts = _g(profile, "postsCount") or _g(profile, "posts_count") or 0
|
||||
created_at = _g(profile, "createdAt") or _g(profile, "created_at")
|
||||
created = ""
|
||||
if created_at:
|
||||
created = process_date(created_at, relative_times, offset_hours)
|
||||
|
||||
available_data = dict(
|
||||
display_name=display_name,
|
||||
screen_name=screen_name,
|
||||
description=description,
|
||||
followers=followers,
|
||||
following=following,
|
||||
favorites="",
|
||||
posts=posts,
|
||||
created_at=created,
|
||||
)
|
||||
return Template(_(template)).safe_substitute(**available_data)
|
||||
|
||||
|
||||
def render_notification(notification, template, post_template, settings, relative_times=False, offset_hours=0):
|
||||
author = _g(notification, "author") or {}
|
||||
display_name = _g(author, "displayName") or _g(author, "display_name") or _g(author, "handle", "")
|
||||
screen_name = _g(author, "handle", "")
|
||||
reason = _g(notification, "reason", "unknown")
|
||||
record = _g(notification, "record") or {}
|
||||
|
||||
# Get post text - try multiple locations depending on notification type
|
||||
post_text = _g(record, "text", "") or ""
|
||||
|
||||
# For likes and reposts: try to get the subject post text
|
||||
if not post_text and reason in ("like", "repost"):
|
||||
# First check for hydrated subject text (added by NotificationBuffer)
|
||||
post_text = _g(notification, "_subject_text", "") or ""
|
||||
|
||||
# Check if there's a reasonSubject with embedded post data
|
||||
if not post_text:
|
||||
reason_subject = _g(notification, "reasonSubject") or _g(notification, "reason_subject")
|
||||
if reason_subject:
|
||||
subject_record = _g(reason_subject, "record", {})
|
||||
post_text = _g(subject_record, "text", "") or ""
|
||||
|
||||
# Check subject in record
|
||||
if not post_text:
|
||||
subject = _g(record, "subject", {})
|
||||
post_text = _g(subject, "text", "") or ""
|
||||
|
||||
# Format: action text without username (username is already in display_name for template)
|
||||
if reason == "like":
|
||||
text = _("has added to favorites: {status}").format(status=post_text) if post_text else _("has added to favorites")
|
||||
elif reason == "repost":
|
||||
text = _("has reposted: {status}").format(status=post_text) if post_text else _("has reposted")
|
||||
elif reason == "follow":
|
||||
text = _("has followed you.")
|
||||
elif reason == "mention":
|
||||
text = _("has mentioned you: {status}").format(status=post_text) if post_text else _("has mentioned you")
|
||||
elif reason == "reply":
|
||||
text = _("has replied: {status}").format(status=post_text) if post_text else _("has replied")
|
||||
elif reason == "quote":
|
||||
text = _("has quoted your post: {status}").format(status=post_text) if post_text else _("has quoted your post")
|
||||
else:
|
||||
text = reason
|
||||
|
||||
indexed_at = _g(notification, "indexedAt") or _g(notification, "indexed_at")
|
||||
date = process_date(indexed_at, relative_times, offset_hours) if indexed_at else ""
|
||||
|
||||
available_data = dict(
|
||||
display_name=display_name,
|
||||
screen_name=screen_name,
|
||||
text=text,
|
||||
date=date,
|
||||
)
|
||||
return Template(_(template)).safe_substitute(**available_data)
|
||||
444
src/sessions/blueski/utils.py
Normal file
444
src/sessions/blueski/utils.py
Normal file
@@ -0,0 +1,444 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Utility functions for Bluesky session.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
log = logging.getLogger("sessions.blueski.utils")
|
||||
|
||||
url_re = re.compile(r'https?://[^\s<>\[\]()"\',]+[^\s<>\[\]()"\',.:;!?]')
|
||||
|
||||
|
||||
def g(obj, key, default=None):
|
||||
"""Helper to get attribute from dict or object."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
return getattr(obj, key, default)
|
||||
|
||||
|
||||
def is_audio_or_video(post):
|
||||
"""
|
||||
Check if post contains audio or video content.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object (FeedViewPost or PostView)
|
||||
|
||||
Returns:
|
||||
bool: True if post has audio/video media
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if not embed:
|
||||
return False
|
||||
|
||||
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||
|
||||
# Check for video embed
|
||||
if "video" in etype.lower():
|
||||
return True
|
||||
|
||||
# Check for external link that might be video (YouTube, etc.)
|
||||
if "external" in etype.lower():
|
||||
ext = g(embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"]
|
||||
for host in video_hosts:
|
||||
if host in uri.lower():
|
||||
return True
|
||||
|
||||
# Check in recordWithMedia wrapper
|
||||
if "recordwithmedia" in etype.lower():
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||
if "video" in mtype.lower():
|
||||
return True
|
||||
if "external" in mtype.lower():
|
||||
ext = g(media, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
video_hosts = ["youtube.com", "youtu.be", "vimeo.com", "twitch.tv", "dailymotion.com"]
|
||||
for host in video_hosts:
|
||||
if host in uri.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _extract_images_from_embed(embed):
|
||||
"""Extract image URLs from an embed object."""
|
||||
images = []
|
||||
if not embed:
|
||||
return images
|
||||
|
||||
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||
|
||||
def extract_images(img_list):
|
||||
result = []
|
||||
for img in (img_list or []):
|
||||
url = None
|
||||
# Try all possible URL field names
|
||||
for key in ["fullsize", "thumb", "url", "uri", "src"]:
|
||||
val = g(img, key)
|
||||
if val and isinstance(val, str) and val.startswith("http"):
|
||||
url = val
|
||||
break
|
||||
# Also check for nested 'image' object
|
||||
if not url:
|
||||
image_obj = g(img, "image", {})
|
||||
if image_obj:
|
||||
for key in ["ref", "$link", "url", "uri"]:
|
||||
val = g(image_obj, key)
|
||||
if val:
|
||||
url = val
|
||||
break
|
||||
if url:
|
||||
result.append({
|
||||
"url": url,
|
||||
"alt": g(img, "alt", "") or ""
|
||||
})
|
||||
return result
|
||||
|
||||
# Direct images embed (app.bsky.embed.images or app.bsky.embed.images#view)
|
||||
if "images" in etype.lower():
|
||||
images.extend(extract_images(g(embed, "images", [])))
|
||||
|
||||
# Check in recordWithMedia wrapper
|
||||
if "recordwithmedia" in etype.lower():
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||
if "images" in mtype.lower():
|
||||
images.extend(extract_images(g(media, "images", [])))
|
||||
|
||||
return images
|
||||
|
||||
|
||||
def is_image(post):
|
||||
"""
|
||||
Check if post contains image content.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object (FeedViewPost or PostView)
|
||||
|
||||
Returns:
|
||||
bool: True if post has image media
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if not embed:
|
||||
return False
|
||||
|
||||
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||
|
||||
# Direct images embed
|
||||
if "images" in etype.lower():
|
||||
images = g(embed, "images", [])
|
||||
if images and len(images) > 0:
|
||||
return True
|
||||
|
||||
# Check in recordWithMedia wrapper
|
||||
if "recordwithmedia" in etype.lower():
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||
if "images" in mtype.lower():
|
||||
images = g(media, "images", [])
|
||||
if images and len(images) > 0:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_image_urls(post):
|
||||
"""
|
||||
Get URLs for image attachments from post for OCR.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object
|
||||
|
||||
Returns:
|
||||
list: List of dicts with 'url' and 'alt' keys
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
embed = g(actual_post, "embed", None)
|
||||
return _extract_images_from_embed(embed)
|
||||
|
||||
|
||||
def get_media_urls(post):
|
||||
"""
|
||||
Get URLs for media attachments (video/audio) from post.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object
|
||||
|
||||
Returns:
|
||||
list: List of media URLs
|
||||
"""
|
||||
urls = []
|
||||
actual_post = g(post, "post", post)
|
||||
embed = g(actual_post, "embed", None)
|
||||
if not embed:
|
||||
return urls
|
||||
|
||||
etype = g(embed, "$type") or g(embed, "py_type") or ""
|
||||
|
||||
def extract_video_urls(video_embed):
|
||||
"""Extract URLs from a video embed object."""
|
||||
result = []
|
||||
# Playlist URL (HLS stream)
|
||||
playlist = g(video_embed, "playlist", None)
|
||||
if playlist:
|
||||
result.append(playlist)
|
||||
# Alternative URL fields
|
||||
for key in ["url", "uri"]:
|
||||
val = g(video_embed, key)
|
||||
if val and val not in result:
|
||||
result.append(val)
|
||||
return result
|
||||
|
||||
# Direct video embed (app.bsky.embed.video#view)
|
||||
if "video" in etype.lower():
|
||||
urls.extend(extract_video_urls(embed))
|
||||
|
||||
# Check in recordWithMedia wrapper
|
||||
if "recordWithMedia" in etype or "record_with_media" in etype.lower():
|
||||
media = g(embed, "media", {})
|
||||
mtype = g(media, "$type") or g(media, "py_type") or ""
|
||||
if "video" in mtype.lower():
|
||||
urls.extend(extract_video_urls(media))
|
||||
# Also check for external in media
|
||||
if "external" in mtype.lower():
|
||||
ext = g(media, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
# External links (YouTube, etc.)
|
||||
if "external" in etype.lower():
|
||||
ext = g(embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def find_urls(post):
|
||||
"""
|
||||
Find all URLs in post content.
|
||||
|
||||
Args:
|
||||
post: Bluesky post object
|
||||
|
||||
Returns:
|
||||
list: List of URLs found
|
||||
"""
|
||||
urls = []
|
||||
actual_post = g(post, "post", post)
|
||||
record = g(actual_post, "record", {})
|
||||
|
||||
# Check facets for link annotations
|
||||
facets = g(record, "facets", []) or []
|
||||
for facet in facets:
|
||||
features = g(facet, "features", []) or []
|
||||
for feature in features:
|
||||
ftype = g(feature, "$type") or g(feature, "py_type")
|
||||
if ftype and "link" in ftype.lower():
|
||||
uri = g(feature, "uri", "")
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
# Check embed for external links
|
||||
embed = g(actual_post, "embed", None)
|
||||
if embed:
|
||||
etype = g(embed, "$type") or g(embed, "py_type")
|
||||
if etype and "external" in etype:
|
||||
ext = g(embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
# Also search plain text for URLs using regex (fallback)
|
||||
text = g(record, "text", "")
|
||||
if text:
|
||||
text_urls = url_re.findall(text)
|
||||
for u in text_urls:
|
||||
if u not in urls:
|
||||
urls.append(u)
|
||||
|
||||
# Include URLs from quoted post, if present.
|
||||
quote_info = extract_quoted_post_info(post)
|
||||
if quote_info and quote_info.get("kind") == "post":
|
||||
for uri in quote_info.get("urls", []):
|
||||
if uri and uri not in urls:
|
||||
urls.append(uri)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def find_item(item, items_list):
|
||||
"""
|
||||
Find item index in list by URI.
|
||||
|
||||
Args:
|
||||
item: Item to find
|
||||
items_list: List to search
|
||||
|
||||
Returns:
|
||||
int or None: Index if found, None otherwise
|
||||
"""
|
||||
item_uri = g(item, "uri") or g(g(item, "post"), "uri")
|
||||
if not item_uri:
|
||||
return None
|
||||
|
||||
for i, existing in enumerate(items_list):
|
||||
existing_uri = g(existing, "uri") or g(g(existing, "post"), "uri")
|
||||
if existing_uri == item_uri:
|
||||
return i
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_quoted_record_from_embed(embed):
|
||||
"""Resolve quoted record payload from a Bluesky embed structure."""
|
||||
if not embed:
|
||||
return None
|
||||
|
||||
etype = (g(embed, "$type") or g(embed, "py_type") or "").lower()
|
||||
|
||||
candidate = None
|
||||
if "recordwithmedia" in etype:
|
||||
record_view = g(embed, "record")
|
||||
candidate = g(record_view, "record") or record_view
|
||||
elif "record" in etype:
|
||||
candidate = g(embed, "record") or embed
|
||||
else:
|
||||
record_view = g(embed, "record")
|
||||
if record_view is not None:
|
||||
candidate = g(record_view, "record") or record_view
|
||||
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
# Unwrap one extra layer if still wrapped in a record-view container.
|
||||
nested = g(candidate, "record")
|
||||
nested_type = (g(nested, "$type") or g(nested, "py_type") or "").lower() if nested else ""
|
||||
if nested and ("view" in nested_type or "record" in nested_type):
|
||||
return nested
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
def extract_reply_to_handle(post):
|
||||
"""
|
||||
Best-effort extraction of the replied-to handle for a Bluesky post.
|
||||
|
||||
Returns:
|
||||
str | None: Handle (without @) when available.
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
|
||||
# Fast path: pre-hydrated by buffers/session.
|
||||
cached = g(post, "_reply_to_handle", None) or g(actual_post, "_reply_to_handle", None)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Feed views frequently include hydrated reply context.
|
||||
reply_view = g(post, "reply", None) or g(actual_post, "reply", None)
|
||||
if reply_view:
|
||||
parent = g(reply_view, "parent", None) or g(reply_view, "post", None) or reply_view
|
||||
parent_post = g(parent, "post", None) or parent
|
||||
parent_author = g(parent_post, "author", None) or g(parent, "author", None)
|
||||
handle = g(parent_author, "handle", None)
|
||||
if handle:
|
||||
return handle
|
||||
|
||||
# Some payloads include parent author directly under record.reply.parent.
|
||||
record = g(actual_post, "record", {}) or {}
|
||||
record_reply = g(record, "reply", None)
|
||||
if record_reply:
|
||||
parent = g(record_reply, "parent", None) or record_reply
|
||||
parent_post = g(parent, "post", None) or parent
|
||||
parent_author = g(parent_post, "author", None) or g(parent, "author", None)
|
||||
handle = g(parent_author, "handle", None)
|
||||
if handle:
|
||||
return handle
|
||||
|
||||
# When only record.reply is available, we generally only have strong refs.
|
||||
# No handle can be resolved here without extra API calls.
|
||||
return None
|
||||
|
||||
|
||||
def extract_quoted_post_info(post):
|
||||
"""
|
||||
Extract quoted content metadata from a Bluesky post.
|
||||
|
||||
Returns:
|
||||
dict | None: one of:
|
||||
- {"kind": "not_found"}
|
||||
- {"kind": "blocked"}
|
||||
- {"kind": "feed", "feed_name": "..."}
|
||||
- {"kind": "post", "handle": "...", "text": "...", "urls": ["..."]}
|
||||
"""
|
||||
actual_post = g(post, "post", post)
|
||||
record = g(actual_post, "record", {}) or {}
|
||||
embed = g(actual_post, "embed", None) or g(record, "embed", None)
|
||||
quote_rec = _resolve_quoted_record_from_embed(embed)
|
||||
if not quote_rec:
|
||||
return None
|
||||
|
||||
qtype = (g(quote_rec, "$type") or g(quote_rec, "py_type") or "").lower()
|
||||
if "viewnotfound" in qtype:
|
||||
return {"kind": "not_found"}
|
||||
if "viewblocked" in qtype:
|
||||
return {"kind": "blocked"}
|
||||
if "generatorview" in qtype:
|
||||
return {"kind": "feed", "feed_name": g(quote_rec, "displayName", "Feed")}
|
||||
|
||||
q_author = g(quote_rec, "author", {}) or {}
|
||||
q_handle = g(q_author, "handle", "unknown") or "unknown"
|
||||
|
||||
q_value = g(quote_rec, "value") or g(quote_rec, "record") or {}
|
||||
q_text = g(q_value, "text", "") or g(quote_rec, "text", "")
|
||||
if not q_text:
|
||||
nested_value = g(q_value, "value") or {}
|
||||
q_text = g(nested_value, "text", "")
|
||||
|
||||
q_urls = []
|
||||
|
||||
q_facets = g(q_value, "facets", []) or []
|
||||
for facet in q_facets:
|
||||
features = g(facet, "features", []) or []
|
||||
for feature in features:
|
||||
ftype = (g(feature, "$type") or g(feature, "py_type") or "").lower()
|
||||
if "link" in ftype:
|
||||
uri = g(feature, "uri", "")
|
||||
if uri and uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
|
||||
q_embed = g(quote_rec, "embed", None) or g(q_value, "embed", None)
|
||||
if q_embed:
|
||||
q_etype = (g(q_embed, "$type") or g(q_embed, "py_type") or "").lower()
|
||||
if "external" in q_etype:
|
||||
ext = g(q_embed, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
if "recordwithmedia" in q_etype:
|
||||
media = g(q_embed, "media", {})
|
||||
mtype = (g(media, "$type") or g(media, "py_type") or "").lower()
|
||||
if "external" in mtype:
|
||||
ext = g(media, "external", {})
|
||||
uri = g(ext, "uri", "")
|
||||
if uri and uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
|
||||
for uri in url_re.findall(q_text or ""):
|
||||
if uri not in q_urls:
|
||||
q_urls.append(uri)
|
||||
|
||||
return {
|
||||
"kind": "post",
|
||||
"handle": q_handle,
|
||||
"text": q_text or "",
|
||||
"urls": q_urls,
|
||||
}
|
||||
@@ -17,6 +17,11 @@ def compose_post(post, db, settings, relative_times, show_screen_names, safe=Tru
|
||||
text = _("Boosted from @{}: {}").format(post.reblog.account.acct, templates.process_text(post.reblog, safe=safe))
|
||||
else:
|
||||
text = templates.process_text(post, safe=safe)
|
||||
# Handle quoted posts
|
||||
if hasattr(post, 'quote') and post.quote != None and hasattr(post.quote, 'quoted_status') and post.quote.quoted_status != None:
|
||||
quoted_user = post.quote.quoted_status.account.acct
|
||||
quoted_text = templates.process_text(post.quote.quoted_status, safe=safe)
|
||||
text = text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_text)
|
||||
filtered = utils.evaluate_filters(post=post, current_context="home")
|
||||
if filtered != None:
|
||||
text = _("hidden by filter {}").format(filtered)
|
||||
@@ -79,4 +84,10 @@ def compose_notification(notification, db, settings, relative_times, show_screen
|
||||
filtered = utils.evaluate_filters(post=notification, current_context="notifications")
|
||||
if filtered != None:
|
||||
text = _("hidden by filter {}").format(filtered)
|
||||
return [user, text, ts]
|
||||
return [user, text, ts]
|
||||
|
||||
def compose_announcement(announcement, db, settings, relative_times, show_screen_names, safe=False):
|
||||
# Use the default template or a configured one if available
|
||||
template = settings.get("templates", {}).get("announcement", templates.announcement_default_template)
|
||||
text = templates.render_announcement(announcement, template, settings, relative_times, db["utc_offset"])
|
||||
return [text]
|
||||
@@ -217,37 +217,169 @@ class Session(base.baseSession):
|
||||
self.sound.play(_sound)
|
||||
return val
|
||||
|
||||
def send_post(self, reply_to=None, visibility=None, language=None, posts=[]):
|
||||
def _send_quote_post(self, text, quote_id, visibility, sensitive, spoiler_text, language, scheduled_at, in_reply_to_id=None, media_ids=[], poll=None):
|
||||
"""Internal helper to send a quote post using direct API call."""
|
||||
params = {
|
||||
'status': text,
|
||||
'visibility': visibility,
|
||||
'quoted_status_id': quote_id,
|
||||
}
|
||||
if in_reply_to_id:
|
||||
params['in_reply_to_id'] = in_reply_to_id
|
||||
if sensitive:
|
||||
params['sensitive'] = sensitive
|
||||
if spoiler_text:
|
||||
params['spoiler_text'] = spoiler_text
|
||||
if language:
|
||||
params['language'] = language
|
||||
if scheduled_at:
|
||||
if hasattr(scheduled_at, 'isoformat'):
|
||||
params['scheduled_at'] = scheduled_at.isoformat()
|
||||
else:
|
||||
params['scheduled_at'] = scheduled_at
|
||||
if media_ids:
|
||||
params['media_ids'] = media_ids
|
||||
if poll:
|
||||
params['poll'] = poll
|
||||
|
||||
# Use the internal API request method directly
|
||||
return self.api._Mastodon__api_request('POST', '/api/v1/statuses', params)
|
||||
|
||||
def send_post(self, reply_to=None, quote_id=None, visibility=None, language=None, posts=[]):
|
||||
""" Convenience function to send a thread. """
|
||||
in_reply_to_id = reply_to
|
||||
for obj in posts:
|
||||
text = obj.get("text")
|
||||
if len(obj["attachments"]) == 0:
|
||||
scheduled_at = obj.get("scheduled_at")
|
||||
|
||||
# Prepare media and polls first as they are needed for both standard and quote posts
|
||||
media_ids = []
|
||||
poll = None
|
||||
if len(obj["attachments"]) > 0:
|
||||
try:
|
||||
item = self.api_call(call_name="status_post", status=text, _sound="tweet_send.ogg", in_reply_to_id=in_reply_to_id, visibility=visibility, sensitive=obj["sensitive"], spoiler_text=obj["spoiler_text"], language=language)
|
||||
# If it fails, let's basically send an event with all passed info so we will catch it later.
|
||||
except Exception as e:
|
||||
pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, lang=language)
|
||||
return
|
||||
if item != None:
|
||||
in_reply_to_id = item["id"]
|
||||
else:
|
||||
media_ids = []
|
||||
try:
|
||||
poll = None
|
||||
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"])
|
||||
else:
|
||||
for i in obj["attachments"]:
|
||||
media = self.api_call("media_post", media_file=i["file"], description=i["description"], synchronous=True)
|
||||
media_ids.append(media.id)
|
||||
item = self.api_call(call_name="status_post", status=text, _sound="tweet_send.ogg", in_reply_to_id=in_reply_to_id, media_ids=media_ids, visibility=visibility, poll=poll, sensitive=obj["sensitive"], spoiler_text=obj["spoiler_text"], language=language)
|
||||
if item != None:
|
||||
in_reply_to_id = item["id"]
|
||||
except Exception as e:
|
||||
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, language=language)
|
||||
return
|
||||
|
||||
try:
|
||||
if quote_id:
|
||||
item = self._send_quote_post(text, quote_id, visibility, obj["sensitive"], obj["spoiler_text"], language, scheduled_at, in_reply_to_id, media_ids, poll)
|
||||
self.sound.play("tweet_send.ogg")
|
||||
else:
|
||||
item = self.api_call(call_name="status_post", status=text, _sound="tweet_send.ogg", in_reply_to_id=in_reply_to_id, media_ids=media_ids, visibility=visibility, poll=poll, sensitive=obj["sensitive"], spoiler_text=obj["spoiler_text"], language=language, scheduled_at=scheduled_at)
|
||||
|
||||
if item != None:
|
||||
in_reply_to_id = item["id"]
|
||||
except Exception as e:
|
||||
pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, language=language)
|
||||
return
|
||||
|
||||
def edit_post(self, post_id, posts=[]):
|
||||
""" Convenience function to edit a post. Only the first item in posts list is used as threads cannot be edited.
|
||||
|
||||
Note: According to Mastodon API, not all fields can be edited. Visibility, language, and reply context cannot be changed.
|
||||
|
||||
Args:
|
||||
post_id: ID of the status to edit
|
||||
posts: List with post data. Only first item is used.
|
||||
|
||||
Returns:
|
||||
Updated status object or None on failure
|
||||
"""
|
||||
if len(posts) == 0:
|
||||
log.warning("edit_post called with empty posts list")
|
||||
return None
|
||||
|
||||
obj = posts[0]
|
||||
text = obj.get("text")
|
||||
|
||||
if not text:
|
||||
log.warning("edit_post called without text content")
|
||||
return None
|
||||
|
||||
media_ids = []
|
||||
media_attributes = []
|
||||
|
||||
try:
|
||||
poll = None
|
||||
# Handle poll attachments
|
||||
if len(obj["attachments"]) == 1 and obj["attachments"][0]["type"] == "poll":
|
||||
poll = self.api.make_poll(
|
||||
options=obj["attachments"][0]["options"],
|
||||
expires_in=obj["attachments"][0]["expires_in"],
|
||||
multiple=obj["attachments"][0]["multiple"],
|
||||
hide_totals=obj["attachments"][0]["hide_totals"]
|
||||
)
|
||||
log.debug("Editing post with poll (this will reset votes)")
|
||||
# Handle media attachments
|
||||
elif len(obj["attachments"]) > 0:
|
||||
for i in obj["attachments"]:
|
||||
# If attachment has an 'id', it's an existing media that we keep
|
||||
if "id" in i:
|
||||
media_ids.append(i["id"])
|
||||
# If existing media has metadata to update, use generate_media_edit_attributes
|
||||
if "description" in i or "focus" in i:
|
||||
media_attr = self.api.generate_media_edit_attributes(
|
||||
id=i["id"],
|
||||
description=i.get("description"),
|
||||
focus=i.get("focus")
|
||||
)
|
||||
media_attributes.append(media_attr)
|
||||
# Otherwise it's a new file to upload
|
||||
elif "file" in i:
|
||||
description = i.get("description", "")
|
||||
focus = i.get("focus", None)
|
||||
media = self.api_call(
|
||||
"media_post",
|
||||
media_file=i["file"],
|
||||
description=description,
|
||||
focus=focus,
|
||||
synchronous=True
|
||||
)
|
||||
media_ids.append(media.id)
|
||||
log.debug("Uploaded new media with id: {}".format(media.id))
|
||||
|
||||
# Prepare parameters for status_update
|
||||
update_params = {
|
||||
"id": post_id,
|
||||
"status": text,
|
||||
"_sound": "tweet_send.ogg",
|
||||
"sensitive": obj.get("sensitive", False),
|
||||
"spoiler_text": obj.get("spoiler_text", None),
|
||||
}
|
||||
|
||||
# Add optional parameters only if provided
|
||||
if media_ids:
|
||||
update_params["media_ids"] = media_ids
|
||||
if media_attributes:
|
||||
update_params["media_attributes"] = media_attributes
|
||||
if poll:
|
||||
update_params["poll"] = poll
|
||||
|
||||
# Call status_update API
|
||||
log.debug("Editing post {} with params: {}".format(post_id, {k: v for k, v in update_params.items() if k not in ["_sound"]}))
|
||||
item = self.api_call(call_name="status_update", **update_params)
|
||||
|
||||
if item:
|
||||
log.info("Successfully edited post {}".format(post_id))
|
||||
return item
|
||||
|
||||
except MastodonAPIError as e:
|
||||
log.exception("Mastodon API error updating post {}: {}".format(post_id, str(e)))
|
||||
output.speak(_("Error editing post: {}").format(str(e)))
|
||||
pub.sendMessage("mastodon.error_edit", name=self.get_name(), post_id=post_id, error=str(e))
|
||||
return None
|
||||
except Exception as e:
|
||||
log.exception("Unexpected error updating post {}: {}".format(post_id, str(e)))
|
||||
output.speak(_("Error editing post: {}").format(str(e)))
|
||||
return None
|
||||
|
||||
def get_name(self):
|
||||
instance = self.settings["mastodon"]["instance"]
|
||||
instance = instance.replace("https://", "")
|
||||
@@ -326,4 +458,4 @@ class Session(base.baseSession):
|
||||
# Now, add notification to its buffer.
|
||||
num = self.order_buffer("notifications", [notification])
|
||||
if num > 0:
|
||||
pub.sendMessage("mastodon.new_item", session_name=self.get_name(), item=notification, _buffers=["notifications"])
|
||||
pub.sendMessage("mastodon.new_item", session_name=self.get_name(), item=notification, _buffers=["notifications"])
|
||||
|
||||
@@ -13,12 +13,14 @@ post_variables = ["date", "display_name", "screen_name", "source", "lang", "safe
|
||||
person_variables = ["display_name", "screen_name", "description", "followers", "following", "favorites", "posts", "created_at"]
|
||||
conversation_variables = ["users", "last_post"]
|
||||
notification_variables = ["display_name", "screen_name", "text", "date"]
|
||||
announcement_variables = ["text", "published_at", "updated_at", "starts_at", "ends_at", "read"]
|
||||
|
||||
# Default, translatable templates.
|
||||
post_default_template = _("$display_name, $text $image_descriptions $date. $source")
|
||||
dm_sent_default_template = _("Dm to $recipient_display_name, $text $date")
|
||||
person_default_template = _("$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.")
|
||||
notification_default_template = _("$display_name $text, $date")
|
||||
announcement_default_template = _("$text. Published $published_at. $read")
|
||||
|
||||
def process_date(field, relative_times=True, offset_hours=0):
|
||||
original_date = arrow.get(field)
|
||||
@@ -76,6 +78,13 @@ def render_post(post, template, settings, relative_times=False, offset_hours=0):
|
||||
else:
|
||||
text = process_text(post, safe=False)
|
||||
safe_text = process_text(post)
|
||||
# Handle quoted posts
|
||||
if hasattr(post, 'quote') and post.quote != None and hasattr(post.quote, 'quoted_status') and post.quote.quoted_status != None:
|
||||
quoted_user = post.quote.quoted_status.account.acct
|
||||
quoted_text = process_text(post.quote.quoted_status, safe=False)
|
||||
quoted_safe_text = process_text(post.quote.quoted_status, safe=True)
|
||||
text = text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_text)
|
||||
safe_text = safe_text + " " + _("Quoting @{}: {}").format(quoted_user, quoted_safe_text)
|
||||
filtered = utils.evaluate_filters(post=post, current_context="home")
|
||||
if filtered != None:
|
||||
text = _("hidden by filter {}").format(filtered)
|
||||
@@ -178,3 +187,23 @@ def render_notification(notification, template, post_template, settings, relativ
|
||||
result = Template(_(template)).safe_substitute(**available_data)
|
||||
result = result.replace(" . ", "")
|
||||
return result
|
||||
|
||||
def render_announcement(announcement, template, settings, relative_times=False, offset_hours=0):
|
||||
""" Renders any given announcement according to the passed template. """
|
||||
global announcement_variables
|
||||
available_data = dict()
|
||||
# Process dates
|
||||
for date_field in ["published_at", "updated_at", "starts_at", "ends_at"]:
|
||||
if hasattr(announcement, date_field) and getattr(announcement, date_field) is not None:
|
||||
available_data[date_field] = process_date(getattr(announcement, date_field), relative_times, offset_hours)
|
||||
else:
|
||||
available_data[date_field] = ""
|
||||
|
||||
available_data["text"] = utils.html_filter(announcement.content)
|
||||
if announcement.read:
|
||||
available_data["read"] = _("Read")
|
||||
else:
|
||||
available_data["read"] = _("Unread")
|
||||
|
||||
result = Template(_(template)).safe_substitute(**available_data)
|
||||
return result
|
||||
|
||||
@@ -3,23 +3,47 @@ import demoji
|
||||
from html.parser import HTMLParser
|
||||
from datetime import datetime, timezone
|
||||
|
||||
url_re = re.compile('<a\s*href=[\'|"](.*?)[\'"].*?>')
|
||||
url_re = re.compile(r'<a\s*href=[\'|"](.*?)[\'"].*?>')
|
||||
|
||||
class HTMLFilter(HTMLParser):
|
||||
# Classes to ignore when parsing HTML
|
||||
IGNORED_CLASSES = ["quote-inline"]
|
||||
|
||||
text = ""
|
||||
first_paragraph = True
|
||||
skip_depth = 0 # Track nesting depth of ignored elements
|
||||
|
||||
def handle_data(self, data):
|
||||
self.text += data
|
||||
# Only add data if we're not inside an ignored element
|
||||
if self.skip_depth == 0:
|
||||
self.text += data
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "br":
|
||||
self.text = self.text+"\n"
|
||||
elif tag == "p":
|
||||
if self.first_paragraph:
|
||||
self.first_paragraph = False
|
||||
else:
|
||||
self.text = self.text+"\n\n"
|
||||
# Check if this tag has a class that should be ignored
|
||||
attrs_dict = dict(attrs)
|
||||
tag_class = attrs_dict.get("class", "")
|
||||
|
||||
# Check if any ignored class is present in this tag
|
||||
should_skip = any(ignored_class in tag_class for ignored_class in self.IGNORED_CLASSES)
|
||||
|
||||
if should_skip:
|
||||
self.skip_depth += 1
|
||||
elif self.skip_depth == 0: # Only process tags if we're not skipping
|
||||
if tag == "br":
|
||||
self.text = self.text+"\n"
|
||||
elif tag == "p":
|
||||
if self.first_paragraph:
|
||||
self.first_paragraph = False
|
||||
else:
|
||||
self.text = self.text+"\n\n"
|
||||
else:
|
||||
# We're inside a skipped element, increment depth for nested tags
|
||||
self.skip_depth += 1
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
# Decrement skip depth when closing any tag while skipping
|
||||
if self.skip_depth > 0:
|
||||
self.skip_depth -= 1
|
||||
|
||||
def html_filter(data):
|
||||
f = HTMLFilter()
|
||||
@@ -116,6 +140,11 @@ def evaluate_filters(post: dict, current_context: str) -> str | None:
|
||||
- None if no applicable filters are found, meaning the post should be shown normally.
|
||||
"""
|
||||
filters = post.get("filtered", None)
|
||||
|
||||
# Automatically hide muted conversations from home timeline.
|
||||
if current_context == "home" and post.get("muted") == True:
|
||||
return "hide"
|
||||
|
||||
if filters == None:
|
||||
return
|
||||
warn_filter_title = None
|
||||
|
||||
@@ -3,7 +3,7 @@ import sys
|
||||
import application
|
||||
import platform
|
||||
import os
|
||||
from cx_Freeze import setup, Executable, winmsvcr
|
||||
from cx_Freeze import setup, Executable
|
||||
from requests import certs
|
||||
|
||||
def get_architecture_files():
|
||||
@@ -34,7 +34,7 @@ def find_accessible_output2_datafiles():
|
||||
|
||||
base = None
|
||||
if sys.platform == 'win32':
|
||||
base = 'Win32GUI'
|
||||
base = 'GUI'
|
||||
|
||||
build_exe_options = dict(
|
||||
build_exe="dist",
|
||||
@@ -51,8 +51,6 @@ executables = [
|
||||
Executable('main.py', base=base, target_name="twblue")
|
||||
]
|
||||
|
||||
winmsvcr.FILES = ()
|
||||
winmsvcr.FILES_TO_DUPLICATE = ()
|
||||
setup(name=application.name,
|
||||
version=application.version,
|
||||
description=application.description,
|
||||
|
||||
0
src/test/sessions/blueski/__init__.py
Normal file
0
src/test/sessions/blueski/__init__.py
Normal file
212
src/test/sessions/blueski/test_blueski_quotes.py
Normal file
212
src/test/sessions/blueski/test_blueski_quotes.py
Normal file
@@ -0,0 +1,212 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import builtins
|
||||
import unittest
|
||||
|
||||
from sessions.blueski import compose, templates, utils
|
||||
|
||||
|
||||
class TestBlueskyQuotedPosts(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
if not hasattr(builtins, "_"):
|
||||
builtins._ = lambda s: s
|
||||
|
||||
def _build_quoted_post(self):
|
||||
return {
|
||||
"post": {
|
||||
"author": {"handle": "alice.bsky.social", "displayName": "Alice"},
|
||||
"record": {"text": "Main post text"},
|
||||
"embed": {
|
||||
"$type": "app.bsky.embed.recordWithMedia#view",
|
||||
"record": {
|
||||
"$type": "app.bsky.embed.record#view",
|
||||
"record": {
|
||||
"$type": "app.bsky.embed.record#viewRecord",
|
||||
"author": {"handle": "bob.bsky.social"},
|
||||
"value": {"text": "Quoted post text"},
|
||||
},
|
||||
},
|
||||
"media": {
|
||||
"$type": "app.bsky.embed.images#view",
|
||||
"images": [],
|
||||
},
|
||||
},
|
||||
"indexedAt": "2026-02-15T10:00:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
def _with_reply_context(self, item, reply_to_handle="carol.bsky.social"):
|
||||
if isinstance(item, dict):
|
||||
item["reply"] = {
|
||||
"parent": {
|
||||
"author": {
|
||||
"handle": reply_to_handle,
|
||||
}
|
||||
}
|
||||
}
|
||||
return item
|
||||
|
||||
def test_extract_quoted_post_info_with_text(self):
|
||||
item = self._build_quoted_post()
|
||||
info = utils.extract_quoted_post_info(item)
|
||||
self.assertIsNotNone(info)
|
||||
self.assertEqual(info["kind"], "post")
|
||||
self.assertEqual(info["handle"], "bob.bsky.social")
|
||||
self.assertEqual(info["text"], "Quoted post text")
|
||||
|
||||
def test_compose_post_includes_quoted_text(self):
|
||||
item = self._build_quoted_post()
|
||||
result = compose.compose_post(
|
||||
item,
|
||||
db={},
|
||||
settings={"general": {}},
|
||||
relative_times=True,
|
||||
show_screen_names=False,
|
||||
safe=True,
|
||||
)
|
||||
self.assertIn("Quoting @bob.bsky.social: Quoted post text", result[1])
|
||||
|
||||
def test_template_render_post_includes_quoted_text(self):
|
||||
item = self._build_quoted_post()
|
||||
rendered = templates.render_post(
|
||||
item,
|
||||
template="$display_name, $safe_text $date.",
|
||||
settings={"general": {}},
|
||||
relative_times=True,
|
||||
offset_hours=0,
|
||||
)
|
||||
self.assertIn("Quoting @bob.bsky.social: Quoted post text", rendered)
|
||||
|
||||
def test_extract_quoted_post_info_includes_urls(self):
|
||||
item = self._build_quoted_post()
|
||||
quoted = item["post"]["embed"]["record"]["record"]
|
||||
quoted["value"]["facets"] = [
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"$type": "app.bsky.richtext.facet#link",
|
||||
"uri": "https://example.com/full-link",
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
info = utils.extract_quoted_post_info(item)
|
||||
self.assertIn("https://example.com/full-link", info["urls"])
|
||||
|
||||
def test_find_urls_includes_quoted_urls(self):
|
||||
item = self._build_quoted_post()
|
||||
quoted = item["post"]["embed"]["record"]["record"]
|
||||
quoted["value"]["facets"] = [
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"$type": "app.bsky.richtext.facet#link",
|
||||
"uri": "https://example.com/quoted-only",
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
urls = utils.find_urls(item)
|
||||
self.assertIn("https://example.com/quoted-only", urls)
|
||||
|
||||
def test_compose_post_appends_full_quoted_url(self):
|
||||
item = self._build_quoted_post()
|
||||
quoted = item["post"]["embed"]["record"]["record"]
|
||||
quoted["value"]["text"] = "Mira example.com/..."
|
||||
quoted["value"]["facets"] = [
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"$type": "app.bsky.richtext.facet#link",
|
||||
"uri": "https://example.com/full-target",
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
result = compose.compose_post(
|
||||
item,
|
||||
db={},
|
||||
settings={"general": {}},
|
||||
relative_times=True,
|
||||
show_screen_names=False,
|
||||
safe=True,
|
||||
)
|
||||
self.assertIn("[https://example.com/full-target]", result[1])
|
||||
|
||||
def test_extract_reply_to_handle_from_record_parent_author(self):
|
||||
item = {
|
||||
"post": {
|
||||
"record": {
|
||||
"text": "Reply text",
|
||||
"reply": {
|
||||
"parent": {
|
||||
"uri": "at://did:plc:parent/app.bsky.feed.post/abc",
|
||||
"author": {"handle": "parent.user"},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
handle = utils.extract_reply_to_handle(item)
|
||||
self.assertEqual(handle, "parent.user")
|
||||
|
||||
def test_template_render_post_reply_fallback_without_reply_to_variable(self):
|
||||
item = {
|
||||
"post": {
|
||||
"author": {"handle": "alice.bsky.social", "displayName": "Alice"},
|
||||
"record": {
|
||||
"text": "Reply body",
|
||||
"reply": {
|
||||
"parent": {
|
||||
"uri": "at://did:plc:parent/app.bsky.feed.post/abc",
|
||||
"author": {"handle": "parent.user"},
|
||||
}
|
||||
},
|
||||
},
|
||||
"indexedAt": "2026-02-15T10:00:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
rendered = templates.render_post(
|
||||
item,
|
||||
template="$display_name, $safe_text $date.",
|
||||
settings={"general": {}},
|
||||
relative_times=True,
|
||||
offset_hours=0,
|
||||
)
|
||||
self.assertIn("Replying to @parent.user.", rendered)
|
||||
|
||||
def test_extract_reply_to_handle(self):
|
||||
item = self._with_reply_context(self._build_quoted_post())
|
||||
handle = utils.extract_reply_to_handle(item)
|
||||
self.assertEqual(handle, "carol.bsky.social")
|
||||
|
||||
def test_compose_post_includes_reply_context(self):
|
||||
item = self._with_reply_context(self._build_quoted_post())
|
||||
result = compose.compose_post(
|
||||
item,
|
||||
db={},
|
||||
settings={"general": {}},
|
||||
relative_times=True,
|
||||
show_screen_names=False,
|
||||
safe=True,
|
||||
)
|
||||
self.assertIn("Replying to @carol.bsky.social:", result[1])
|
||||
|
||||
def test_template_render_post_exposes_reply_to_variable(self):
|
||||
item = self._with_reply_context(self._build_quoted_post())
|
||||
rendered = templates.render_post(
|
||||
item,
|
||||
template="$reply_to$text",
|
||||
settings={"general": {}},
|
||||
relative_times=True,
|
||||
offset_hours=0,
|
||||
)
|
||||
self.assertIn("Replying to @carol.bsky.social.", rendered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
362
src/test/sessions/blueski/test_blueski_session.py
Normal file
362
src/test/sessions/blueski/test_blueski_session.py
Normal file
@@ -0,0 +1,362 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock, PropertyMock
|
||||
|
||||
# Assuming paths are set up correctly for test environment to find these
|
||||
from sessions.blueski.session import Session as BlueskiSession
|
||||
from sessions.session_exceptions import SessionLoginError, SessionError
|
||||
from approve.notifications import NotificationError # Assuming this is the correct import path
|
||||
from atproto.xrpc_client.models.common import XrpcError
|
||||
from atproto.xrpc_client import models as atp_models # For ATProto models
|
||||
from atproto.xrpc_client.models import ids # For lexicon IDs
|
||||
|
||||
# Mock wx for headless testing
|
||||
class MockWxDialog:
|
||||
def __init__(self, parent, message, caption, value="", style=0):
|
||||
self.message = message
|
||||
self.caption = caption
|
||||
self.value = value
|
||||
self.return_code = mock_wx.ID_CANCEL # Default to cancel, specific tests can change this
|
||||
|
||||
def ShowModal(self):
|
||||
return self.return_code
|
||||
|
||||
def GetValue(self):
|
||||
return self.value
|
||||
|
||||
def Destroy(self):
|
||||
pass
|
||||
|
||||
class MockWxMessageBox(MockWxDialog):
|
||||
pass
|
||||
|
||||
# Need to mock wx before it's imported by other modules if they do it at import time.
|
||||
# Patching directly where used in session.py is generally safer.
|
||||
mock_wx = MagicMock()
|
||||
mock_wx.TextEntryDialog = MockWxDialog
|
||||
mock_wx.PasswordEntryDialog = MockWxDialog
|
||||
mock_wx.MessageBox = MockWxMessageBox
|
||||
mock_wx.ID_OK = 1
|
||||
mock_wx.ID_CANCEL = 2
|
||||
mock_wx.ICON_ERROR = 16
|
||||
mock_wx.ICON_INFORMATION = 64
|
||||
mock_wx.OK = 4
|
||||
mock_wx.YES_NO = 1 # Example, actual value might differ but not critical for test logic
|
||||
mock_wx.YES = 1 # Example
|
||||
mock_wx.ICON_QUESTION = 32 # Example
|
||||
|
||||
# Mock config objects
|
||||
# This structure tries to mimic how config is accessed in session.py
|
||||
# e.g., config.sessions.blueski[user_id].handle
|
||||
class MockConfigNode:
|
||||
def __init__(self, initial_value=None):
|
||||
self._value = initial_value
|
||||
self.get = MagicMock(return_value=self._value)
|
||||
self.set = AsyncMock() # .set() is async
|
||||
|
||||
class MockUserSessionConfig:
|
||||
def __init__(self):
|
||||
self.handle = MockConfigNode("")
|
||||
self.app_password = MockConfigNode("")
|
||||
self.did = MockConfigNode("")
|
||||
# Add other config values if session.py uses them for blueski
|
||||
|
||||
class MockBlueskiConfig:
|
||||
def __init__(self):
|
||||
self._user_configs = {"test_user": MockUserSessionConfig()}
|
||||
def __getitem__(self, key):
|
||||
return self._user_configs.get(key, MagicMock(return_value=MockUserSessionConfig())) # Return a mock if key not found
|
||||
|
||||
class MockSessionsConfig:
|
||||
def __init__(self):
|
||||
self.blueski = MockBlueskiConfig()
|
||||
|
||||
mock_config_global = MagicMock()
|
||||
mock_config_global.sessions = MockSessionsConfig()
|
||||
|
||||
|
||||
class TestBlueskiSession(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
@patch('sessions.blueski.session.wx', mock_wx)
|
||||
@patch('sessions.blueski.session.config', mock_config_global)
|
||||
def setUp(self):
|
||||
self.mock_approval_api = MagicMock()
|
||||
|
||||
# Reset mocks for user_config part of global mock_config_global for each test
|
||||
self.mock_user_config_instance = MockUserSessionConfig()
|
||||
mock_config_global.sessions.blueski.__getitem__.return_value = self.mock_user_config_instance
|
||||
|
||||
self.session = BlueskiSession(approval_api=self.mock_approval_api, user_id="test_user", channel_id="test_channel")
|
||||
|
||||
self.session.db = {}
|
||||
self.session.save_db = AsyncMock()
|
||||
self.session.notify_session_ready = AsyncMock()
|
||||
self.session.send_text_notification = MagicMock()
|
||||
|
||||
# Mock the util property to return a MagicMock for BlueskiUtils
|
||||
self.mock_util_instance = AsyncMock() # Make it an AsyncMock if its methods are async
|
||||
self.mock_util_instance._own_did = None # These are set directly by session.login
|
||||
self.mock_util_instance._own_handle = None
|
||||
# Add any methods from util that are directly called by session methods being tested
|
||||
# e.g., self.mock_util_instance.get_own_did = MagicMock(return_value="did:plc:test")
|
||||
self.session._util = self.mock_util_instance
|
||||
self.session.util # Call property to ensure _util is set if it's lazy loaded
|
||||
|
||||
def test_session_initialization(self):
|
||||
self.assertIsInstance(self.session, BlueskiSession)
|
||||
self.assertEqual(self.session.KIND, "blueski")
|
||||
self.assertIsNone(self.session.client)
|
||||
self.assertEqual(self.session.user_id, "test_user")
|
||||
|
||||
@patch('sessions.blueski.session.AsyncClient')
|
||||
async def test_login_successful(self, MockAsyncClient):
|
||||
mock_client_instance = MockAsyncClient.return_value
|
||||
# Use actual ATProto models for spec if possible for better type checking in mocks
|
||||
mock_profile = MagicMock(spec=atp_models.ComAtprotoServerDefs.Session)
|
||||
mock_profile.access_jwt = "fake_access_jwt"
|
||||
mock_profile.refresh_jwt = "fake_refresh_jwt"
|
||||
mock_profile.did = "did:plc:testdid"
|
||||
mock_profile.handle = "testhandle.bsky.social"
|
||||
mock_client_instance.login = AsyncMock(return_value=mock_profile)
|
||||
|
||||
self.session.config_get = MagicMock(return_value=None) # Simulate no pre-existing config
|
||||
|
||||
result = await self.session.login("testhandle.bsky.social", "test_password")
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertIsNotNone(self.session.client)
|
||||
mock_client_instance.login.assert_called_once_with("testhandle.bsky.social", "test_password")
|
||||
|
||||
self.assertEqual(self.session.db.get("access_jwt"), "fake_access_jwt")
|
||||
self.assertEqual(self.session.db.get("did"), "did:plc:testdid")
|
||||
self.assertEqual(self.session.db.get("handle"), "testhandle.bsky.social")
|
||||
self.session.save_db.assert_called_once()
|
||||
|
||||
self.mock_user_config_instance.handle.set.assert_called_once_with("testhandle.bsky.social")
|
||||
self.mock_user_config_instance.app_password.set.assert_called_once_with("test_password")
|
||||
self.mock_user_config_instance.did.set.assert_called_once_with("did:plc:testdid")
|
||||
|
||||
self.assertEqual(self.session._util._own_did, "did:plc:testdid")
|
||||
self.assertEqual(self.session._util._own_handle, "testhandle.bsky.social")
|
||||
|
||||
self.session.notify_session_ready.assert_called_once()
|
||||
|
||||
@patch('sessions.blueski.session.AsyncClient')
|
||||
async def test_login_failure_xrpc(self, MockAsyncClient):
|
||||
mock_client_instance = MockAsyncClient.return_value
|
||||
mock_client_instance.login = AsyncMock(side_effect=XrpcError(error="AuthenticationFailed", message="Invalid credentials"))
|
||||
self.session.config_get = MagicMock(return_value=None)
|
||||
|
||||
with self.assertRaises(NotificationError) as ctx:
|
||||
await self.session.login("testhandle.bsky.social", "wrong_password")
|
||||
|
||||
self.assertTrue("Invalid handle or app password." in str(ctx.exception) or "Invalid credentials" in str(ctx.exception))
|
||||
self.assertIsNone(self.session.client)
|
||||
self.session.notify_session_ready.assert_not_called()
|
||||
|
||||
@patch('sessions.blueski.session.wx', new=mock_wx)
|
||||
@patch.object(BlueskiSession, 'login', new_callable=AsyncMock)
|
||||
async def test_authorise_successful(self, mock_login_method):
|
||||
mock_login_method.return_value = True
|
||||
|
||||
mock_wx.TextEntryDialog.return_value.GetValue = MagicMock(return_value="test_handle")
|
||||
mock_wx.TextEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK)
|
||||
mock_wx.PasswordEntryDialog.return_value.GetValue = MagicMock(return_value="password_ok")
|
||||
mock_wx.PasswordEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK)
|
||||
|
||||
self.session.config_get = MagicMock(return_value="prefill_handle") # For pre-filling handle dialog
|
||||
|
||||
result = await self.session.authorise()
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_login_method.assert_called_once_with("test_handle", "password_ok")
|
||||
# Further check if wx.MessageBox was called with success
|
||||
# This requires more complex mocking or inspection of calls to mock_wx.MessageBox
|
||||
|
||||
@patch('sessions.blueski.session.wx', new=mock_wx)
|
||||
@patch.object(BlueskiSession, 'login', new_callable=AsyncMock)
|
||||
async def test_authorise_login_fails_with_notification_error(self, mock_login_method):
|
||||
mock_login_method.side_effect = NotificationError("Specific login failure from mock.")
|
||||
|
||||
mock_wx.TextEntryDialog.return_value.GetValue = MagicMock(return_value="test_handle")
|
||||
mock_wx.TextEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK)
|
||||
mock_wx.PasswordEntryDialog.return_value.GetValue = MagicMock(return_value="any_password")
|
||||
mock_wx.PasswordEntryDialog.return_value.ShowModal = MagicMock(return_value=mock_wx.ID_OK)
|
||||
|
||||
self.session.config_get = MagicMock(return_value="")
|
||||
|
||||
result = await self.session.authorise()
|
||||
self.assertFalse(result)
|
||||
mock_login_method.assert_called_once()
|
||||
|
||||
|
||||
# --- Test Sending Posts ---
|
||||
async def test_send_simple_post_successful(self):
|
||||
self.session.is_ready = MagicMock(return_value=True) # Assume session is ready
|
||||
self.session.util.post_status = AsyncMock(return_value="at://mock_post_uri")
|
||||
|
||||
post_uri = await self.session.send_message("Test text post")
|
||||
|
||||
self.assertEqual(post_uri, "at://mock_post_uri")
|
||||
self.session.util.post_status.assert_called_once_with(
|
||||
text="Test text post", media_ids=None, reply_to_uri=None, quote_uri=None,
|
||||
cw_text=None, is_sensitive=False, langs=None, tags=None
|
||||
)
|
||||
|
||||
async def test_send_post_with_quote_and_lang(self):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
self.session.util.post_status = AsyncMock(return_value="at://mock_post_uri_quote")
|
||||
|
||||
post_uri = await self.session.send_message(
|
||||
"Quoting another post",
|
||||
quote_uri="at://did:plc:someuser/app.bsky.feed.post/somepostid",
|
||||
langs=["en", "es"]
|
||||
)
|
||||
self.assertEqual(post_uri, "at://mock_post_uri_quote")
|
||||
self.session.util.post_status.assert_called_once_with(
|
||||
text="Quoting another post", media_ids=None, reply_to_uri=None,
|
||||
quote_uri="at://did:plc:someuser/app.bsky.feed.post/somepostid",
|
||||
cw_text=None, is_sensitive=False, langs=["en", "es"], tags=None
|
||||
)
|
||||
|
||||
@patch('sessions.blueski.session.os.path.basename', return_value="image.png") # Mock os.path.basename
|
||||
async def test_send_post_with_media(self, mock_basename):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
mock_blob_info = {"blob_ref": MagicMock(spec=atp_models.ComAtprotoRepoStrongRef.Blob), "alt_text": "A test image"}
|
||||
self.session.util.upload_media = AsyncMock(return_value=mock_blob_info)
|
||||
self.session.util.post_status = AsyncMock(return_value="at://mock_post_uri_media")
|
||||
|
||||
post_uri = await self.session.send_message(
|
||||
"Post with media", files=["dummy/path/image.png"], media_alt_texts=["A test image"]
|
||||
)
|
||||
self.assertEqual(post_uri, "at://mock_post_uri_media")
|
||||
self.session.util.upload_media.assert_called_once_with("dummy/path/image.png", "image/png", alt_text="A test image")
|
||||
self.session.util.post_status.assert_called_once_with(
|
||||
text="Post with media", media_ids=[mock_blob_info], reply_to_uri=None, quote_uri=None,
|
||||
cw_text=None, is_sensitive=False, langs=None, tags=None
|
||||
)
|
||||
|
||||
async def test_send_post_util_failure(self):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
self.session.util.post_status = AsyncMock(side_effect=NotificationError("Failed to post from util"))
|
||||
with self.assertRaisesRegex(NotificationError, "Failed to post from util"):
|
||||
await self.session.send_message("This will fail")
|
||||
|
||||
# --- Test Fetching Timelines ---
|
||||
def _create_mock_feed_view_post(self, uri_suffix):
|
||||
post_view = MagicMock(spec=atp_models.AppBskyFeedDefs.PostView)
|
||||
post_view.uri = f"at://did:plc:test/app.bsky.feed.post/{uri_suffix}"
|
||||
post_view.cid = f"cid_{uri_suffix}"
|
||||
author_mock = MagicMock(spec=atp_models.AppBskyActorDefs.ProfileViewBasic)
|
||||
author_mock.did = "did:plc:author"
|
||||
author_mock.handle = "author.bsky.social"
|
||||
post_view.author = author_mock
|
||||
record_mock = MagicMock(spec=atp_models.AppBskyFeedPost.Main)
|
||||
record_mock.text = f"Text of post {uri_suffix}"
|
||||
record_mock.createdAt = "2024-01-01T00:00:00Z"
|
||||
post_view.record = record_mock
|
||||
feed_view_post = MagicMock(spec=atp_models.AppBskyFeedDefs.FeedViewPost)
|
||||
feed_view_post.post = post_view
|
||||
feed_view_post.reason = None
|
||||
feed_view_post.reply = None
|
||||
return feed_view_post
|
||||
|
||||
async def test_fetch_home_timeline_successful(self):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
mock_post1 = self._create_mock_feed_view_post("post1")
|
||||
mock_post2 = self._create_mock_feed_view_post("post2")
|
||||
self.session.util.get_timeline = AsyncMock(return_value=([mock_post1, mock_post2], "cursor_for_home"))
|
||||
self.session.order_buffer = AsyncMock(return_value=["uri1", "uri2"])
|
||||
|
||||
processed_uris, next_cursor = await self.session.fetch_home_timeline(limit=5, new_only=True)
|
||||
|
||||
self.session.util.get_timeline.assert_called_once_with(algorithm=None, limit=5, cursor=None)
|
||||
self.session.order_buffer.assert_called_once_with(items=[mock_post1, mock_post2], new_only=True, buffer_name="home_timeline_buffer")
|
||||
self.assertEqual(self.session.home_timeline_cursor, "cursor_for_home")
|
||||
self.assertEqual(processed_uris, ["uri1", "uri2"])
|
||||
|
||||
async def test_fetch_user_timeline_successful(self):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
mock_post3 = self._create_mock_feed_view_post("post3")
|
||||
self.session.util.get_author_feed = AsyncMock(return_value=([mock_post3], "cursor_for_user"))
|
||||
self.session.order_buffer = AsyncMock(return_value=["uri3"])
|
||||
|
||||
processed_uris, next_cursor = await self.session.fetch_user_timeline(
|
||||
user_did="did:plc:targetuser", limit=10, filter_type="posts_no_replies"
|
||||
)
|
||||
self.session.util.get_author_feed.assert_called_once_with(
|
||||
actor_did="did:plc:targetuser", limit=10, cursor=None, filter="posts_no_replies"
|
||||
)
|
||||
self.session.order_buffer.assert_called_once_with(items=[mock_post3], new_only=False, buffer_name='user_timeline_did:plc:targetuser')
|
||||
self.assertEqual(next_cursor, "cursor_for_user")
|
||||
self.assertEqual(processed_uris, ["uri3"])
|
||||
|
||||
async def test_fetch_timeline_failure(self):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
self.session.util.get_timeline = AsyncMock(side_effect=NotificationError("API error for timeline"))
|
||||
with self.assertRaisesRegex(NotificationError, "API error for timeline"):
|
||||
await self.session.fetch_home_timeline()
|
||||
|
||||
# --- Test Fetching Notifications ---
|
||||
def _create_mock_notification(self, reason: str, uri_suffix: str, isRead: bool = False):
|
||||
notif = MagicMock(spec=atp_models.AppBskyNotificationListNotifications.Notification)
|
||||
notif.uri = f"at://did:plc:test/app.bsky.feed.like/{uri_suffix}"
|
||||
notif.cid = f"cid_notif_{uri_suffix}"
|
||||
author_mock = MagicMock(spec=atp_models.AppBskyActorDefs.ProfileView)
|
||||
author_mock.did = f"did:plc:otheruser{uri_suffix}"
|
||||
author_mock.handle = f"other{uri_suffix}.bsky.social"
|
||||
author_mock.displayName = f"Other User {uri_suffix}"
|
||||
author_mock.avatar = "http://example.com/avatar.png"
|
||||
notif.author = author_mock
|
||||
notif.reason = reason
|
||||
notif.reasonSubject = f"at://did:plc:test/app.bsky.feed.post/mypost{uri_suffix}" if reason != "follow" else None
|
||||
if reason in ["mention", "reply", "quote"]:
|
||||
record_mock = MagicMock(spec=atp_models.AppBskyFeedPost.Main)
|
||||
record_mock.text = f"Notification related text for {reason}"
|
||||
record_mock.createdAt = "2024-01-02T00:00:00Z"
|
||||
notif.record = record_mock
|
||||
else:
|
||||
notif.record = MagicMock()
|
||||
notif.isRead = isRead
|
||||
notif.indexedAt = "2024-01-02T00:00:00Z"
|
||||
return notif
|
||||
|
||||
async def test_fetch_notifications_successful_and_handler_dispatch(self):
|
||||
self.session.is_ready = MagicMock(return_value=True)
|
||||
self.session.util = AsyncMock()
|
||||
mock_like_notif = self._create_mock_notification("like", "like1", isRead=False)
|
||||
mock_mention_notif = self._create_mock_notification("mention", "mention1", isRead=False)
|
||||
self.session.util.get_notifications = AsyncMock(return_value=([mock_like_notif, mock_mention_notif], "next_notif_cursor"))
|
||||
|
||||
self.session._handle_like_notification = AsyncMock()
|
||||
self.session._handle_mention_notification = AsyncMock()
|
||||
self.session._handle_repost_notification = AsyncMock()
|
||||
self.session._handle_follow_notification = AsyncMock()
|
||||
self.session._handle_reply_notification = AsyncMock()
|
||||
self.session._handle_quote_notification = AsyncMock()
|
||||
|
||||
returned_cursor = await self.session.fetch_notifications(limit=10)
|
||||
|
||||
self.session.util.get_notifications.assert_called_once_with(limit=10, cursor=None)
|
||||
self.session._handle_like_notification.assert_called_once_with(mock_like_notif)
|
||||
self.session._handle_mention_notification.assert_called_once_with(mock_mention_notif)
|
||||
self.assertEqual(returned_cursor, "next_notif_cursor")
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
# Minimal wx mock for running tests headlessly
|
||||
if 'wx' not in sys.modules: # type: ignore
|
||||
sys.modules['wx'] = MagicMock()
|
||||
mock_wx_module = sys.modules['wx']
|
||||
mock_wx_module.ID_OK = 1
|
||||
mock_wx_module.ID_CANCEL = 2
|
||||
mock_wx_module.ICON_ERROR = 16
|
||||
mock_wx_module.ICON_INFORMATION = 64
|
||||
mock_wx_module.OK = 4
|
||||
mock_wx_module.TextEntryDialog = MockWxDialog
|
||||
mock_wx_module.PasswordEntryDialog = MockWxDialog
|
||||
mock_wx_module.MessageBox = MockWxMessageBox
|
||||
mock_wx_module.CallAfter = MagicMock()
|
||||
mock_wx_module.GetApp = MagicMock()
|
||||
0
src/twblue.pot
Normal file
0
src/twblue.pot
Normal file
167
src/wxUI/buffers/blueski/panels.py
Normal file
167
src/wxUI/buffers/blueski/panels.py
Normal file
@@ -0,0 +1,167 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import wx
|
||||
import languageHandler
|
||||
from multiplatform_widgets import widgets
|
||||
|
||||
class HomePanel(wx.Panel):
|
||||
def __init__(self, parent, name, account="Unknown"):
|
||||
super().__init__(parent, name=name)
|
||||
self.name = name
|
||||
self.account = account
|
||||
self.type = "home_timeline"
|
||||
|
||||
self.sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
# List
|
||||
self.list = widgets.list(self, _("Author"), _("Post"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 120)
|
||||
self.list.set_windows_size(1, 400)
|
||||
self.list.set_windows_size(2, 120)
|
||||
self.list.set_size()
|
||||
|
||||
# Buttons
|
||||
self.post = wx.Button(self, -1, _("Post"))
|
||||
self.repost = wx.Button(self, -1, _("Repost"))
|
||||
self.reply = wx.Button(self, -1, _("Reply"))
|
||||
self.like = wx.Button(self, wx.ID_ANY, _("Like"))
|
||||
# self.bookmark = wx.Button(self, wx.ID_ANY, _("Bookmark")) # Not yet common in Bsky API usage here
|
||||
self.dm = wx.Button(self, -1, _("Chat"))
|
||||
|
||||
btnSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
btnSizer.Add(self.post, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.repost, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.reply, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.like, 0, wx.ALL, 5)
|
||||
# btnSizer.Add(self.bookmark, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.dm, 0, wx.ALL, 5)
|
||||
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
|
||||
self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
|
||||
# Some helper methods expected by controller might be needed?
|
||||
# Controller accesses self.buffer.list directly.
|
||||
# Some older code expected .set_position, .post, .message, .actions attributes or buttons on the panel?
|
||||
# Mastodon panels usually have bottom buttons (Post, Reply, etc).
|
||||
# I should add them if I want to "reuse Mastodon".
|
||||
|
||||
# But for now, simple list is what the previous code had.
|
||||
|
||||
def set_focus_function(self, func):
|
||||
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func)
|
||||
|
||||
def set_position(self, reverse):
|
||||
if reverse:
|
||||
self.list.select_item(0)
|
||||
else:
|
||||
self.list.select_item(self.list.get_count() - 1)
|
||||
|
||||
def set_focus_in_list(self):
|
||||
self.list.list.SetFocus()
|
||||
|
||||
class NotificationPanel(HomePanel):
|
||||
pass
|
||||
|
||||
class UserPanel(wx.Panel):
|
||||
def __init__(self, parent, name, account="Unknown"):
|
||||
super().__init__(parent, name=name)
|
||||
self.name = name
|
||||
self.account = account
|
||||
self.type = "user"
|
||||
|
||||
self.sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
# List: User
|
||||
self.list = widgets.list(self, _("User"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 600)
|
||||
self.list.set_size()
|
||||
|
||||
# Buttons
|
||||
self.post = wx.Button(self, -1, _("Post"))
|
||||
self.actions = wx.Button(self, -1, _("Actions"))
|
||||
self.message = wx.Button(self, -1, _("Message"))
|
||||
|
||||
btnSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
btnSizer.Add(self.post, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.actions, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.message, 0, wx.ALL, 5)
|
||||
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
|
||||
self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
|
||||
def set_focus_function(self, func):
|
||||
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func)
|
||||
|
||||
def set_position(self, reverse):
|
||||
if reverse:
|
||||
self.list.select_item(0)
|
||||
else:
|
||||
self.list.select_item(self.list.get_count() - 1)
|
||||
|
||||
def set_focus_in_list(self):
|
||||
self.list.list.SetFocus()
|
||||
|
||||
class ChatPanel(wx.Panel):
|
||||
"""Panel for conversation list, similar to Mastodon's conversationListPanel."""
|
||||
def __init__(self, parent, name, account="Unknown"):
|
||||
super().__init__(parent, name=name)
|
||||
self.name = name
|
||||
self.account = account
|
||||
self.type = "chat"
|
||||
|
||||
self.sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
|
||||
# List: User, Text, Date (like Mastodon)
|
||||
self.list = widgets.list(self, _("User"), _("Text"), _("Date"), style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 200)
|
||||
self.list.set_windows_size(1, 600)
|
||||
self.list.set_windows_size(2, 200)
|
||||
self.list.set_size()
|
||||
|
||||
# Buttons (like Mastodon: Post, Reply)
|
||||
self.post = wx.Button(self, -1, _("Post"))
|
||||
self.reply = wx.Button(self, -1, _("Reply"))
|
||||
self.new_chat = wx.Button(self, -1, _("New Chat"))
|
||||
btnSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
btnSizer.Add(self.post, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.reply, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.new_chat, 0, wx.ALL, 5)
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
|
||||
self.sizer.Add(self.list.list, 1, wx.EXPAND | wx.ALL, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
|
||||
def set_focus_function(self, func):
|
||||
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, func)
|
||||
|
||||
def set_position(self, reversed=False):
|
||||
if reversed == False:
|
||||
self.list.select_item(self.list.get_count()-1)
|
||||
else:
|
||||
self.list.select_item(0)
|
||||
|
||||
def set_focus_in_list(self):
|
||||
self.list.list.SetFocus()
|
||||
|
||||
class ChatMessagePanel(HomePanel):
|
||||
def __init__(self, parent, name, account="Unknown"):
|
||||
super().__init__(parent, name, account)
|
||||
self.type = "chat_messages"
|
||||
# Adjust buttons for chat
|
||||
self.repost.Hide()
|
||||
self.like.Hide()
|
||||
self.dm.Hide() # Hide Chat button since we're already in a chat
|
||||
self.reply.SetLabel(_("Send Message"))
|
||||
|
||||
# Refresh columns
|
||||
self.list.list.ClearAll()
|
||||
self.list.list.InsertColumn(0, _("Sender"))
|
||||
self.list.list.InsertColumn(1, _("Message"))
|
||||
self.list.list.InsertColumn(2, _("Date"))
|
||||
self.list.set_windows_size(0, 100)
|
||||
self.list.set_windows_size(1, 400)
|
||||
self.list.set_windows_size(2, 100)
|
||||
self.list.set_size()
|
||||
@@ -2,4 +2,5 @@
|
||||
from .base import basePanel
|
||||
from .conversationList import conversationListPanel
|
||||
from .notifications import notificationsPanel
|
||||
from .user import userPanel
|
||||
from .user import userPanel
|
||||
from .announcements import announcementsPanel
|
||||
|
||||
36
src/wxUI/buffers/mastodon/announcements.py
Normal file
36
src/wxUI/buffers/mastodon/announcements.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import wx
|
||||
from multiplatform_widgets import widgets
|
||||
|
||||
class announcementsPanel(wx.Panel):
|
||||
|
||||
def set_focus_function(self, f):
|
||||
self.list.list.Bind(wx.EVT_LIST_ITEM_FOCUSED, f)
|
||||
|
||||
def create_list(self):
|
||||
self.list = widgets.list(self, _("Announcement"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 800)
|
||||
self.list.set_size()
|
||||
|
||||
def __init__(self, parent, name):
|
||||
super(announcementsPanel, self).__init__(parent)
|
||||
self.name = name
|
||||
self.type = "baseBuffer"
|
||||
self.sizer = wx.BoxSizer(wx.VERTICAL)
|
||||
self.create_list()
|
||||
self.dismiss = wx.Button(self, -1, _("Dismiss"))
|
||||
btnSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||
btnSizer.Add(self.dismiss, 0, wx.ALL, 5)
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
self.SetClientSize(self.sizer.CalcMin())
|
||||
|
||||
def set_position(self, reversed=False):
|
||||
if reversed == False:
|
||||
self.list.select_item(self.list.get_count()-1)
|
||||
else:
|
||||
self.list.select_item(0)
|
||||
|
||||
def set_focus_in_list(self):
|
||||
self.list.list.SetFocus()
|
||||
@@ -9,10 +9,10 @@ class basePanel(wx.Panel):
|
||||
|
||||
def create_list(self):
|
||||
self.list = widgets.list(self, _(u"User"), _(u"Text"), _(u"Date"), _(u"Client"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 60)
|
||||
self.list.set_windows_size(1, 320)
|
||||
self.list.set_windows_size(2, 110)
|
||||
self.list.set_windows_size(3, 84)
|
||||
self.list.set_windows_size(0, 200)
|
||||
self.list.set_windows_size(1, 600)
|
||||
self.list.set_windows_size(2, 200)
|
||||
self.list.set_windows_size(3, 200)
|
||||
self.list.set_size()
|
||||
|
||||
def __init__(self, parent, name):
|
||||
@@ -35,7 +35,7 @@ class basePanel(wx.Panel):
|
||||
btnSizer.Add(self.bookmark, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.dm, 0, wx.ALL, 5)
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
self.sizer.Add(self.list.list, 0, wx.ALL|wx.EXPAND, 5)
|
||||
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
self.SetClientSize(self.sizer.CalcMin())
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ class conversationListPanel(wx.Panel):
|
||||
|
||||
def create_list(self):
|
||||
self.list = widgets.list(self, _(u"User"), _(u"Text"), _(u"Date"), _(u"Client"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 60)
|
||||
self.list.set_windows_size(1, 320)
|
||||
self.list.set_windows_size(2, 110)
|
||||
self.list.set_windows_size(3, 84)
|
||||
self.list.set_windows_size(0, 200)
|
||||
self.list.set_windows_size(1, 600)
|
||||
self.list.set_windows_size(2, 200)
|
||||
self.list.set_windows_size(3, 200)
|
||||
self.list.set_size()
|
||||
|
||||
def __init__(self, parent, name):
|
||||
@@ -27,7 +27,7 @@ class conversationListPanel(wx.Panel):
|
||||
btnSizer.Add(self.post, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.reply, 0, wx.ALL, 5)
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
self.sizer.Add(self.list.list, 0, wx.ALL|wx.EXPAND, 5)
|
||||
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
self.SetClientSize(self.sizer.CalcMin())
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ class notificationsPanel(wx.Panel):
|
||||
|
||||
def create_list(self):
|
||||
self.list = widgets.list(self, _("Text"), _("Date"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 320)
|
||||
self.list.set_windows_size(2, 110)
|
||||
self.list.set_windows_size(0, 600)
|
||||
self.list.set_windows_size(1, 200)
|
||||
self.list.set_size()
|
||||
|
||||
def __init__(self, parent, name):
|
||||
@@ -25,7 +25,7 @@ class notificationsPanel(wx.Panel):
|
||||
btnSizer.Add(self.post, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.dismiss, 0, wx.ALL, 5)
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
self.sizer.Add(self.list.list, 0, wx.ALL|wx.EXPAND, 5)
|
||||
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
self.SetClientSize(self.sizer.CalcMin())
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ class userPanel(wx.Panel):
|
||||
|
||||
def create_list(self):
|
||||
self.list = widgets.list(self, _("User"), style=wx.LC_REPORT|wx.LC_SINGLE_SEL|wx.LC_VRULES)
|
||||
self.list.set_windows_size(0, 320)
|
||||
self.list.set_windows_size(0, 600)
|
||||
self.list.set_size()
|
||||
|
||||
def __init__(self, parent, name):
|
||||
@@ -23,7 +23,7 @@ class userPanel(wx.Panel):
|
||||
btnSizer.Add(self.actions, 0, wx.ALL, 5)
|
||||
btnSizer.Add(self.message, 0, wx.ALL, 5)
|
||||
self.sizer.Add(btnSizer, 0, wx.ALL, 5)
|
||||
self.sizer.Add(self.list.list, 0, wx.ALL|wx.EXPAND, 5)
|
||||
self.sizer.Add(self.list.list, 1, wx.ALL|wx.EXPAND, 5)
|
||||
self.SetSizer(self.sizer)
|
||||
self.SetClientSize(self.sizer.CalcMin())
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user