Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
e1230dc463 Bump setuptools from 69.0.0 to 80.9.0
Bumps [setuptools](https://github.com/pypa/setuptools) from 69.0.0 to 80.9.0.
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/setuptools/compare/v69.0.0...v80.9.0)

---
updated-dependencies:
- dependency-name: setuptools
  dependency-version: 80.9.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-02 23:42:17 +00:00
21 changed files with 65 additions and 718 deletions

342
CLAUDE.md
View File

@@ -1,342 +0,0 @@
# 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

View File

@@ -1,58 +1,58 @@
accessible_output2 @ git+https://github.com/accessibleapps/accessible_output2@57bda997d98e87dd78aa049e7021cf777871619b
arrow==1.4.0
attrs==25.4.0
arrow==1.3.0
attrs==25.3.0
backports.functools-lru-cache==2.0.0
blurhash==1.1.5
certifi==2025.10.5
blurhash==1.1.4
certifi==2025.4.26
chardet==5.2.0
charset-normalizer==3.4.4
charset-normalizer==3.4.2
colorama==0.4.6
configobj==5.0.9
coverage==7.11.0
cx-Freeze==8.4.1
coverage==7.8.2
cx-Freeze==8.3.0
cx-Logging==3.2.1
decorator==5.2.1
demoji==1.1.0
deepl==1.23.0
deepl==1.22.0
future==1.0.0
idna==3.11
idna==3.10
importlib-metadata==8.7.0
iniconfig==2.3.0
iniconfig==2.1.0
libloader @ git+https://github.com/accessibleapps/libloader@bc94811c095b2e57a036acd88660be9a33260267
libretranslatepy==2.1.4
lief==0.15.1
Markdown==3.10
Mastodon.py==2.1.4
numpy==2.3.4
oauthlib==3.3.1
Markdown==3.8
Mastodon.py==2.0.1
numpy==2.2.3
oauthlib==3.2.2
packaging==25.0
pillow==12.0.0
pillow==11.2.1
platform_utils @ git+https://github.com/accessibleapps/platform_utils@e0d79f7b399c4ea677a633d2dde9202350d62c38
pluggy==1.6.0
psutil==7.1.3
pyenchant==3.3.0
psutil==7.0.0
pyenchant==3.2.2
pypiwin32==223
Pypubsub==4.0.3
PySocks==1.7.1
pytest==8.4.2
pytest==8.3.5
python-dateutil==2.9.0.post0
python-magic-bin==0.4.14
python-vlc==3.0.21203
pywin32==311
requests==2.32.5
pywin32==310
requests==2.32.3
requests-oauthlib==2.0.0
requests-toolbelt==1.0.0
rfc3986==2.0.0
setuptools==69.0.0
setuptools==80.9.0
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.20251008
urllib3==2.5.0
types-python-dateutil==2.9.0.20250516
urllib3==2.4.0
win-inet-pton==1.1.0
winpaths==0.2
wxPython==4.2.4
wxPython==4.2.3
youtube-dl==2021.12.17
zipp==3.23.0
zipp==3.22.0

View File

@@ -280,12 +280,6 @@ 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)
@@ -507,49 +501,6 @@ 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

View File

@@ -161,13 +161,6 @@ 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)

View File

@@ -449,15 +449,6 @@ 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)

View File

@@ -2,7 +2,6 @@
import os
import re
import wx
import logging
import widgetUtils
import config
import output
@@ -15,8 +14,6 @@ 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
@@ -265,108 +262,6 @@ class post(messages.basicMessage):
visibility_setting = visibility_settings.index(setting)
self.message.visibility.SetSelection(setting)
class editPost(post):
def __init__(self, session, item, title, caption, *args, **kwargs):
""" Initialize edit dialog with existing post data.
Note: Per Mastodon API, visibility and language cannot be changed when editing.
These fields will be displayed but disabled in the UI.
"""
# Extract text from post
if item.reblog != None:
item = item.reblog
text = item.content
# Remove HTML tags from content
import re
text = re.sub('<[^<]+?>', '', text)
# Initialize parent class
super(editPost, self).__init__(session, title, caption, text=text, *args, **kwargs)
# Store the post ID for editing
self.post_id = item.id
# Set visibility (read-only, cannot be changed)
visibility_settings = dict(public=0, unlisted=1, private=2, direct=3)
self.message.visibility.SetSelection(visibility_settings.get(item.visibility, 0))
self.message.visibility.Enable(False) # Disable as it cannot be edited
# Set language (read-only, cannot be changed)
if item.language:
self.set_language(item.language)
self.message.language.Enable(False) # Disable as it cannot be edited
# Set sensitive content and spoiler
if item.sensitive:
self.message.sensitive.SetValue(True)
if item.spoiler_text:
self.message.spoiler.ChangeValue(item.spoiler_text)
self.message.on_sensitivity_changed()
# Load existing poll (if any)
# Note: You cannot have both media and a poll, so check poll first
if hasattr(item, 'poll') and item.poll is not None:
log.debug("Loading existing poll for post {}".format(self.post_id))
poll = item.poll
# Extract poll options (just the text, not the votes)
poll_options = [option.title for option in poll.options]
# Calculate expires_in based on current time and expires_at
# For editing, we need to provide a new expiration time
# Since we can't get the original expires_in, use a default or let user configure
# For now, use 1 day (86400 seconds) as default
expires_in = 86400
if hasattr(poll, 'expires_at') and poll.expires_at and not poll.expired:
# Calculate remaining time if poll hasn't expired
from dateutil import parser as date_parser
import datetime
try:
expires_at = poll.expires_at
if isinstance(expires_at, str):
expires_at = date_parser.parse(expires_at)
now = datetime.datetime.now(datetime.timezone.utc)
remaining = (expires_at - now).total_seconds()
if remaining > 0:
expires_in = int(remaining)
except Exception as e:
log.warning("Could not calculate poll expiration: {}".format(e))
poll_info = {
"type": "poll",
"file": "",
"description": _("Poll with {} options").format(len(poll_options)),
"options": poll_options,
"expires_in": expires_in,
"multiple": poll.multiple if hasattr(poll, 'multiple') else False,
"hide_totals": poll.voters_count == 0 if hasattr(poll, 'voters_count') else False
}
self.attachments.append(poll_info)
self.message.add_item(item=[poll_info["file"], poll_info["type"], poll_info["description"]])
log.debug("Loaded poll with {} options. WARNING: Editing will reset all votes!".format(len(poll_options)))
# Load existing media attachments (only if no poll)
elif hasattr(item, 'media_attachments'):
log.debug("Loading existing media attachments for post {}".format(self.post_id))
log.debug("Item has media_attachments attribute, count: {}".format(len(item.media_attachments)))
if len(item.media_attachments) > 0:
for media in item.media_attachments:
log.debug("Processing media: id={}, type={}, url={}".format(media.id, media.type, media.url))
media_info = {
"id": media.id, # Keep the existing media ID
"type": media.type,
"file": media.url, # URL of existing media
"description": media.description or ""
}
# Include focus point if available
if hasattr(media, 'meta') and media.meta and 'focus' in media.meta:
focus = media.meta['focus']
media_info["focus"] = (focus.get('x'), focus.get('y'))
log.debug("Added focus point: {}".format(media_info["focus"]))
self.attachments.append(media_info)
# Display in the attachment list
display_name = media.url.split('/')[-1]
log.debug("Adding item to UI: name={}, type={}, desc={}".format(display_name, media.type, media.description or ""))
self.message.add_item(item=[display_name, media.type, media.description or ""])
log.debug("Total attachments loaded: {}".format(len(self.attachments)))
else:
log.debug("media_attachments list is empty")
else:
log.debug("Item has no poll or media attachments")
# Update text processor to reflect the loaded content
self.text_processor()
class viewPost(post):
def __init__(self, session, post, offset_hours=0, date="", item_url=""):
self.session = session

View File

@@ -23,7 +23,6 @@ url = string(default="control+win+b")
go_home = string(default="control+win+home")
go_end = string(default="control+win+end")
delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c")

View File

@@ -33,7 +33,6 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="control+win+shift+p")
delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c")

View File

@@ -33,7 +33,6 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p")
delete = string(default="alt+win+delete")
edit_post = string(default="")
clear_buffer = string(default="alt+win+shift+delete")
repeat_item = string(default="alt+win+space")
copy_to_clipboard = string(default="alt+win+shift+c")

View File

@@ -33,7 +33,6 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p")
delete = string(default="alt+win+delete")
edit_post = string(default="")
clear_buffer = string(default="alt+win+shift+delete")
repeat_item = string(default="control+alt+win+space")
copy_to_clipboard = string(default="alt+win+shift+c")

View File

@@ -34,7 +34,6 @@ go_page_up = string(default="control+win+pageup")
go_page_down = string(default="control+win+pagedown")
update_profile = string(default="alt+win+p")
delete = string(default="control+win+delete")
edit_post = string(default="")
clear_buffer = string(default="control+win+shift+delete")
repeat_item = string(default="control+win+space")
copy_to_clipboard = string(default="control+win+shift+c")

Binary file not shown.

View File

@@ -1,23 +1,22 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) 2019 ORGANIZATION
# FIRST AUTHOR <EMAIL@ADDRESS>, 2019.
# zvonimir stanecic <zvonimirek222@yandex.com>, 2023, 2025.
# zvonimir stanecic <zvonimirek222@yandex.com>, 2023.
msgid ""
msgstr ""
"Project-Id-Version: Tw Blue 0.80\n"
"Report-Msgid-Bugs-To: manuel@manuelcortez.net\n"
"POT-Creation-Date: 2025-04-13 01:18+0000\n"
"PO-Revision-Date: 2025-08-10 16:08+0000\n"
"PO-Revision-Date: 2023-04-21 07:45+0000\n"
"Last-Translator: zvonimir stanecic <zvonimirek222@yandex.com>\n"
"Language-Team: Polish <https://weblate.mcvsoftware.com/projects/twblue/"
"twblue/pl/>\n"
"Language: pl\n"
"Language-Team: Polish "
"<https://weblate.mcvsoftware.com/projects/twblue/twblue/pl/>\n"
"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && "
"(n%100<10 || n%100>=20) ? 1 : 2;\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
"|| n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 5.10.4\n"
"Generated-By: Babel 2.17.0\n"
#: languageHandler.py:61
@@ -102,7 +101,7 @@ msgstr "Domyślne dla użytkownika"
#: main.py:105
#, fuzzy
msgid "https://twblue.mcvsoftware.com/donate"
msgstr "https://twblue.mcvsoftware.com/donate"
msgstr "https://twblue.es/donate"
#: main.py:118
#, python-brace-format
@@ -247,8 +246,9 @@ msgid "Following for {}"
msgstr "Śledzący użytkownika {}"
#: controller/messages.py:18
#, fuzzy
msgid "Translated"
msgstr "Przetłumaczono"
msgstr "&Przetłumacz"
#: controller/settings.py:60
msgid "System default"
@@ -540,8 +540,9 @@ msgid "There are no more items in this buffer."
msgstr "W tym buforze nie ma więcej elementów."
#: controller/mastodon/handler.py:30 wxUI/dialogs/mastodon/updateProfile.py:35
#, fuzzy
msgid "Update Profile"
msgstr "Zaktualizuj profil"
msgstr "&Edytuj profil"
#: controller/mastodon/handler.py:31 wxUI/dialogs/mastodon/search.py:10
#: wxUI/view.py:19
@@ -614,12 +615,13 @@ msgid "Add a&lias"
msgstr "Dodaj a&lias"
#: controller/mastodon/handler.py:51
#, fuzzy
msgid "Show user profile"
msgstr "Pokaż profil użytkownika"
msgstr "&Pokaż profil użytkownika"
#: controller/mastodon/handler.py:54
msgid "Create c&ommunity timeline"
msgstr "Stwórz &oś czasu społeczności"
msgstr ""
#: controller/mastodon/handler.py:55 wxUI/view.py:57
msgid "Create a &filter"
@@ -645,9 +647,10 @@ msgstr "Wyszukiwanie {}"
#: controller/mastodon/handler.py:111
msgid "Communities"
msgstr "Społeczności"
msgstr ""
#: controller/mastodon/handler.py:114
#, fuzzy
msgid "federated"
msgstr "federowana"
@@ -4861,3 +4864,4 @@ msgstr "Dodatki"
#~ msgid "DeepL API Key: "
#~ msgstr ""

View File

@@ -17,11 +17,6 @@ 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)

View File

@@ -248,106 +248,6 @@ class Session(base.baseSession):
pub.sendMessage("mastodon.error_post", name=self.get_name(), reply_to=reply_to, visibility=visibility, posts=posts, lang=language)
return
def edit_post(self, post_id, posts=[]):
""" Convenience function to edit a post. Only the first item in posts list is used as threads cannot be edited.
Note: According to Mastodon API, not all fields can be edited. Visibility, language, and reply context cannot be changed.
Args:
post_id: ID of the status to edit
posts: List with post data. Only first item is used.
Returns:
Updated status object or None on failure
"""
if len(posts) == 0:
log.warning("edit_post called with empty posts list")
return None
obj = posts[0]
text = obj.get("text")
if not text:
log.warning("edit_post called without text content")
return None
media_ids = []
media_attributes = []
try:
poll = None
# Handle poll attachments
if len(obj["attachments"]) == 1 and obj["attachments"][0]["type"] == "poll":
poll = self.api.make_poll(
options=obj["attachments"][0]["options"],
expires_in=obj["attachments"][0]["expires_in"],
multiple=obj["attachments"][0]["multiple"],
hide_totals=obj["attachments"][0]["hide_totals"]
)
log.debug("Editing post with poll (this will reset votes)")
# Handle media attachments
elif len(obj["attachments"]) > 0:
for i in obj["attachments"]:
# If attachment has an 'id', it's an existing media that we keep
if "id" in i:
media_ids.append(i["id"])
# If existing media has metadata to update, use generate_media_edit_attributes
if "description" in i or "focus" in i:
media_attr = self.api.generate_media_edit_attributes(
id=i["id"],
description=i.get("description"),
focus=i.get("focus")
)
media_attributes.append(media_attr)
# Otherwise it's a new file to upload
elif "file" in i:
description = i.get("description", "")
focus = i.get("focus", None)
media = self.api_call(
"media_post",
media_file=i["file"],
description=description,
focus=focus,
synchronous=True
)
media_ids.append(media.id)
log.debug("Uploaded new media with id: {}".format(media.id))
# Prepare parameters for status_update
update_params = {
"id": post_id,
"status": text,
"_sound": "tweet_send.ogg",
"sensitive": obj.get("sensitive", False),
"spoiler_text": obj.get("spoiler_text", None),
}
# Add optional parameters only if provided
if media_ids:
update_params["media_ids"] = media_ids
if media_attributes:
update_params["media_attributes"] = media_attributes
if poll:
update_params["poll"] = poll
# Call status_update API
log.debug("Editing post {} with params: {}".format(post_id, {k: v for k, v in update_params.items() if k not in ["_sound"]}))
item = self.api_call(call_name="status_update", **update_params)
if item:
log.info("Successfully edited post {}".format(post_id))
return item
except MastodonAPIError as e:
log.exception("Mastodon API error updating post {}: {}".format(post_id, str(e)))
output.speak(_("Error editing post: {}").format(str(e)))
pub.sendMessage("mastodon.error_edit", name=self.get_name(), post_id=post_id, error=str(e))
return None
except Exception as e:
log.exception("Unexpected error updating post {}: {}".format(post_id, str(e)))
output.speak(_("Error editing post: {}").format(str(e)))
return None
def get_name(self):
instance = self.settings["mastodon"]["instance"]
instance = instance.replace("https://", "")

View File

@@ -76,13 +76,6 @@ 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)

View File

@@ -3,47 +3,23 @@ import demoji
from html.parser import HTMLParser
from datetime import datetime, timezone
url_re = re.compile(r'<a\s*href=[\'|"](.*?)[\'"].*?>')
url_re = re.compile('<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):
# Only add data if we're not inside an ignored element
if self.skip_depth == 0:
self.text += data
self.text += data
def handle_starttag(self, tag, attrs):
# 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
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"
def html_filter(data):
f = HTMLFilter()

View File

@@ -8,8 +8,6 @@ class base(wx.Menu):
self.Append(self.boost)
self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply"))
self.Append(self.reply)
self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit"))
self.Append(self.edit)
self.fav = wx.MenuItem(self, wx.ID_ANY, _(u"&Add to favorites"))
self.Append(self.fav)
self.unfav = wx.MenuItem(self, wx.ID_ANY, _(u"R&emove from favorites"))
@@ -38,8 +36,6 @@ class notification(wx.Menu):
self.Append(self.boost)
self.reply = wx.MenuItem(self, wx.ID_ANY, _(u"Re&ply"))
self.Append(self.reply)
self.edit = wx.MenuItem(self, wx.ID_ANY, _(u"&Edit"))
self.Append(self.edit)
self.fav = wx.MenuItem(self, wx.ID_ANY, _(u"&Add to favorites"))
self.Append(self.fav)
self.unfav = wx.MenuItem(self, wx.ID_ANY, _(u"R&emove from favorites"))

View File

@@ -51,7 +51,7 @@ class Post(wx.Dialog):
visibility_sizer.Add(self.visibility, 0, 0, 0)
language_sizer = wx.BoxSizer(wx.HORIZONTAL)
post_actions_sizer.Add(language_sizer, 0, wx.RIGHT, 20)
lang_label = wx.StaticText(self, wx.ID_ANY, _("&Language"))
lang_label = wx.StaticText(self, wx.ID_ANY, _("Language"))
language_sizer.Add(lang_label, 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 5)
self.language = wx.ComboBox(self, wx.ID_ANY, choices=languages, style=wx.CB_DROPDOWN | wx.CB_READONLY)
language_sizer.Add(self.language, 0, wx.ALIGN_CENTER_VERTICAL, 0)
@@ -234,9 +234,9 @@ class viewPost(wx.Dialog):
def create_buttons_section(self, panel):
sizer = wx.BoxSizer(wx.HORIZONTAL)
self.mute = wx.Button(panel, wx.ID_ANY, _("&Mute conversation"))
self.mute = wx.Button(panel, wx.ID_ANY, _("Mute conversation"))
self.mute.Enable(False)
self.share = wx.Button(panel, wx.ID_ANY, _("&Copy link to clipboard"))
self.share = wx.Button(panel, wx.ID_ANY, _("Copy link to clipboard"))
self.share.Enable(False)
self.spellcheck = wx.Button(panel, wx.ID_ANY, _("Check &spelling..."))
self.translateButton = wx.Button(panel, wx.ID_ANY, _("&Translate..."))
@@ -295,7 +295,7 @@ class poll(wx.Dialog):
sizer_1 = wx.BoxSizer(wx.VERTICAL)
period_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_1.Add(period_sizer, 1, wx.EXPAND, 0)
label_period = wx.StaticText(self, wx.ID_ANY, _("&Participation time"))
label_period = wx.StaticText(self, wx.ID_ANY, _("Participation time"))
period_sizer.Add(label_period, 0, 0, 0)
self.period = wx.ComboBox(self, wx.ID_ANY, choices=[_("5 minutes"), _("30 minutes"), _("1 hour"), _("6 hours"), _("1 day"), _("2 days"), _("3 days"), _("4 days"), _("5 days"), _("6 days"), _("7 days")], style=wx.CB_DROPDOWN | wx.CB_READONLY | wx.CB_SIMPLE)
self.period.SetFocus()
@@ -305,36 +305,36 @@ class poll(wx.Dialog):
sizer_1.Add(sizer_2, 1, wx.EXPAND, 0)
option1_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_2.Add(option1_sizer, 1, wx.EXPAND, 0)
label_2 = wx.StaticText(self, wx.ID_ANY, _("Option &1"))
label_2 = wx.StaticText(self, wx.ID_ANY, _("Option 1"))
option1_sizer.Add(label_2, 0, 0, 0)
self.option1 = wx.TextCtrl(self, wx.ID_ANY, "")
self.option1.SetMaxLength(25)
option1_sizer.Add(self.option1, 0, 0, 0)
option2_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_2.Add(option2_sizer, 1, wx.EXPAND, 0)
label_3 = wx.StaticText(self, wx.ID_ANY, _("Option &2"))
label_3 = wx.StaticText(self, wx.ID_ANY, _("Option 2"))
option2_sizer.Add(label_3, 0, 0, 0)
self.option2 = wx.TextCtrl(self, wx.ID_ANY, "")
self.option2.SetMaxLength(25)
option2_sizer.Add(self.option2, 0, 0, 0)
option3_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_2.Add(option3_sizer, 1, wx.EXPAND, 0)
label_4 = wx.StaticText(self, wx.ID_ANY, _("Option &3"))
label_4 = wx.StaticText(self, wx.ID_ANY, _("Option 3"))
option3_sizer.Add(label_4, 0, 0, 0)
self.option3 = wx.TextCtrl(self, wx.ID_ANY, "")
self.option3.SetMaxLength(25)
option3_sizer.Add(self.option3, 0, 0, 0)
option4_sizer = wx.BoxSizer(wx.HORIZONTAL)
sizer_2.Add(option4_sizer, 1, wx.EXPAND, 0)
label_5 = wx.StaticText(self, wx.ID_ANY, _("Option &4"))
label_5 = wx.StaticText(self, wx.ID_ANY, _("Option 4"))
option4_sizer.Add(label_5, 0, 0, 0)
self.option4 = wx.TextCtrl(self, wx.ID_ANY, "")
self.option4.SetMaxLength(25)
option4_sizer.Add(self.option4, 0, 0, 0)
self.multiple = wx.CheckBox(self, wx.ID_ANY, _("&Allow multiple choices per user"))
self.multiple = wx.CheckBox(self, wx.ID_ANY, _("Allow multiple choices per user"))
self.multiple.SetValue(False)
sizer_1.Add(self.multiple, 0, wx.ALL, 5)
self.hide_votes = wx.CheckBox(self, wx.ID_ANY, _("&Hide votes count until the poll expires"))
self.hide_votes = wx.CheckBox(self, wx.ID_ANY, _("Hide votes count until the poll expires"))
self.hide_votes.SetValue(False)
sizer_1.Add(self.hide_votes, 0, wx.ALL, 5)
btn_sizer = wx.StdDialogButtonSizer()

View File

@@ -26,7 +26,7 @@ class EditTemplateDialog(wx.Dialog):
sizer_3.AddButton(self.button_SAVE)
self.button_CANCEL = wx.Button(self, wx.ID_CANCEL)
sizer_3.AddButton(self.button_CANCEL)
self.button_RESTORE = wx.Button(self, wx.ID_ANY, _("&Restore template"))
self.button_RESTORE = wx.Button(self, wx.ID_ANY, _("Restore template"))
self.button_RESTORE.Bind(wx.EVT_BUTTON, self.on_restore)
sizer_3.AddButton(self.button_CANCEL)
sizer_3.Realize()

View File

@@ -22,11 +22,11 @@ class UserListDialog(wx.Dialog):
user_list_sizer.Add(self.user_list, 1, wx.EXPAND | wx.ALL, 10)
main_sizer.Add(user_list_sizer, 1, wx.EXPAND | wx.ALL, 15)
buttons_sizer = wx.BoxSizer(wx.HORIZONTAL)
self.actions_button = wx.Button(panel, wx.ID_ANY, "&Actions")
self.actions_button = wx.Button(panel, wx.ID_ANY, "Actions")
buttons_sizer.Add(self.actions_button, 0, wx.RIGHT, 10)
self.details_button = wx.Button(panel, wx.ID_ANY, _("&View profile"))
self.details_button = wx.Button(panel, wx.ID_ANY, _("View profile"))
buttons_sizer.Add(self.details_button, 0, wx.RIGHT, 10)
close_button = wx.Button(panel, wx.ID_CANCEL, "&Close")
close_button = wx.Button(panel, wx.ID_CANCEL, "Close")
buttons_sizer.Add(close_button, 0)
main_sizer.Add(buttons_sizer, 0, wx.ALIGN_CENTER | wx.BOTTOM, 15)
panel.SetSizer(main_sizer)