Initial commit: Bifrost accessible fediverse client
- Full ActivityPub support for Pleroma, GoToSocial, and Mastodon - Screen reader optimized interface with PySide6 - Timeline switching with tabs and keyboard shortcuts (Ctrl+1-4) - Threaded conversation navigation with expand/collapse - Cross-platform desktop notifications via plyer - Customizable sound pack system with audio feedback - Complete keyboard navigation and accessibility features - XDG Base Directory compliant configuration - Multiple account support with OAuth authentication 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
133
.gitignore
vendored
Normal file
133
.gitignore
vendored
Normal file
@ -0,0 +1,133 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Bifrost specific
|
||||
*.log
|
||||
config/*.ini
|
||||
accounts/*.json
|
||||
.cache/
|
||||
sounds/*/
|
||||
!sounds/default/
|
386
CLAUDE.md
Normal file
386
CLAUDE.md
Normal file
@ -0,0 +1,386 @@
|
||||
# Bifrost Fediverse Client - Development Plan
|
||||
|
||||
## Project Overview
|
||||
Bifrost is a fully accessible fediverse client built with PySide6, designed specifically for screen reader users. The application uses "post/posts" terminology instead of "toot" and focuses on excellent keyboard navigation and audio feedback.
|
||||
|
||||
## Core Features
|
||||
- Full ActivityPub protocol support (Pleroma and GoToSocial primary targets)
|
||||
- Threaded conversation navigation with collapsible tree view
|
||||
- Customizable sound pack system for audio notifications
|
||||
- Screen reader optimized interface
|
||||
- XDG Base Directory specification compliance
|
||||
|
||||
## Technology Stack
|
||||
- **PySide6**: Main GUI framework (proven accessibility with existing doom launcher)
|
||||
- **requests**: HTTP client for ActivityPub APIs
|
||||
- **simpleaudio**: Cross-platform audio with subprocess fallback
|
||||
- **XDG directories**: Configuration and data storage
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
bifrost/
|
||||
├── bifrost/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # Application entry point
|
||||
│ ├── accessibility/ # Accessibility widgets and helpers
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── accessible_tree.py # AccessibleTreeWidget for conversations
|
||||
│ │ └── accessible_combo.py # Enhanced ComboBox from doom launcher
|
||||
│ ├── activitypub/ # Federation protocol handling
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── client.py # Main ActivityPub client
|
||||
│ │ ├── pleroma.py # Pleroma-specific implementation
|
||||
│ │ └── gotosocial.py # GoToSocial-specific implementation
|
||||
│ ├── models/ # Data models
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── post.py # Post data structure
|
||||
│ │ ├── user.py # User profiles
|
||||
│ │ ├── timeline.py # Timeline model for QTreeView
|
||||
│ │ └── thread.py # Conversation threading
|
||||
│ ├── widgets/ # Custom UI components
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── timeline_view.py # Main timeline widget
|
||||
│ │ ├── compose_dialog.py # Post composition
|
||||
│ │ ├── settings_dialog.py # Application settings
|
||||
│ │ └── login_dialog.py # Instance login
|
||||
│ ├── audio/ # Sound system
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── sound_manager.py # Audio notification handler
|
||||
│ │ └── sound_pack.py # Sound pack management
|
||||
│ └── config/ # Configuration management
|
||||
│ ├── __init__.py
|
||||
│ ├── settings.py # Settings handler with XDG compliance
|
||||
│ └── accounts.py # Account management
|
||||
├── sounds/ # Sound packs directory
|
||||
│ ├── default/
|
||||
│ │ ├── pack.json
|
||||
│ │ ├── private_message.wav
|
||||
│ │ ├── mention.wav
|
||||
│ │ ├── boost.wav
|
||||
│ │ ├── reply.wav
|
||||
│ │ ├── post_sent.wav
|
||||
│ │ ├── timeline_update.wav
|
||||
│ │ └── notification.wav
|
||||
│ └── doom/ # Example themed sound pack
|
||||
│ ├── pack.json
|
||||
│ └── *.wav files
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── test_accessibility.py
|
||||
│ ├── test_activitypub.py
|
||||
│ └── test_audio.py
|
||||
├── requirements.txt
|
||||
├── setup.py
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### XDG Directory Usage
|
||||
- **Config**: `~/.config/bifrost/` - Settings, accounts, current sound pack
|
||||
- **Data**: `~/.local/share/bifrost/` - Sound packs, cached data
|
||||
- **Cache**: `~/.cache/bifrost/` - Temporary files, avatar cache
|
||||
|
||||
## Accessibility Implementation
|
||||
|
||||
### From Doom Launcher Success
|
||||
- **AccessibleComboBox**: Enhanced keyboard navigation (Page Up/Down, Home/End)
|
||||
- **Proper Accessible Names**: All widgets get descriptive `setAccessibleName()`
|
||||
- **Focus Management**: Clear tab order and focus indicators
|
||||
- **No Custom Speech**: Screen reader handles all announcements
|
||||
|
||||
### Threaded Conversation Navigation
|
||||
**Navigation Pattern:**
|
||||
```
|
||||
Timeline Item: "Alice posted: Hello world (3 replies, collapsed)"
|
||||
[Right Arrow] → "Alice posted: Hello world (3 replies, expanded)"
|
||||
[Down Arrow] → " Bob replied: Hi there"
|
||||
[Down Arrow] → " Carol replied: How's it going?"
|
||||
[Down Arrow] → "David posted: Another topic"
|
||||
```
|
||||
|
||||
**Key Behaviors:**
|
||||
- Right Arrow: Expand thread, announce "expanded"
|
||||
- Left Arrow: Collapse thread, announce "collapsed"
|
||||
- Down Arrow: Next item (skip collapsed children)
|
||||
- Up Arrow: Previous item
|
||||
- Page Down/Up: Jump 5 items
|
||||
- Home/End: First/last item
|
||||
|
||||
### AccessibleTreeWidget Requirements
|
||||
- Inherit from QTreeWidget
|
||||
- Override keyPressEvent for custom navigation
|
||||
- Proper accessibility roles and states
|
||||
- Focus management for nested items
|
||||
- Status announcements via Qt accessibility
|
||||
|
||||
## Sound System Design
|
||||
|
||||
### Sound Events
|
||||
- **startup**: Application started
|
||||
- **shutdown**: Application closing
|
||||
- **private_message**: Direct message received
|
||||
- **mention**: User mentioned in post
|
||||
- **boost**: Post boosted/reblogged
|
||||
- **reply**: Reply to user's post
|
||||
- **post_sent**: User successfully posted
|
||||
- **timeline_update**: New posts in timeline
|
||||
- **notification**: General notification
|
||||
- **expand**: Thread expanded
|
||||
- **collapse**: Thread collapsed
|
||||
- **success**: General success feedback
|
||||
- **error**: Error occurred
|
||||
|
||||
### Sound Pack Structure
|
||||
**pack.json Format:**
|
||||
```json
|
||||
{
|
||||
"name": "Pack Display Name",
|
||||
"description": "Pack description",
|
||||
"author": "Creator name",
|
||||
"version": "1.0",
|
||||
"sounds": {
|
||||
"private_message": "filename.wav",
|
||||
"mention": "filename.wav",
|
||||
"boost": "filename.wav",
|
||||
"reply": "filename.wav",
|
||||
"post_sent": "filename.wav",
|
||||
"timeline_update": "filename.wav",
|
||||
"notification": "filename.wav"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SoundManager Features
|
||||
- simpleaudio for cross-platform WAV playback with volume control
|
||||
- Subprocess fallback (sox/play on Linux, afplay on macOS, PowerShell on Windows)
|
||||
- Fallback to default pack if sound missing
|
||||
- Master volume and notification volume controls
|
||||
- Enable/disable per event
|
||||
- Pack discovery and validation
|
||||
- Smart threading (direct calls for simpleaudio, threaded for subprocess)
|
||||
|
||||
## ActivityPub Implementation
|
||||
|
||||
### Core Client Features
|
||||
- **Timeline Streaming**: Real-time updates via WebSocket/polling
|
||||
- **Post Composition**: Text, media attachments, visibility settings
|
||||
- **Thread Resolution**: Fetch complete conversation trees
|
||||
- **User Profiles**: Following, followers, profile viewing
|
||||
- **Notifications**: Mentions, boosts, follows, favorites
|
||||
|
||||
### Server Compatibility
|
||||
**Primary Targets:**
|
||||
- Pleroma: Full feature support
|
||||
- GoToSocial: Full feature support
|
||||
|
||||
**Extended Support:**
|
||||
- Mastodon: Best effort compatibility
|
||||
- Other ActivityPub servers: Basic functionality
|
||||
|
||||
### API Endpoints Usage
|
||||
- `/api/v1/timelines/home` - Home timeline
|
||||
- `/api/v1/statuses` - Post creation
|
||||
- `/api/v1/statuses/:id/context` - Thread fetching
|
||||
- `/api/v1/streaming` - Real-time updates
|
||||
- `/api/v1/notifications` - Notification management
|
||||
|
||||
## User Interface Design
|
||||
|
||||
### Main Window Layout
|
||||
```
|
||||
[Menu Bar]
|
||||
[Instance/Account Selector]
|
||||
[Timeline Tree View - Main Focus]
|
||||
[Compose Box]
|
||||
[Status Bar]
|
||||
```
|
||||
|
||||
### Key UI Components
|
||||
- **Timeline View**: AccessibleTreeWidget showing posts and threads
|
||||
- **Compose Dialog**: Modal for creating posts with accessibility
|
||||
- **Settings Dialog**: Sound pack selection, accessibility options
|
||||
- **Login Dialog**: Instance selection and authentication
|
||||
|
||||
### Keyboard Shortcuts
|
||||
- **Ctrl+N**: New post
|
||||
- **Ctrl+R**: Reply to selected post
|
||||
- **Ctrl+B**: Boost selected post
|
||||
- **Ctrl+F**: Favorite selected post
|
||||
- **F5**: Refresh timeline
|
||||
- **Ctrl+,**: Settings
|
||||
- **Escape**: Close dialogs
|
||||
|
||||
## Development Phases
|
||||
|
||||
### Phase 1: Foundation
|
||||
1. Project structure setup
|
||||
2. XDG configuration system
|
||||
3. Basic PySide6 window with accessibility
|
||||
4. AccessibleTreeWidget implementation
|
||||
5. Sound system foundation
|
||||
|
||||
### Phase 2: ActivityPub Core
|
||||
1. Basic HTTP client
|
||||
2. Authentication (OAuth2)
|
||||
3. Timeline fetching and display
|
||||
4. Post composition and sending
|
||||
5. Basic thread display
|
||||
|
||||
### Phase 3: Advanced Features
|
||||
1. Thread expansion/collapse
|
||||
2. Real-time updates
|
||||
3. Notifications system
|
||||
4. Sound pack system
|
||||
5. Settings interface
|
||||
|
||||
### Phase 4: Polish
|
||||
1. Comprehensive accessibility testing
|
||||
2. Screen reader testing (Orca, NVDA, JAWS)
|
||||
3. Performance optimization
|
||||
4. Error handling
|
||||
5. Documentation
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Accessibility Testing
|
||||
- Automated testing with screen reader APIs
|
||||
- Manual testing with Orca, NVDA (via Wine)
|
||||
- Keyboard-only navigation testing
|
||||
- Focus management verification
|
||||
|
||||
### Functional Testing
|
||||
- ActivityPub protocol compliance
|
||||
- Thread navigation accuracy
|
||||
- Sound system reliability
|
||||
- Configuration persistence
|
||||
|
||||
### Test Instances
|
||||
- Pleroma test server
|
||||
- GoToSocial test server
|
||||
- Mock ActivityPub server for edge cases
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Settings Structure
|
||||
```ini
|
||||
[general]
|
||||
instance_url = https://example.social
|
||||
username = user
|
||||
timeline_refresh_interval = 60
|
||||
|
||||
[audio]
|
||||
sound_pack = Doom
|
||||
master_volume = 100
|
||||
notification_volume = 100
|
||||
|
||||
[sounds]
|
||||
private_message_enabled = true
|
||||
private_message_volume = 0.8
|
||||
mention_enabled = true
|
||||
mention_volume = 1.0
|
||||
# ... other sound settings
|
||||
|
||||
[accessibility]
|
||||
announce_thread_state = true
|
||||
auto_expand_mentions = false
|
||||
keyboard_navigation_wrap = true
|
||||
```
|
||||
|
||||
### Account Storage
|
||||
- Secure credential storage
|
||||
- Multiple account support
|
||||
- Instance-specific settings
|
||||
|
||||
## Known Challenges and Solutions
|
||||
|
||||
### ActivityPub Complexity
|
||||
- **Challenge**: Different server implementations vary
|
||||
- **Solution**: Modular client design with server-specific adapters
|
||||
|
||||
### Screen Reader Performance
|
||||
- **Challenge**: Large timelines may impact performance
|
||||
- **Solution**: Virtual scrolling, lazy loading, efficient tree models
|
||||
|
||||
### Thread Visualization
|
||||
- **Challenge**: Complex thread structures hard to navigate
|
||||
- **Solution**: Clear indentation, status announcements, skip collapsed
|
||||
|
||||
### Sound Customization
|
||||
- **Challenge**: Users want different audio feedback
|
||||
- **Solution**: Comprehensive sound pack system with easy installation
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Advanced Features
|
||||
- Custom timeline filters
|
||||
- Multiple column support
|
||||
- Direct message interface
|
||||
- List management
|
||||
- Advanced search
|
||||
|
||||
### Accessibility Extensions
|
||||
- Braille display optimization
|
||||
- Voice control integration
|
||||
- High contrast themes
|
||||
- Font size scaling
|
||||
|
||||
### Federation Features
|
||||
- Cross-instance thread following
|
||||
- Server switching
|
||||
- Federation status monitoring
|
||||
- Custom emoji support
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core Requirements
|
||||
```
|
||||
PySide6>=6.0.0
|
||||
requests>=2.25.0
|
||||
simpleaudio>=1.0.4
|
||||
numpy
|
||||
configparser
|
||||
pathlib
|
||||
```
|
||||
|
||||
### Optional Dependencies
|
||||
```
|
||||
pytest (testing)
|
||||
coverage (test coverage)
|
||||
black (code formatting)
|
||||
mypy (type checking)
|
||||
```
|
||||
|
||||
## Installation and Distribution
|
||||
|
||||
### Development Setup
|
||||
```bash
|
||||
git clone <repository>
|
||||
cd bifrost
|
||||
pip install -r requirements.txt
|
||||
# Run with proper display
|
||||
DISPLAY=:0 ./bifrost.py
|
||||
# Or
|
||||
python bifrost.py
|
||||
```
|
||||
|
||||
### Packaging
|
||||
- Python wheel distribution
|
||||
- AppImage for Linux
|
||||
- Consideration for distro packages
|
||||
|
||||
## Accessibility Compliance
|
||||
|
||||
### Standards Adherence
|
||||
- WCAG 2.1 AA compliance
|
||||
- Qt Accessibility framework usage
|
||||
- AT-SPI2 protocol support (Linux)
|
||||
- Platform accessibility APIs
|
||||
|
||||
### Screen Reader Testing Matrix
|
||||
- **Orca** (Linux): Primary target
|
||||
- **NVDA** (Windows via Wine): Secondary
|
||||
- **JAWS** (Windows via Wine): Basic compatibility
|
||||
- **VoiceOver** (macOS): Future consideration
|
||||
|
||||
This document serves as the comprehensive development guide for Bifrost, ensuring all accessibility, functionality, and architectural decisions are preserved and can be referenced throughout development.
|
674
LICENSE
Normal file
674
LICENSE
Normal file
@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
65
README.md
Normal file
65
README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Bifrost
|
||||
|
||||
A fully accessible fediverse client built with PySide6, designed specifically for screen reader users.
|
||||
|
||||
## Vibe Coding Project
|
||||
|
||||
This project was created through "vibe coding" - a collaborative development approach where a human (Storm Dragon) provides direction, requirements, and testing while an AI assistant (Claude) handles the actual code implementation. Vibe coding combines human creativity and domain expertise with AI's rapid development capabilities, resulting in functional software that meets real accessibility needs.
|
||||
|
||||
**All code in this project was written by Claude (Anthropic's AI assistant)** based on specifications and feedback from Storm Dragon.
|
||||
|
||||
## Features
|
||||
|
||||
- **Full ActivityPub Support**: Compatible with Pleroma, GoToSocial, and other fediverse servers
|
||||
- **Screen Reader Optimized**: Designed from the ground up for excellent accessibility
|
||||
- **Threaded Conversations**: Navigate complex conversation trees with keyboard shortcuts
|
||||
- **Timeline Switching**: Easy navigation between Home, Mentions, Local, and Federated timelines
|
||||
- **Desktop Notifications**: Cross-platform notifications for mentions, direct messages, and timeline updates
|
||||
- **Customizable Audio Feedback**: Rich sound pack system with themed audio notifications
|
||||
- **Clean Interface**: Focused on functionality over visual design
|
||||
- **Keyboard Navigation**: Complete keyboard control with intuitive shortcuts
|
||||
|
||||
## Audio System
|
||||
|
||||
Bifrost includes a sophisticated sound system with:
|
||||
- Customizable sound packs (includes Default sounds)
|
||||
- Audio feedback for all major actions
|
||||
- Per-event volume control
|
||||
- Cross-platform audio support
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **PySide6**: Main GUI framework for proven accessibility
|
||||
- **ActivityPub**: Full federation protocol support
|
||||
- **simpleaudio**: Cross-platform audio with subprocess fallback
|
||||
- **Plyer**: Cross-platform desktop notifications
|
||||
- **XDG Base Directory**: Standards-compliant configuration storage
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
git clone <repository>
|
||||
cd bifrost
|
||||
pip install -r requirements.txt
|
||||
python bifrost.py
|
||||
```
|
||||
|
||||
## Accessibility Features
|
||||
|
||||
- Complete keyboard navigation
|
||||
- Proper screen reader announcements
|
||||
- Focus management and tab order
|
||||
- Accessible names and descriptions for all controls
|
||||
- Thread expansion/collapse with audio feedback
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a vibe coding project where AI handles implementation. Human contributors can:
|
||||
- Test accessibility with different screen readers
|
||||
- Suggest features and improvements
|
||||
- Create new sound packs
|
||||
- Report bugs and usability issues
|
||||
|
||||
## License
|
||||
|
||||
This project demonstrates the potential of human-AI collaboration in creating accessible software. It is released under the gpl version 3. See LICENSE file for details.
|
38
bifrost.py
Executable file
38
bifrost.py
Executable file
@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Bifrost - Accessible Fediverse Client
|
||||
A fully accessible ActivityPub client designed for screen reader users.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Add src directory to Python path
|
||||
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtCore import Qt
|
||||
from main_window import MainWindow
|
||||
|
||||
def main():
|
||||
"""Main application entry point"""
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Bifrost")
|
||||
app.setApplicationDisplayName("Bifrost Fediverse Client")
|
||||
app.setApplicationVersion("1.0.0")
|
||||
app.setOrganizationName("Bifrost")
|
||||
app.setOrganizationDomain("bifrost.social")
|
||||
|
||||
# High DPI scaling is enabled by default in newer Qt versions
|
||||
|
||||
# Create and show main window
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
# Run the application
|
||||
sys.exit(app.exec())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
PySide6>=6.0.0
|
||||
requests>=2.25.0
|
||||
simpleaudio>=1.0.4
|
||||
plyer>=2.1.0
|
1
src/__init__.py
Normal file
1
src/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Bifrost package
|
1
src/accessibility/__init__.py
Normal file
1
src/accessibility/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Accessibility widgets and helpers
|
76
src/accessibility/accessible_combo.py
Normal file
76
src/accessibility/accessible_combo.py
Normal file
@ -0,0 +1,76 @@
|
||||
"""
|
||||
Accessible combo box with enhanced keyboard navigation
|
||||
Based on the successful implementation from the Doom Launcher
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
|
||||
|
||||
class AccessibleComboBox(QComboBox):
|
||||
"""ComboBox with enhanced keyboard navigation for accessibility"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setEditable(True)
|
||||
self.lineEdit().setReadOnly(True)
|
||||
self.page_step = 5 # Number of items to jump for page up/down
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent):
|
||||
"""Handle keyboard navigation with accessibility enhancements"""
|
||||
current_index = self.currentIndex()
|
||||
item_count = self.count()
|
||||
|
||||
if event.key() == Qt.Key_PageUp:
|
||||
# Jump up by page_step items
|
||||
new_index = max(0, current_index - self.page_step)
|
||||
self.setCurrentIndex(new_index)
|
||||
|
||||
elif event.key() == Qt.Key_PageDown:
|
||||
# Jump down by page_step items
|
||||
new_index = min(item_count - 1, current_index + self.page_step)
|
||||
self.setCurrentIndex(new_index)
|
||||
|
||||
elif event.key() == Qt.Key_Home:
|
||||
# Go to first item
|
||||
self.setCurrentIndex(0)
|
||||
# Force update and focus events
|
||||
self.setFocus()
|
||||
self.currentIndexChanged.emit(0)
|
||||
self.activated.emit(0)
|
||||
|
||||
elif event.key() == Qt.Key_End:
|
||||
# Go to last item
|
||||
last_index = item_count - 1
|
||||
self.setCurrentIndex(last_index)
|
||||
# Force update and focus events
|
||||
self.setFocus()
|
||||
self.currentIndexChanged.emit(last_index)
|
||||
self.activated.emit(last_index)
|
||||
|
||||
else:
|
||||
# Use default behavior for other keys
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def wheelEvent(self, event):
|
||||
"""Handle mouse wheel events with moderation"""
|
||||
# Only change selection if the widget has focus
|
||||
if self.hasFocus():
|
||||
super().wheelEvent(event)
|
||||
else:
|
||||
# Ignore wheel events when not focused to prevent accidental changes
|
||||
event.ignore()
|
||||
|
||||
def setAccessibleName(self, name: str):
|
||||
"""Set accessible name and ensure it's properly applied"""
|
||||
super().setAccessibleName(name)
|
||||
# Also set it on the line edit for better screen reader support
|
||||
if self.lineEdit():
|
||||
self.lineEdit().setAccessibleName(name)
|
||||
|
||||
def setAccessibleDescription(self, description: str):
|
||||
"""Set accessible description"""
|
||||
super().setAccessibleDescription(description)
|
||||
if self.lineEdit():
|
||||
self.lineEdit().setAccessibleDescription(description)
|
254
src/accessibility/accessible_tree.py
Normal file
254
src/accessibility/accessible_tree.py
Normal file
@ -0,0 +1,254 @@
|
||||
"""
|
||||
Accessible tree widget for threaded conversation navigation
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
|
||||
|
||||
class AccessibleTreeWidget(QTreeWidget):
|
||||
"""Tree widget with enhanced accessibility for threaded conversations"""
|
||||
|
||||
item_state_changed = Signal(QTreeWidgetItem, str) # item, state_description
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.page_step = 5 # Number of items to jump for page up/down
|
||||
self.setup_accessibility()
|
||||
|
||||
def setup_accessibility(self):
|
||||
"""Set up accessibility features"""
|
||||
self.setFocusPolicy(Qt.StrongFocus)
|
||||
self.itemExpanded.connect(self.on_item_expanded)
|
||||
self.itemCollapsed.connect(self.on_item_collapsed)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent):
|
||||
"""Handle keyboard navigation with accessibility enhancements"""
|
||||
key = event.key()
|
||||
current = self.currentItem()
|
||||
|
||||
# Handle context menu key (Menu key or Shift+F10)
|
||||
if key == Qt.Key_Menu or (key == Qt.Key_F10 and event.modifiers() & Qt.ShiftModifier):
|
||||
if current:
|
||||
# Get the position of the current item
|
||||
rect = self.visualItemRect(current)
|
||||
center = rect.center()
|
||||
# Emit context menu signal
|
||||
self.customContextMenuRequested.emit(center)
|
||||
return
|
||||
|
||||
if not current:
|
||||
super().keyPressEvent(event)
|
||||
return
|
||||
|
||||
# Check for Shift modifier
|
||||
has_shift = event.modifiers() & Qt.ShiftModifier
|
||||
|
||||
if key == Qt.Key_Right:
|
||||
# Right Arrow (with or without Shift): Expand thread
|
||||
if current.childCount() > 0 and not current.isExpanded():
|
||||
# Use Qt's built-in expand method
|
||||
self.expandItem(current)
|
||||
# Keep focus on the same item
|
||||
self.setCurrentItem(current)
|
||||
self.scrollToItem(current) # Prevent jumping to bottom
|
||||
self.announce_item_state(current, "expanded")
|
||||
return
|
||||
# If already expanded or no children, move to first child (only without Shift)
|
||||
elif current.childCount() > 0 and current.isExpanded() and not has_shift:
|
||||
first_child = current.child(0)
|
||||
if first_child:
|
||||
self.setCurrentItem(first_child)
|
||||
self.scrollToItem(first_child)
|
||||
return
|
||||
|
||||
elif key == Qt.Key_Left:
|
||||
# Shift+Left or plain Left: Collapse thread if expanded
|
||||
if current.childCount() > 0 and current.isExpanded():
|
||||
# Try direct setExpanded(False) instead of collapseItem()
|
||||
current.setExpanded(False)
|
||||
# Keep focus on the same item
|
||||
self.setCurrentItem(current)
|
||||
self.scrollToItem(current)
|
||||
self.announce_item_state(current, "collapsed")
|
||||
return
|
||||
# Plain Left only: move to parent if already collapsed
|
||||
elif current.parent() and not has_shift:
|
||||
parent_item = current.parent()
|
||||
self.setCurrentItem(parent_item)
|
||||
self.scrollToItem(parent_item) # Ensure parent stays visible
|
||||
return
|
||||
|
||||
elif key == Qt.Key_Down:
|
||||
# Move to next visible item (skip collapsed children)
|
||||
next_item = self.get_next_visible_item(current)
|
||||
if next_item:
|
||||
self.setCurrentItem(next_item)
|
||||
return
|
||||
|
||||
elif key == Qt.Key_Up:
|
||||
# Move to previous visible item
|
||||
prev_item = self.get_previous_visible_item(current)
|
||||
if prev_item:
|
||||
self.setCurrentItem(prev_item)
|
||||
return
|
||||
|
||||
elif key == Qt.Key_PageDown:
|
||||
# Jump down by page_step items
|
||||
target = current
|
||||
for _ in range(self.page_step):
|
||||
next_item = self.get_next_visible_item(target)
|
||||
if next_item:
|
||||
target = next_item
|
||||
else:
|
||||
break
|
||||
self.setCurrentItem(target)
|
||||
return
|
||||
|
||||
elif key == Qt.Key_PageUp:
|
||||
# Jump up by page_step items
|
||||
target = current
|
||||
for _ in range(self.page_step):
|
||||
prev_item = self.get_previous_visible_item(target)
|
||||
if prev_item:
|
||||
target = prev_item
|
||||
else:
|
||||
break
|
||||
self.setCurrentItem(target)
|
||||
return
|
||||
|
||||
elif key == Qt.Key_Home:
|
||||
# Go to first item
|
||||
first_item = self.topLevelItem(0)
|
||||
if first_item:
|
||||
self.setCurrentItem(first_item)
|
||||
return
|
||||
|
||||
elif key == Qt.Key_End:
|
||||
# Go to last visible item
|
||||
last_item = self.get_last_visible_item()
|
||||
if last_item:
|
||||
self.setCurrentItem(last_item)
|
||||
return
|
||||
|
||||
# Only fall back to default behavior if we didn't handle the key
|
||||
if key not in [Qt.Key_Left, Qt.Key_Right, Qt.Key_Up, Qt.Key_Down,
|
||||
Qt.Key_PageUp, Qt.Key_PageDown, Qt.Key_Home, Qt.Key_End]:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def get_next_visible_item(self, item: QTreeWidgetItem) -> QTreeWidgetItem:
|
||||
"""Get the next visible item in the tree"""
|
||||
# If item has children and is expanded, go to first child
|
||||
if item.childCount() > 0 and item.isExpanded():
|
||||
return item.child(0)
|
||||
|
||||
# Otherwise, find next sibling or ancestor's sibling
|
||||
current = item
|
||||
while current:
|
||||
parent = current.parent()
|
||||
if parent:
|
||||
# Find next sibling
|
||||
index = parent.indexOfChild(current)
|
||||
if index + 1 < parent.childCount():
|
||||
return parent.child(index + 1)
|
||||
# No more siblings, go up to parent
|
||||
current = parent
|
||||
else:
|
||||
# Top-level item, find next top-level item
|
||||
index = self.indexOfTopLevelItem(current)
|
||||
if index + 1 < self.topLevelItemCount():
|
||||
return self.topLevelItem(index + 1)
|
||||
# No more items
|
||||
return None
|
||||
return None
|
||||
|
||||
def get_previous_visible_item(self, item: QTreeWidgetItem) -> QTreeWidgetItem:
|
||||
"""Get the previous visible item in the tree"""
|
||||
parent = item.parent()
|
||||
|
||||
if parent:
|
||||
# Find previous sibling
|
||||
index = parent.indexOfChild(item)
|
||||
if index > 0:
|
||||
# Get previous sibling and its last visible descendant
|
||||
prev_sibling = parent.child(index - 1)
|
||||
return self.get_last_visible_descendant(prev_sibling)
|
||||
else:
|
||||
# No previous sibling, go to parent
|
||||
return parent
|
||||
else:
|
||||
# Top-level item
|
||||
index = self.indexOfTopLevelItem(item)
|
||||
if index > 0:
|
||||
# Get previous top-level item and its last visible descendant
|
||||
prev_item = self.topLevelItem(index - 1)
|
||||
return self.get_last_visible_descendant(prev_item)
|
||||
return None
|
||||
|
||||
def get_last_visible_descendant(self, item: QTreeWidgetItem) -> QTreeWidgetItem:
|
||||
"""Get the last visible descendant of an item"""
|
||||
if item.childCount() > 0 and item.isExpanded():
|
||||
# Get the last child and its last visible descendant
|
||||
last_child = item.child(item.childCount() - 1)
|
||||
return self.get_last_visible_descendant(last_child)
|
||||
return item
|
||||
|
||||
def get_last_visible_item(self) -> QTreeWidgetItem:
|
||||
"""Get the last visible item in the tree"""
|
||||
if self.topLevelItemCount() == 0:
|
||||
return None
|
||||
last_top_level = self.topLevelItem(self.topLevelItemCount() - 1)
|
||||
return self.get_last_visible_descendant(last_top_level)
|
||||
|
||||
def on_item_expanded(self, item: QTreeWidgetItem):
|
||||
"""Handle item expansion"""
|
||||
self.announce_item_state(item, "expanded")
|
||||
|
||||
def on_item_collapsed(self, item: QTreeWidgetItem):
|
||||
"""Handle item collapse"""
|
||||
self.announce_item_state(item, "collapsed")
|
||||
|
||||
def announce_item_state(self, item: QTreeWidgetItem, state: str):
|
||||
"""Announce item state change for screen readers"""
|
||||
# Update accessible text to include state
|
||||
if item.childCount() > 0:
|
||||
original_text = item.text(0)
|
||||
child_count = item.childCount()
|
||||
accessible_text = f"{original_text} ({child_count} replies, {state})"
|
||||
item.setData(0, Qt.AccessibleTextRole, accessible_text)
|
||||
|
||||
# Emit signal for potential sound feedback
|
||||
self.item_state_changed.emit(item, state)
|
||||
|
||||
def get_item_description(self, item: QTreeWidgetItem) -> str:
|
||||
"""Get a full description of an item for screen readers"""
|
||||
if not item:
|
||||
return "No item selected"
|
||||
|
||||
text = item.text(0)
|
||||
|
||||
# Determine item type and context
|
||||
if item.parent() is None:
|
||||
# Top-level post
|
||||
if item.childCount() > 0:
|
||||
state = "expanded" if item.isExpanded() else "collapsed"
|
||||
return f"{text} ({item.childCount()} replies, {state})"
|
||||
else:
|
||||
return text
|
||||
else:
|
||||
# Reply
|
||||
depth = self.get_item_depth(item)
|
||||
if depth == 1:
|
||||
return f"Reply: {text}"
|
||||
else:
|
||||
return f"Reply level {depth}: {text}"
|
||||
|
||||
def get_item_depth(self, item: QTreeWidgetItem) -> int:
|
||||
"""Get the nesting depth of an item"""
|
||||
depth = 0
|
||||
current = item
|
||||
while current.parent():
|
||||
depth += 1
|
||||
current = current.parent()
|
||||
return depth
|
1
src/activitypub/__init__.py
Normal file
1
src/activitypub/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# ActivityPub client implementation
|
232
src/activitypub/client.py
Normal file
232
src/activitypub/client.py
Normal file
@ -0,0 +1,232 @@
|
||||
"""
|
||||
ActivityPub client for communicating with fediverse servers
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
from typing import Dict, List, Optional, Any
|
||||
from urllib.parse import urljoin
|
||||
from datetime import datetime
|
||||
|
||||
from models.post import Post
|
||||
from models.user import User
|
||||
|
||||
|
||||
class ActivityPubClient:
|
||||
"""Main ActivityPub client for fediverse communication"""
|
||||
|
||||
def __init__(self, instance_url: str, access_token: Optional[str] = None):
|
||||
self.instance_url = instance_url.rstrip('/')
|
||||
self.access_token = access_token
|
||||
self.session = requests.Session()
|
||||
|
||||
# Set up headers
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Bifrost/1.0.0 (Accessible Fediverse Client)',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
|
||||
if access_token:
|
||||
self.session.headers['Authorization'] = f'Bearer {access_token}'
|
||||
|
||||
def _make_request(self, method: str, endpoint: str, params: Optional[Dict] = None,
|
||||
data: Optional[Dict] = None, files: Optional[Dict] = None) -> Dict:
|
||||
"""Make an authenticated request to the API"""
|
||||
url = urljoin(self.instance_url, endpoint)
|
||||
|
||||
try:
|
||||
if method.upper() == 'GET':
|
||||
response = self.session.get(url, params=params, timeout=30)
|
||||
elif method.upper() == 'POST':
|
||||
if files:
|
||||
# For file uploads, don't set Content-Type header
|
||||
headers = {k: v for k, v in self.session.headers.items() if k != 'Content-Type'}
|
||||
response = self.session.post(url, data=data, files=files, headers=headers, timeout=30)
|
||||
else:
|
||||
response = self.session.post(url, json=data, timeout=30)
|
||||
elif method.upper() == 'PUT':
|
||||
response = self.session.put(url, json=data, timeout=30)
|
||||
elif method.upper() == 'DELETE':
|
||||
response = self.session.delete(url, timeout=30)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
# Handle different success responses
|
||||
if response.status_code in [200, 201, 202]:
|
||||
if response.content:
|
||||
try:
|
||||
return response.json()
|
||||
except json.JSONDecodeError:
|
||||
# Some endpoints might return non-JSON on success
|
||||
return {"success": True, "status_code": response.status_code}
|
||||
return {"success": True, "status_code": response.status_code}
|
||||
|
||||
return {}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise Exception(f"API request failed: {e}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise Exception(f"Invalid JSON response: {e}")
|
||||
|
||||
def verify_credentials(self) -> Dict:
|
||||
"""Verify account credentials"""
|
||||
return self._make_request('GET', '/api/v1/accounts/verify_credentials')
|
||||
|
||||
def get_timeline(self, timeline_type: str = 'home', limit: int = 40,
|
||||
max_id: Optional[str] = None, since_id: Optional[str] = None) -> List[Dict]:
|
||||
"""Get timeline posts"""
|
||||
# Map timeline types to correct endpoints
|
||||
if timeline_type == 'local':
|
||||
endpoint = '/api/v1/timelines/public'
|
||||
params = {'limit': limit, 'local': 'true'}
|
||||
elif timeline_type == 'federated':
|
||||
endpoint = '/api/v1/timelines/public'
|
||||
params = {'limit': limit, 'local': 'false'}
|
||||
else:
|
||||
# home timeline
|
||||
endpoint = f'/api/v1/timelines/{timeline_type}'
|
||||
params = {'limit': limit}
|
||||
|
||||
if max_id:
|
||||
params['max_id'] = max_id
|
||||
if since_id:
|
||||
params['since_id'] = since_id
|
||||
|
||||
return self._make_request('GET', endpoint, params=params)
|
||||
|
||||
def get_status_context(self, status_id: str) -> Dict:
|
||||
"""Get context (replies/ancestors) for a status"""
|
||||
endpoint = f'/api/v1/statuses/{status_id}/context'
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def post_status(self, content: str, visibility: str = 'public',
|
||||
content_warning: Optional[str] = None,
|
||||
in_reply_to_id: Optional[str] = None,
|
||||
media_ids: Optional[List[str]] = None,
|
||||
content_type: str = 'text/plain') -> Dict:
|
||||
"""Post a new status"""
|
||||
data = {
|
||||
'status': content,
|
||||
'visibility': visibility
|
||||
}
|
||||
|
||||
# Add content type for instances that support it (Pleroma, GoToSocial)
|
||||
if content_type == 'text/markdown':
|
||||
data['content_type'] = 'text/markdown'
|
||||
|
||||
if content_warning:
|
||||
data['spoiler_text'] = content_warning
|
||||
if in_reply_to_id:
|
||||
data['in_reply_to_id'] = in_reply_to_id
|
||||
if media_ids:
|
||||
data['media_ids'] = media_ids
|
||||
|
||||
return self._make_request('POST', '/api/v1/statuses', data=data)
|
||||
|
||||
def delete_status(self, status_id: str) -> Dict:
|
||||
"""Delete a status"""
|
||||
endpoint = f'/api/v1/statuses/{status_id}'
|
||||
return self._make_request('DELETE', endpoint)
|
||||
|
||||
def favourite_status(self, status_id: str) -> Dict:
|
||||
"""Favourite a status"""
|
||||
endpoint = f'/api/v1/statuses/{status_id}/favourite'
|
||||
return self._make_request('POST', endpoint)
|
||||
|
||||
def unfavourite_status(self, status_id: str) -> Dict:
|
||||
"""Unfavourite a status"""
|
||||
endpoint = f'/api/v1/statuses/{status_id}/unfavourite'
|
||||
return self._make_request('POST', endpoint)
|
||||
|
||||
def reblog_status(self, status_id: str) -> Dict:
|
||||
"""Reblog/boost a status"""
|
||||
endpoint = f'/api/v1/statuses/{status_id}/reblog'
|
||||
return self._make_request('POST', endpoint)
|
||||
|
||||
def unreblog_status(self, status_id: str) -> Dict:
|
||||
"""Unreblog/unboost a status"""
|
||||
endpoint = f'/api/v1/statuses/{status_id}/unreblog'
|
||||
return self._make_request('POST', endpoint)
|
||||
|
||||
def get_notifications(self, limit: int = 20, max_id: Optional[str] = None,
|
||||
types: Optional[List[str]] = None) -> List[Dict]:
|
||||
"""Get notifications"""
|
||||
params = {'limit': limit}
|
||||
|
||||
if max_id:
|
||||
params['max_id'] = max_id
|
||||
if types:
|
||||
params['types[]'] = types
|
||||
|
||||
return self._make_request('GET', '/api/v1/notifications', params=params)
|
||||
|
||||
def get_account(self, account_id: str) -> Dict:
|
||||
"""Get account information"""
|
||||
endpoint = f'/api/v1/accounts/{account_id}'
|
||||
return self._make_request('GET', endpoint)
|
||||
|
||||
def follow_account(self, account_id: str) -> Dict:
|
||||
"""Follow an account"""
|
||||
endpoint = f'/api/v1/accounts/{account_id}/follow'
|
||||
return self._make_request('POST', endpoint)
|
||||
|
||||
def unfollow_account(self, account_id: str) -> Dict:
|
||||
"""Unfollow an account"""
|
||||
endpoint = f'/api/v1/accounts/{account_id}/unfollow'
|
||||
return self._make_request('POST', endpoint)
|
||||
|
||||
def search(self, query: str, account_id: Optional[str] = None,
|
||||
max_id: Optional[str] = None, min_id: Optional[str] = None,
|
||||
type_filter: Optional[str] = None, limit: int = 20) -> Dict:
|
||||
"""Search for content"""
|
||||
params = {
|
||||
'q': query,
|
||||
'limit': limit
|
||||
}
|
||||
|
||||
if account_id:
|
||||
params['account_id'] = account_id
|
||||
if max_id:
|
||||
params['max_id'] = max_id
|
||||
if min_id:
|
||||
params['min_id'] = min_id
|
||||
if type_filter:
|
||||
params['type'] = type_filter
|
||||
|
||||
return self._make_request('GET', '/api/v2/search', params=params)
|
||||
|
||||
def upload_media(self, file_path: str, description: Optional[str] = None) -> Dict:
|
||||
"""Upload a media file"""
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': f}
|
||||
data = {}
|
||||
if description:
|
||||
data['description'] = description
|
||||
|
||||
return self._make_request('POST', '/api/v1/media', data=data, files=files)
|
||||
|
||||
def get_instance_info(self) -> Dict:
|
||||
"""Get instance information"""
|
||||
return self._make_request('GET', '/api/v1/instance')
|
||||
|
||||
def get_custom_emojis(self) -> List[Dict]:
|
||||
"""Get custom emojis for this instance"""
|
||||
return self._make_request('GET', '/api/v1/custom_emojis')
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when authentication fails"""
|
||||
pass
|
||||
|
||||
|
||||
class RateLimitError(Exception):
|
||||
"""Raised when rate limit is exceeded"""
|
||||
pass
|
||||
|
||||
|
||||
class ServerError(Exception):
|
||||
"""Raised when server returns an error"""
|
||||
pass
|
214
src/activitypub/oauth.py
Normal file
214
src/activitypub/oauth.py
Normal file
@ -0,0 +1,214 @@
|
||||
"""
|
||||
OAuth2 authentication flow for ActivityPub instances
|
||||
"""
|
||||
|
||||
import requests
|
||||
import secrets
|
||||
import webbrowser
|
||||
import urllib.parse
|
||||
from typing import Dict, Optional, Tuple
|
||||
from urllib.parse import urljoin, parse_qs, urlparse
|
||||
|
||||
from PySide6.QtCore import QObject, Signal, QTimer
|
||||
from PySide6.QtWidgets import QMessageBox, QInputDialog, QApplication
|
||||
from PySide6.QtGui import QClipboard
|
||||
|
||||
|
||||
class OAuth2Handler(QObject):
|
||||
"""Handles OAuth2 authentication flow for fediverse instances"""
|
||||
|
||||
authentication_complete = Signal(dict) # Emitted with token data
|
||||
authentication_failed = Signal(str) # Emitted with error message
|
||||
|
||||
def __init__(self, instance_url: str):
|
||||
super().__init__()
|
||||
self.instance_url = instance_url.rstrip('/')
|
||||
self.client_name = "Bifrost"
|
||||
self.redirect_uri = "urn:ietf:wg:oauth:2.0:oob" # Out-of-band flow
|
||||
self.scopes = "read write follow push"
|
||||
|
||||
self.client_id = None
|
||||
self.client_secret = None
|
||||
self.auth_code = None
|
||||
|
||||
def start_authentication(self) -> bool:
|
||||
"""Start the OAuth2 authentication process"""
|
||||
try:
|
||||
# Step 1: Register application
|
||||
if not self._register_application():
|
||||
return False
|
||||
|
||||
# Step 2: Get authorization URL and open browser
|
||||
auth_url = self._get_authorization_url()
|
||||
if not auth_url:
|
||||
return False
|
||||
|
||||
# Open browser or provide manual link
|
||||
self._open_authorization_url(auth_url)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.authentication_failed.emit(f"Authentication setup failed: {str(e)}")
|
||||
return False
|
||||
|
||||
def _register_application(self) -> bool:
|
||||
"""Register the application with the instance"""
|
||||
url = f"{self.instance_url}/api/v1/apps"
|
||||
|
||||
data = {
|
||||
'client_name': self.client_name,
|
||||
'redirect_uris': self.redirect_uri,
|
||||
'scopes': self.scopes,
|
||||
'website': 'https://github.com/stormux/bifrost'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, data=data, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
app_data = response.json()
|
||||
self.client_id = app_data['client_id']
|
||||
self.client_secret = app_data['client_secret']
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.authentication_failed.emit(f"Failed to register application: {str(e)}")
|
||||
return False
|
||||
except KeyError as e:
|
||||
self.authentication_failed.emit(f"Invalid response from server: missing {str(e)}")
|
||||
return False
|
||||
|
||||
def _get_authorization_url(self) -> Optional[str]:
|
||||
"""Generate the authorization URL"""
|
||||
if not self.client_id:
|
||||
return None
|
||||
|
||||
params = {
|
||||
'client_id': self.client_id,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'response_type': 'code',
|
||||
'scope': self.scopes
|
||||
}
|
||||
|
||||
query_string = urllib.parse.urlencode(params)
|
||||
return f"{self.instance_url}/oauth/authorize?{query_string}"
|
||||
|
||||
def _open_authorization_url(self, auth_url: str):
|
||||
"""Open authorization URL in browser and prompt for code"""
|
||||
try:
|
||||
# Try to open in browser
|
||||
webbrowser.open(auth_url)
|
||||
browser_opened = True
|
||||
except Exception:
|
||||
browser_opened = False
|
||||
|
||||
# Show dialog with instructions
|
||||
if browser_opened:
|
||||
message = (
|
||||
"Your web browser should open to authorize Bifrost.\n\n"
|
||||
"After authorizing, you'll get an authorization code.\n"
|
||||
"Copy that code and paste it in the next dialog.\n\n"
|
||||
"If the browser didn't open, you can manually visit:\n"
|
||||
f"{auth_url}"
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
"Please visit this URL in your web browser to authorize Bifrost:\n\n"
|
||||
f"{auth_url}\n\n"
|
||||
"After authorizing, you'll get an authorization code.\n"
|
||||
"Copy that code and paste it in the next dialog."
|
||||
)
|
||||
|
||||
# Copy URL to clipboard for convenience
|
||||
clipboard = QApplication.clipboard()
|
||||
clipboard.setText(auth_url)
|
||||
|
||||
QMessageBox.information(
|
||||
None,
|
||||
"Authorization Required",
|
||||
message + "\n\n(The URL has been copied to your clipboard)"
|
||||
)
|
||||
|
||||
# Prompt for authorization code
|
||||
self._prompt_for_auth_code()
|
||||
|
||||
def _prompt_for_auth_code(self):
|
||||
"""Prompt user to enter the authorization code"""
|
||||
code, ok = QInputDialog.getText(
|
||||
None,
|
||||
"Enter Authorization Code",
|
||||
"Please paste the authorization code you received:",
|
||||
text=""
|
||||
)
|
||||
|
||||
if ok and code.strip():
|
||||
self.auth_code = code.strip()
|
||||
self._exchange_code_for_token()
|
||||
else:
|
||||
self.authentication_failed.emit("Authorization cancelled by user")
|
||||
|
||||
def _exchange_code_for_token(self):
|
||||
"""Exchange authorization code for access token"""
|
||||
if not self.auth_code:
|
||||
return
|
||||
|
||||
url = f"{self.instance_url}/oauth/token"
|
||||
|
||||
data = {
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'redirect_uri': self.redirect_uri,
|
||||
'grant_type': 'authorization_code',
|
||||
'code': self.auth_code,
|
||||
'scope': self.scopes
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, data=data, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
|
||||
# Get user account info
|
||||
account_info = self._get_account_info(token_data['access_token'])
|
||||
if account_info:
|
||||
# Combine token and account data
|
||||
result = {
|
||||
'instance_url': self.instance_url,
|
||||
'access_token': token_data['access_token'],
|
||||
'token_type': token_data.get('token_type', 'Bearer'),
|
||||
'scope': token_data.get('scope', self.scopes),
|
||||
'username': account_info.get('username', ''),
|
||||
'display_name': account_info.get('display_name', ''),
|
||||
'account_id': account_info.get('id', ''),
|
||||
'avatar_url': account_info.get('avatar', ''),
|
||||
'created_at': account_info.get('created_at', '')
|
||||
}
|
||||
|
||||
self.authentication_complete.emit(result)
|
||||
else:
|
||||
self.authentication_failed.emit("Failed to get account information")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.authentication_failed.emit(f"Failed to exchange code for token: {str(e)}")
|
||||
except KeyError as e:
|
||||
self.authentication_failed.emit(f"Invalid token response: missing {str(e)}")
|
||||
|
||||
def _get_account_info(self, access_token: str) -> Optional[Dict]:
|
||||
"""Get account information using the access token"""
|
||||
url = f"{self.instance_url}/api/v1/accounts/verify_credentials"
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Failed to get account info: {e}")
|
||||
return None
|
1
src/audio/__init__.py
Normal file
1
src/audio/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Audio and sound management
|
398
src/audio/sound_manager.py
Normal file
398
src/audio/sound_manager.py
Normal file
@ -0,0 +1,398 @@
|
||||
"""
|
||||
Sound manager for audio notifications
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import platform
|
||||
import json
|
||||
import wave
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from threading import Thread
|
||||
|
||||
try:
|
||||
import simpleaudio as sa
|
||||
SIMPLEAUDIO_AVAILABLE = True
|
||||
except ImportError:
|
||||
SIMPLEAUDIO_AVAILABLE = False
|
||||
sa = None
|
||||
|
||||
from config.settings import SettingsManager
|
||||
|
||||
|
||||
class SoundPack:
|
||||
"""Represents a sound pack with metadata"""
|
||||
|
||||
def __init__(self, pack_dir: Path):
|
||||
self.pack_dir = pack_dir
|
||||
self.pack_file = pack_dir / "pack.json"
|
||||
self.metadata = self._load_metadata()
|
||||
|
||||
def _load_metadata(self) -> Dict:
|
||||
"""Load pack metadata from pack.json"""
|
||||
if not self.pack_file.exists():
|
||||
return {
|
||||
"name": self.pack_dir.name.title(),
|
||||
"description": f"Sound pack: {self.pack_dir.name}",
|
||||
"author": "Unknown",
|
||||
"version": "1.0",
|
||||
"sounds": {}
|
||||
}
|
||||
|
||||
try:
|
||||
with open(self.pack_file, 'r') as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return {
|
||||
"name": self.pack_dir.name.title(),
|
||||
"description": "Corrupted sound pack",
|
||||
"author": "Unknown",
|
||||
"version": "1.0",
|
||||
"sounds": {}
|
||||
}
|
||||
|
||||
def get_sound_path(self, event_type: str) -> Optional[Path]:
|
||||
"""Get the file path for a specific sound event"""
|
||||
sound_file = self.metadata.get("sounds", {}).get(event_type)
|
||||
if not sound_file:
|
||||
return None
|
||||
|
||||
sound_path = self.pack_dir / sound_file
|
||||
if sound_path.exists():
|
||||
return sound_path
|
||||
return None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.metadata.get("name", self.pack_dir.name.title())
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self.metadata.get("description", "")
|
||||
|
||||
@property
|
||||
def author(self) -> str:
|
||||
return self.metadata.get("author", "Unknown")
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return self.metadata.get("version", "1.0")
|
||||
|
||||
|
||||
class SoundManager:
|
||||
"""Manages audio notifications for Bifrost"""
|
||||
|
||||
# Standard sound events
|
||||
SOUND_EVENTS = [
|
||||
"private_message",
|
||||
"mention",
|
||||
"boost",
|
||||
"reply",
|
||||
"post_sent",
|
||||
"timeline_update",
|
||||
"notification",
|
||||
"startup",
|
||||
"shutdown",
|
||||
"success",
|
||||
"error",
|
||||
"expand",
|
||||
"collapse"
|
||||
]
|
||||
|
||||
def __init__(self, settings: SettingsManager):
|
||||
self.settings = settings
|
||||
self.system = platform.system()
|
||||
self.sound_packs = {}
|
||||
self.current_pack = None
|
||||
self.discover_sound_packs()
|
||||
self.load_current_pack()
|
||||
|
||||
def reload_settings(self):
|
||||
"""Reload settings and sound pack"""
|
||||
self.discover_sound_packs()
|
||||
self.load_current_pack()
|
||||
|
||||
def discover_sound_packs(self):
|
||||
"""Discover available sound packs"""
|
||||
sounds_dir = self.settings.get_sounds_dir()
|
||||
sounds_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create default pack if it doesn't exist
|
||||
self.create_default_pack()
|
||||
|
||||
# Scan for sound packs
|
||||
self.sound_packs = {}
|
||||
for pack_dir in sounds_dir.iterdir():
|
||||
if pack_dir.is_dir():
|
||||
pack = SoundPack(pack_dir)
|
||||
self.sound_packs[pack_dir.name] = pack
|
||||
|
||||
def create_default_pack(self):
|
||||
"""Create default sound pack if it doesn't exist"""
|
||||
default_dir = self.settings.get_sounds_dir() / "default"
|
||||
default_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
pack_file = default_dir / "pack.json"
|
||||
if not pack_file.exists():
|
||||
pack_data = {
|
||||
"name": "Default",
|
||||
"description": "Default system sounds",
|
||||
"author": "Bifrost",
|
||||
"version": "1.0",
|
||||
"sounds": {
|
||||
"private_message": "private_message.wav",
|
||||
"mention": "mention.wav",
|
||||
"boost": "boost.wav",
|
||||
"reply": "reply.wav",
|
||||
"post_sent": "post_sent.wav",
|
||||
"timeline_update": "timeline_update.wav",
|
||||
"notification": "notification.wav",
|
||||
"startup": "startup.wav",
|
||||
"shutdown": "shutdown.wav",
|
||||
"success": "success.wav",
|
||||
"error": "error.wav",
|
||||
"expand": "expand.wav",
|
||||
"collapse": "collapse.wav"
|
||||
}
|
||||
}
|
||||
|
||||
with open(pack_file, 'w') as f:
|
||||
json.dump(pack_data, f, indent=2)
|
||||
|
||||
def load_current_pack(self):
|
||||
"""Load the currently selected sound pack"""
|
||||
current_pack_name = self.settings.get('audio', 'sound_pack', 'default')
|
||||
|
||||
if current_pack_name in self.sound_packs:
|
||||
self.current_pack = self.sound_packs[current_pack_name]
|
||||
else:
|
||||
# Fall back to default pack
|
||||
self.current_pack = self.sound_packs.get('default')
|
||||
if self.current_pack:
|
||||
self.settings.set('audio', 'sound_pack', 'default')
|
||||
self.settings.save_settings()
|
||||
|
||||
def set_current_pack(self, pack_name: str) -> bool:
|
||||
"""Set the current sound pack"""
|
||||
if pack_name in self.sound_packs:
|
||||
self.current_pack = self.sound_packs[pack_name]
|
||||
self.settings.set('audio', 'sound_pack', pack_name)
|
||||
self.settings.save_settings()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_available_packs(self) -> List[str]:
|
||||
"""Get list of available sound pack names"""
|
||||
return list(self.sound_packs.keys())
|
||||
|
||||
def get_pack_info(self, pack_name: str) -> Optional[Dict]:
|
||||
"""Get information about a sound pack"""
|
||||
pack = self.sound_packs.get(pack_name)
|
||||
if pack:
|
||||
return {
|
||||
"name": pack.name,
|
||||
"description": pack.description,
|
||||
"author": pack.author,
|
||||
"version": pack.version
|
||||
}
|
||||
return None
|
||||
|
||||
def is_sound_enabled(self, event_type: str) -> bool:
|
||||
"""Check if sound is enabled for an event type"""
|
||||
if not self.settings.get_bool('sounds', 'enabled', True):
|
||||
return False
|
||||
return self.settings.get_bool('sounds', f'{event_type}_enabled', True)
|
||||
|
||||
def get_sound_volume(self, event_type: str) -> float:
|
||||
"""Get volume setting for an event type"""
|
||||
return self.settings.get_float('sounds', f'{event_type}_volume', 0.8)
|
||||
|
||||
def play_sound(self, file_path: Path, volume_multiplier: float = 1.0):
|
||||
"""Play a sound file using simpleaudio or platform-specific fallback"""
|
||||
if not file_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
# Calculate final volume
|
||||
master_vol = int(self.settings.get('audio', 'master_volume', 100) or 100) / 100.0
|
||||
final_volume = master_vol * volume_multiplier
|
||||
|
||||
if SIMPLEAUDIO_AVAILABLE:
|
||||
# Use simpleaudio directly (no threading needed for Python library)
|
||||
self._play_with_simpleaudio(file_path, final_volume)
|
||||
else:
|
||||
# Use subprocess in background thread for external commands
|
||||
def _play_async():
|
||||
self._play_with_subprocess(file_path, final_volume)
|
||||
thread = Thread(target=_play_async, daemon=True)
|
||||
thread.start()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Audio playback failed: {e}")
|
||||
|
||||
def _play_with_simpleaudio(self, file_path: Path, volume: float):
|
||||
"""Play sound using simpleaudio library"""
|
||||
try:
|
||||
# Read the audio file
|
||||
if str(file_path).lower().endswith('.wav'):
|
||||
wave_obj = sa.WaveObject.from_wave_file(str(file_path))
|
||||
else:
|
||||
# For non-WAV files, we need to convert or use subprocess fallback
|
||||
self._play_with_subprocess(file_path, volume)
|
||||
return
|
||||
|
||||
# Apply volume by modifying the audio data
|
||||
if volume != 1.0:
|
||||
# Get audio data
|
||||
audio_data = wave_obj.audio_data
|
||||
|
||||
# Convert to numpy array for volume adjustment
|
||||
if wave_obj.bytes_per_sample == 1:
|
||||
# 8-bit audio
|
||||
audio_array = np.frombuffer(audio_data, dtype=np.uint8)
|
||||
# Convert to signed for math
|
||||
audio_array = audio_array.astype(np.int16) - 128
|
||||
audio_array = (audio_array * volume).astype(np.int16)
|
||||
# Convert back to unsigned
|
||||
audio_array = (audio_array + 128).clip(0, 255).astype(np.uint8)
|
||||
elif wave_obj.bytes_per_sample == 2:
|
||||
# 16-bit audio
|
||||
audio_array = np.frombuffer(audio_data, dtype=np.int16)
|
||||
audio_array = (audio_array * volume).clip(-32768, 32767).astype(np.int16)
|
||||
else:
|
||||
# Unsupported format, use original data
|
||||
audio_array = audio_data
|
||||
|
||||
# Create new wave object with modified audio
|
||||
wave_obj = sa.WaveObject(
|
||||
audio_array.tobytes(),
|
||||
wave_obj.num_channels,
|
||||
wave_obj.bytes_per_sample,
|
||||
wave_obj.sample_rate
|
||||
)
|
||||
|
||||
# Play the sound asynchronously (simpleaudio handles this)
|
||||
play_obj = wave_obj.play()
|
||||
# simpleaudio plays in background, no need to wait
|
||||
|
||||
except Exception as e:
|
||||
print(f"simpleaudio playback failed: {e}")
|
||||
# Fall back to subprocess method
|
||||
self._play_with_subprocess(file_path, volume)
|
||||
|
||||
def _play_with_subprocess(self, file_path: Path, volume: float):
|
||||
"""Play sound using platform-specific subprocess commands"""
|
||||
try:
|
||||
if self.system == "Linux":
|
||||
# Try to use play with volume control first
|
||||
play_result = subprocess.run(["which", "play"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
|
||||
if play_result.returncode == 0:
|
||||
# Use play with volume control
|
||||
result = subprocess.run(["play", "-v", str(volume), str(file_path)],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
if result.returncode != 0:
|
||||
print(f"Play command failed")
|
||||
else:
|
||||
# Fall back to aplay (no volume control)
|
||||
subprocess.run(["aplay", str(file_path)],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
elif self.system == "Windows":
|
||||
subprocess.run(["powershell", "-c",
|
||||
f"(New-Object Media.SoundPlayer '{file_path}').PlaySync()"],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
elif self.system == "Darwin": # macOS
|
||||
subprocess.run(["afplay", str(file_path)],
|
||||
check=False,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
except Exception as e:
|
||||
print(f"Subprocess audio playback failed: {e}")
|
||||
|
||||
def play_event(self, event_type: str):
|
||||
"""Play sound for a specific event type"""
|
||||
if not self.is_sound_enabled(event_type):
|
||||
return
|
||||
|
||||
if not self.current_pack:
|
||||
return
|
||||
|
||||
sound_path = self.current_pack.get_sound_path(event_type)
|
||||
if sound_path:
|
||||
# Get volume settings for this event type
|
||||
notification_vol = int(self.settings.get('audio', 'notification_volume', 100) or 100) / 100.0
|
||||
volume_multiplier = notification_vol # Use system volume by default
|
||||
self.play_sound(sound_path, volume_multiplier)
|
||||
else:
|
||||
# Try fallback to default pack
|
||||
default_pack = self.sound_packs.get('default')
|
||||
if default_pack and default_pack != self.current_pack:
|
||||
fallback_path = default_pack.get_sound_path(event_type)
|
||||
if fallback_path:
|
||||
self.play_sound(fallback_path)
|
||||
|
||||
def play_private_message(self):
|
||||
"""Play private message sound"""
|
||||
self.play_event("private_message")
|
||||
|
||||
def play_mention(self):
|
||||
"""Play mention sound"""
|
||||
self.play_event("mention")
|
||||
|
||||
def play_boost(self):
|
||||
"""Play boost sound"""
|
||||
self.play_event("boost")
|
||||
|
||||
def play_reply(self):
|
||||
"""Play reply sound"""
|
||||
self.play_event("reply")
|
||||
|
||||
def play_post_sent(self):
|
||||
"""Play post sent sound"""
|
||||
self.play_event("post_sent")
|
||||
|
||||
def play_timeline_update(self):
|
||||
"""Play timeline update sound"""
|
||||
self.play_event("timeline_update")
|
||||
|
||||
def play_notification(self):
|
||||
"""Play general notification sound"""
|
||||
self.play_event("notification")
|
||||
|
||||
def play_startup(self):
|
||||
"""Play application startup sound"""
|
||||
print("play_startup called")
|
||||
self.play_event("startup")
|
||||
|
||||
def play_shutdown(self):
|
||||
"""Play application shutdown sound"""
|
||||
self.play_event("shutdown")
|
||||
|
||||
def play_success(self):
|
||||
"""Play success feedback sound"""
|
||||
self.play_event("success")
|
||||
|
||||
def play_error(self):
|
||||
"""Play error feedback sound"""
|
||||
self.play_event("error")
|
||||
|
||||
def play_expand(self):
|
||||
"""Play thread expand sound"""
|
||||
self.play_event("expand")
|
||||
|
||||
def play_collapse(self):
|
||||
"""Play thread collapse sound"""
|
||||
self.play_event("collapse")
|
||||
|
||||
def test_sound(self, event_type: str):
|
||||
"""Test play a specific sound type"""
|
||||
self.play_event(event_type)
|
1
src/config/__init__.py
Normal file
1
src/config/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Configuration management
|
171
src/config/accounts.py
Normal file
171
src/config/accounts.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""
|
||||
Account management for multiple fediverse accounts
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
from config.settings import SettingsManager
|
||||
|
||||
|
||||
@dataclass
|
||||
class Account:
|
||||
"""Represents a fediverse account"""
|
||||
instance_url: str
|
||||
username: str
|
||||
display_name: str
|
||||
access_token: str
|
||||
account_id: str
|
||||
avatar_url: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
def get_full_username(self) -> str:
|
||||
"""Get full username in format username@domain"""
|
||||
domain = self.instance_url.replace('https://', '').replace('http://', '')
|
||||
return f"{self.username}@{domain}"
|
||||
|
||||
def get_display_text(self) -> str:
|
||||
"""Get text for display in UI"""
|
||||
if self.display_name and self.display_name != self.username:
|
||||
return f"{self.display_name} ({self.get_full_username()})"
|
||||
return self.get_full_username()
|
||||
|
||||
|
||||
class AccountManager:
|
||||
"""Manages multiple fediverse accounts"""
|
||||
|
||||
def __init__(self, settings: SettingsManager):
|
||||
self.settings = settings
|
||||
self.accounts_file = settings.config_dir / "accounts.json"
|
||||
self.accounts: Dict[str, Account] = {}
|
||||
self.active_account_id: Optional[str] = None
|
||||
self.load_accounts()
|
||||
|
||||
def load_accounts(self):
|
||||
"""Load accounts from file"""
|
||||
if not self.accounts_file.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.accounts_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Load accounts
|
||||
for account_id, account_data in data.get('accounts', {}).items():
|
||||
account = Account(**account_data)
|
||||
self.accounts[account_id] = account
|
||||
|
||||
# Load active account
|
||||
self.active_account_id = data.get('active_account_id')
|
||||
|
||||
# Validate active account exists
|
||||
if self.active_account_id and self.active_account_id not in self.accounts:
|
||||
self.active_account_id = None
|
||||
|
||||
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
||||
print(f"Error loading accounts: {e}")
|
||||
self.accounts = {}
|
||||
self.active_account_id = None
|
||||
|
||||
def save_accounts(self):
|
||||
"""Save accounts to file"""
|
||||
data = {
|
||||
'accounts': {
|
||||
account_id: asdict(account)
|
||||
for account_id, account in self.accounts.items()
|
||||
},
|
||||
'active_account_id': self.active_account_id
|
||||
}
|
||||
|
||||
try:
|
||||
with open(self.accounts_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Error saving accounts: {e}")
|
||||
|
||||
def add_account(self, account_data: Dict) -> str:
|
||||
"""Add a new account and return its ID"""
|
||||
# Generate unique ID
|
||||
account_id = f"{account_data['username']}@{account_data['instance_url']}"
|
||||
|
||||
# Create account object
|
||||
account = Account(
|
||||
instance_url=account_data['instance_url'],
|
||||
username=account_data['username'],
|
||||
display_name=account_data.get('display_name', account_data['username']),
|
||||
access_token=account_data['access_token'],
|
||||
account_id=account_data.get('account_id', '1'),
|
||||
avatar_url=account_data.get('avatar_url'),
|
||||
created_at=account_data.get('created_at')
|
||||
)
|
||||
|
||||
self.accounts[account_id] = account
|
||||
|
||||
# Set as active if it's the first account
|
||||
if not self.active_account_id:
|
||||
self.active_account_id = account_id
|
||||
|
||||
self.save_accounts()
|
||||
return account_id
|
||||
|
||||
def remove_account(self, account_id: str) -> bool:
|
||||
"""Remove an account"""
|
||||
if account_id not in self.accounts:
|
||||
return False
|
||||
|
||||
del self.accounts[account_id]
|
||||
|
||||
# If this was the active account, switch to another or none
|
||||
if self.active_account_id == account_id:
|
||||
if self.accounts:
|
||||
self.active_account_id = next(iter(self.accounts))
|
||||
else:
|
||||
self.active_account_id = None
|
||||
|
||||
self.save_accounts()
|
||||
return True
|
||||
|
||||
def set_active_account(self, account_id: str) -> bool:
|
||||
"""Set the active account"""
|
||||
if account_id not in self.accounts:
|
||||
return False
|
||||
|
||||
self.active_account_id = account_id
|
||||
self.save_accounts()
|
||||
return True
|
||||
|
||||
def get_active_account(self) -> Optional[Account]:
|
||||
"""Get the currently active account"""
|
||||
if self.active_account_id and self.active_account_id in self.accounts:
|
||||
return self.accounts[self.active_account_id]
|
||||
return None
|
||||
|
||||
def get_all_accounts(self) -> List[Account]:
|
||||
"""Get all accounts"""
|
||||
return list(self.accounts.values())
|
||||
|
||||
def get_account_by_id(self, account_id: str) -> Optional[Account]:
|
||||
"""Get account by ID"""
|
||||
return self.accounts.get(account_id)
|
||||
|
||||
def has_accounts(self) -> bool:
|
||||
"""Check if any accounts are configured"""
|
||||
return len(self.accounts) > 0
|
||||
|
||||
def get_account_ids(self) -> List[str]:
|
||||
"""Get list of account IDs"""
|
||||
return list(self.accounts.keys())
|
||||
|
||||
def get_account_display_names(self) -> List[str]:
|
||||
"""Get list of account display names"""
|
||||
return [account.get_display_text() for account in self.accounts.values()]
|
||||
|
||||
def find_account_by_display_name(self, display_name: str) -> Optional[str]:
|
||||
"""Find account ID by display name"""
|
||||
for account_id, account in self.accounts.items():
|
||||
if account.get_display_text() == display_name:
|
||||
return account_id
|
||||
return None
|
145
src/config/settings.py
Normal file
145
src/config/settings.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""
|
||||
Settings management with XDG Base Directory specification compliance
|
||||
"""
|
||||
|
||||
import os
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class SettingsManager:
|
||||
"""Manages application settings with XDG compliance"""
|
||||
|
||||
def __init__(self):
|
||||
self.config_dir = self._get_config_dir()
|
||||
self.data_dir = self._get_data_dir()
|
||||
self.cache_dir = self._get_cache_dir()
|
||||
|
||||
# Ensure directories exist
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.config_file = self.config_dir / "bifrost.conf"
|
||||
self.config = configparser.ConfigParser()
|
||||
self.load_settings()
|
||||
|
||||
def _get_config_dir(self) -> Path:
|
||||
"""Get XDG config directory"""
|
||||
xdg_config = os.getenv('XDG_CONFIG_HOME')
|
||||
if xdg_config:
|
||||
return Path(xdg_config) / "bifrost"
|
||||
return Path.home() / ".config" / "bifrost"
|
||||
|
||||
def _get_data_dir(self) -> Path:
|
||||
"""Get XDG data directory"""
|
||||
xdg_data = os.getenv('XDG_DATA_HOME')
|
||||
if xdg_data:
|
||||
return Path(xdg_data) / "bifrost"
|
||||
return Path.home() / ".local" / "share" / "bifrost"
|
||||
|
||||
def _get_cache_dir(self) -> Path:
|
||||
"""Get XDG cache directory"""
|
||||
xdg_cache = os.getenv('XDG_CACHE_HOME')
|
||||
if xdg_cache:
|
||||
return Path(xdg_cache) / "bifrost"
|
||||
return Path.home() / ".cache" / "bifrost"
|
||||
|
||||
def load_settings(self):
|
||||
"""Load settings from config file"""
|
||||
if self.config_file.exists():
|
||||
self.config.read(self.config_file)
|
||||
else:
|
||||
self.create_default_config()
|
||||
|
||||
def create_default_config(self):
|
||||
"""Create default configuration"""
|
||||
# General settings
|
||||
self.config.add_section('general')
|
||||
self.config.set('general', 'instance_url', '')
|
||||
self.config.set('general', 'username', '')
|
||||
self.config.set('general', 'current_sound_pack', 'default')
|
||||
self.config.set('general', 'timeline_refresh_interval', '60')
|
||||
self.config.set('general', 'auto_refresh_enabled', 'true')
|
||||
|
||||
# Sound settings
|
||||
self.config.add_section('sounds')
|
||||
self.config.set('sounds', 'enabled', 'true')
|
||||
self.config.set('sounds', 'private_message_enabled', 'true')
|
||||
self.config.set('sounds', 'private_message_volume', '0.8')
|
||||
self.config.set('sounds', 'mention_enabled', 'true')
|
||||
self.config.set('sounds', 'mention_volume', '1.0')
|
||||
self.config.set('sounds', 'boost_enabled', 'true')
|
||||
self.config.set('sounds', 'boost_volume', '0.7')
|
||||
self.config.set('sounds', 'reply_enabled', 'true')
|
||||
self.config.set('sounds', 'reply_volume', '0.8')
|
||||
self.config.set('sounds', 'post_sent_enabled', 'true')
|
||||
self.config.set('sounds', 'post_sent_volume', '0.9')
|
||||
self.config.set('sounds', 'timeline_update_enabled', 'true')
|
||||
self.config.set('sounds', 'timeline_update_volume', '0.5')
|
||||
self.config.set('sounds', 'notification_enabled', 'true')
|
||||
self.config.set('sounds', 'notification_volume', '0.8')
|
||||
|
||||
# Accessibility settings
|
||||
self.config.add_section('accessibility')
|
||||
self.config.set('accessibility', 'announce_thread_state', 'true')
|
||||
self.config.set('accessibility', 'auto_expand_mentions', 'false')
|
||||
self.config.set('accessibility', 'keyboard_navigation_wrap', 'true')
|
||||
self.config.set('accessibility', 'focus_follows_mouse', 'false')
|
||||
|
||||
# Interface settings
|
||||
self.config.add_section('interface')
|
||||
self.config.set('interface', 'default_timeline', 'home')
|
||||
self.config.set('interface', 'show_timestamps', 'true')
|
||||
self.config.set('interface', 'compact_mode', 'false')
|
||||
|
||||
self.save_settings()
|
||||
|
||||
def save_settings(self):
|
||||
"""Save settings to config file"""
|
||||
with open(self.config_file, 'w') as f:
|
||||
self.config.write(f)
|
||||
|
||||
def get(self, section: str, key: str, fallback: Any = None) -> Optional[str]:
|
||||
"""Get a setting value"""
|
||||
try:
|
||||
return self.config.get(section, key)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError):
|
||||
return fallback
|
||||
|
||||
def get_bool(self, section: str, key: str, fallback: bool = False) -> bool:
|
||||
"""Get a boolean setting value"""
|
||||
try:
|
||||
return self.config.getboolean(section, key)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError, ValueError):
|
||||
return fallback
|
||||
|
||||
def get_int(self, section: str, key: str, fallback: int = 0) -> int:
|
||||
"""Get an integer setting value"""
|
||||
try:
|
||||
return self.config.getint(section, key)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError, ValueError):
|
||||
return fallback
|
||||
|
||||
def get_float(self, section: str, key: str, fallback: float = 0.0) -> float:
|
||||
"""Get a float setting value"""
|
||||
try:
|
||||
return self.config.getfloat(section, key)
|
||||
except (configparser.NoSectionError, configparser.NoOptionError, ValueError):
|
||||
return fallback
|
||||
|
||||
def set(self, section: str, key: str, value: Any):
|
||||
"""Set a setting value"""
|
||||
if not self.config.has_section(section):
|
||||
self.config.add_section(section)
|
||||
self.config.set(section, key, str(value))
|
||||
|
||||
def get_sounds_dir(self) -> Path:
|
||||
"""Get the sounds directory path"""
|
||||
return self.data_dir / "sounds"
|
||||
|
||||
def get_current_sound_pack_dir(self) -> Path:
|
||||
"""Get the current sound pack directory"""
|
||||
pack_name = self.get('general', 'current_sound_pack', 'default')
|
||||
return self.get_sounds_dir() / pack_name
|
418
src/main_window.py
Normal file
418
src/main_window.py
Normal file
@ -0,0 +1,418 @@
|
||||
"""
|
||||
Main application window for Bifrost
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QMenuBar, QStatusBar, QPushButton, QTabWidget
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QKeySequence, QAction, QTextCursor
|
||||
|
||||
from config.settings import SettingsManager
|
||||
from config.accounts import AccountManager
|
||||
from widgets.timeline_view import TimelineView
|
||||
from widgets.compose_dialog import ComposeDialog
|
||||
from widgets.login_dialog import LoginDialog
|
||||
from widgets.account_selector import AccountSelector
|
||||
from widgets.settings_dialog import SettingsDialog
|
||||
from activitypub.client import ActivityPubClient
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main Bifrost application window"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.settings = SettingsManager()
|
||||
self.account_manager = AccountManager(self.settings)
|
||||
self.setup_ui()
|
||||
self.setup_menus()
|
||||
self.setup_shortcuts()
|
||||
|
||||
# Check if we need to show login dialog
|
||||
if not self.account_manager.has_accounts():
|
||||
self.show_first_time_setup()
|
||||
|
||||
# Play startup sound
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.play_startup()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the user interface"""
|
||||
self.setWindowTitle("Bifrost - Fediverse Client")
|
||||
self.setMinimumSize(800, 600)
|
||||
|
||||
# Central widget
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
main_layout = QVBoxLayout(central_widget)
|
||||
|
||||
# Account selector
|
||||
self.account_selector = AccountSelector(self.account_manager)
|
||||
self.account_selector.account_changed.connect(self.on_account_changed)
|
||||
self.account_selector.add_account_requested.connect(self.show_login_dialog)
|
||||
main_layout.addWidget(self.account_selector)
|
||||
|
||||
# Timeline tabs
|
||||
self.timeline_tabs = QTabWidget()
|
||||
self.timeline_tabs.setAccessibleName("Timeline Selection")
|
||||
self.timeline_tabs.addTab(QWidget(), "Home")
|
||||
self.timeline_tabs.addTab(QWidget(), "Mentions")
|
||||
self.timeline_tabs.addTab(QWidget(), "Local")
|
||||
self.timeline_tabs.addTab(QWidget(), "Federated")
|
||||
self.timeline_tabs.currentChanged.connect(self.on_timeline_tab_changed)
|
||||
main_layout.addWidget(self.timeline_tabs)
|
||||
|
||||
# Status label for connection info
|
||||
self.status_label = QLabel()
|
||||
self.status_label.setAccessibleName("Connection Status")
|
||||
main_layout.addWidget(self.status_label)
|
||||
self.update_status_label()
|
||||
|
||||
# Timeline view (main content area)
|
||||
self.timeline = TimelineView(self.account_manager)
|
||||
self.timeline.setAccessibleName("Timeline")
|
||||
self.timeline.reply_requested.connect(self.reply_to_post)
|
||||
self.timeline.boost_requested.connect(self.boost_post)
|
||||
self.timeline.favorite_requested.connect(self.favorite_post)
|
||||
self.timeline.profile_requested.connect(self.view_profile)
|
||||
main_layout.addWidget(self.timeline)
|
||||
|
||||
# Compose button
|
||||
compose_layout = QHBoxLayout()
|
||||
self.compose_button = QPushButton("&Compose Post")
|
||||
self.compose_button.setAccessibleName("Compose New Post")
|
||||
self.compose_button.clicked.connect(self.show_compose_dialog)
|
||||
compose_layout.addWidget(self.compose_button)
|
||||
compose_layout.addStretch()
|
||||
main_layout.addLayout(compose_layout)
|
||||
|
||||
# Status bar
|
||||
self.status_bar = QStatusBar()
|
||||
self.setStatusBar(self.status_bar)
|
||||
self.status_bar.showMessage("Ready")
|
||||
|
||||
def setup_menus(self):
|
||||
"""Create application menus"""
|
||||
menubar = self.menuBar()
|
||||
|
||||
# File menu
|
||||
file_menu = menubar.addMenu("&File")
|
||||
|
||||
# New post action
|
||||
new_post_action = QAction("&New Post", self)
|
||||
new_post_action.setShortcut(QKeySequence.New)
|
||||
new_post_action.triggered.connect(self.show_compose_dialog)
|
||||
file_menu.addAction(new_post_action)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
# Account management
|
||||
add_account_action = QAction("&Add Account", self)
|
||||
add_account_action.setShortcut(QKeySequence("Ctrl+Shift+A"))
|
||||
add_account_action.triggered.connect(self.show_login_dialog)
|
||||
file_menu.addAction(add_account_action)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
# Settings action
|
||||
settings_action = QAction("&Settings", self)
|
||||
settings_action.setShortcut(QKeySequence.Preferences)
|
||||
settings_action.triggered.connect(self.show_settings)
|
||||
file_menu.addAction(settings_action)
|
||||
|
||||
file_menu.addSeparator()
|
||||
|
||||
# Quit action
|
||||
quit_action = QAction("&Quit", self)
|
||||
quit_action.setShortcut(QKeySequence.Quit)
|
||||
quit_action.triggered.connect(self.quit_application)
|
||||
file_menu.addAction(quit_action)
|
||||
|
||||
# View menu
|
||||
view_menu = menubar.addMenu("&View")
|
||||
|
||||
# Refresh timeline action
|
||||
refresh_action = QAction("&Refresh Timeline", self)
|
||||
refresh_action.setShortcut(QKeySequence.Refresh)
|
||||
refresh_action.triggered.connect(self.refresh_timeline)
|
||||
view_menu.addAction(refresh_action)
|
||||
|
||||
# Timeline menu
|
||||
timeline_menu = menubar.addMenu("&Timeline")
|
||||
|
||||
# Home timeline action
|
||||
home_action = QAction("&Home", self)
|
||||
home_action.setShortcut(QKeySequence("Ctrl+1"))
|
||||
home_action.triggered.connect(lambda: self.switch_timeline(0))
|
||||
timeline_menu.addAction(home_action)
|
||||
|
||||
# Mentions timeline action
|
||||
mentions_action = QAction("&Mentions", self)
|
||||
mentions_action.setShortcut(QKeySequence("Ctrl+2"))
|
||||
mentions_action.triggered.connect(lambda: self.switch_timeline(1))
|
||||
timeline_menu.addAction(mentions_action)
|
||||
|
||||
# Local timeline action
|
||||
local_action = QAction("&Local", self)
|
||||
local_action.setShortcut(QKeySequence("Ctrl+3"))
|
||||
local_action.triggered.connect(lambda: self.switch_timeline(2))
|
||||
timeline_menu.addAction(local_action)
|
||||
|
||||
# Federated timeline action
|
||||
federated_action = QAction("&Federated", self)
|
||||
federated_action.setShortcut(QKeySequence("Ctrl+4"))
|
||||
federated_action.triggered.connect(lambda: self.switch_timeline(3))
|
||||
timeline_menu.addAction(federated_action)
|
||||
|
||||
def setup_shortcuts(self):
|
||||
"""Set up keyboard shortcuts"""
|
||||
# Additional shortcuts that don't need menu items
|
||||
pass
|
||||
|
||||
def show_compose_dialog(self):
|
||||
"""Show the compose post dialog"""
|
||||
dialog = ComposeDialog(self.account_manager, self)
|
||||
dialog.post_sent.connect(self.on_post_sent)
|
||||
dialog.exec()
|
||||
|
||||
def on_post_sent(self, post_data):
|
||||
"""Handle post data from compose dialog"""
|
||||
self.status_bar.showMessage("Sending post...", 2000)
|
||||
|
||||
# Start background posting
|
||||
self.start_background_post(post_data)
|
||||
|
||||
def start_background_post(self, post_data):
|
||||
"""Start posting in background thread"""
|
||||
from PySide6.QtCore import QThread
|
||||
|
||||
class PostThread(QThread):
|
||||
post_success = Signal()
|
||||
post_failed = Signal(str)
|
||||
|
||||
def __init__(self, post_data, parent):
|
||||
super().__init__()
|
||||
self.post_data = post_data
|
||||
self.parent_window = parent
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
account = self.post_data['account']
|
||||
client = ActivityPubClient(account.instance_url, account.access_token)
|
||||
|
||||
result = client.post_status(
|
||||
content=self.post_data['content'],
|
||||
visibility=self.post_data['visibility'],
|
||||
content_warning=self.post_data['content_warning'],
|
||||
in_reply_to_id=self.post_data.get('in_reply_to_id')
|
||||
)
|
||||
|
||||
# Success
|
||||
self.post_success.emit()
|
||||
|
||||
except Exception as e:
|
||||
# Error
|
||||
self.post_failed.emit(str(e))
|
||||
|
||||
self.post_thread = PostThread(post_data, self)
|
||||
self.post_thread.post_success.connect(self.on_post_success)
|
||||
self.post_thread.post_failed.connect(self.on_post_failed)
|
||||
self.post_thread.start()
|
||||
|
||||
def on_post_success(self):
|
||||
"""Handle successful post submission"""
|
||||
# Play success sound
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.play_success()
|
||||
|
||||
self.status_bar.showMessage("Post sent successfully!", 3000)
|
||||
|
||||
# Refresh timeline to show the new post
|
||||
self.timeline.refresh()
|
||||
|
||||
def on_post_failed(self, error_message: str):
|
||||
"""Handle failed post submission"""
|
||||
# Play error sound
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.play_error()
|
||||
|
||||
self.status_bar.showMessage(f"Post failed: {error_message}", 5000)
|
||||
|
||||
def show_settings(self):
|
||||
"""Show the settings dialog"""
|
||||
dialog = SettingsDialog(self)
|
||||
dialog.settings_changed.connect(self.on_settings_changed)
|
||||
dialog.exec()
|
||||
|
||||
def on_settings_changed(self):
|
||||
"""Handle settings changes"""
|
||||
# Reload sound manager with new settings
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.reload_settings()
|
||||
self.status_bar.showMessage("Settings saved successfully", 2000)
|
||||
|
||||
def refresh_timeline(self):
|
||||
"""Refresh the current timeline"""
|
||||
self.timeline.refresh()
|
||||
self.status_bar.showMessage("Timeline refreshed", 2000)
|
||||
|
||||
def on_timeline_tab_changed(self, index):
|
||||
"""Handle timeline tab change"""
|
||||
self.switch_timeline(index)
|
||||
|
||||
def switch_timeline(self, index):
|
||||
"""Switch to timeline by index with loading feedback"""
|
||||
timeline_names = ["Home", "Mentions", "Local", "Federated"]
|
||||
timeline_types = ["home", "notifications", "local", "federated"]
|
||||
|
||||
if 0 <= index < len(timeline_names):
|
||||
timeline_name = timeline_names[index]
|
||||
timeline_type = timeline_types[index]
|
||||
|
||||
# Set tab to match if called from keyboard shortcut
|
||||
if self.timeline_tabs.currentIndex() != index:
|
||||
self.timeline_tabs.setCurrentIndex(index)
|
||||
|
||||
# Announce loading
|
||||
self.status_bar.showMessage(f"Loading {timeline_name} timeline...")
|
||||
|
||||
# Switch timeline type
|
||||
try:
|
||||
self.timeline.set_timeline_type(timeline_type)
|
||||
|
||||
# Success feedback
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.play_success()
|
||||
self.status_bar.showMessage(f"Loaded {timeline_name} timeline", 2000)
|
||||
|
||||
except Exception as e:
|
||||
# Error feedback
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.play_error()
|
||||
self.status_bar.showMessage(f"Failed to load {timeline_name} timeline: {str(e)}", 3000)
|
||||
|
||||
def show_first_time_setup(self):
|
||||
"""Show first-time setup dialog"""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
result = QMessageBox.question(
|
||||
self,
|
||||
"Welcome to Bifrost",
|
||||
"Welcome to Bifrost! You need to add a fediverse account to get started.\n\n"
|
||||
"Would you like to add an account now?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.Yes
|
||||
)
|
||||
|
||||
if result == QMessageBox.Yes:
|
||||
self.show_login_dialog()
|
||||
|
||||
def show_login_dialog(self):
|
||||
"""Show the login dialog"""
|
||||
dialog = LoginDialog(self)
|
||||
dialog.account_added.connect(self.on_account_added)
|
||||
dialog.exec()
|
||||
|
||||
def on_account_added(self, account_data):
|
||||
"""Handle new account being added"""
|
||||
self.account_selector.add_account(account_data)
|
||||
self.update_status_label()
|
||||
self.status_bar.showMessage(f"Added account: {account_data['username']}", 3000)
|
||||
# Refresh timeline with new account
|
||||
self.timeline.refresh()
|
||||
|
||||
def on_account_changed(self, account_id):
|
||||
"""Handle account switching"""
|
||||
account = self.account_manager.get_account_by_id(account_id)
|
||||
if account:
|
||||
self.update_status_label()
|
||||
self.status_bar.showMessage(f"Switched to {account.get_display_text()}", 2000)
|
||||
# Refresh timeline with new account
|
||||
self.timeline.refresh()
|
||||
|
||||
def reply_to_post(self, post):
|
||||
"""Reply to a specific post"""
|
||||
dialog = ComposeDialog(self.account_manager, self)
|
||||
# Pre-fill with reply mention
|
||||
dialog.text_edit.setPlainText(f"@{post.account.username} ")
|
||||
# Move cursor to end
|
||||
cursor = dialog.text_edit.textCursor()
|
||||
cursor.movePosition(QTextCursor.MoveOperation.End)
|
||||
dialog.text_edit.setTextCursor(cursor)
|
||||
dialog.post_sent.connect(lambda data: self.on_post_sent({**data, 'in_reply_to_id': post.id}))
|
||||
dialog.exec()
|
||||
|
||||
def boost_post(self, post):
|
||||
"""Boost/unboost a post"""
|
||||
active_account = self.account_manager.get_active_account()
|
||||
if not active_account:
|
||||
return
|
||||
|
||||
try:
|
||||
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
|
||||
if post.reblogged:
|
||||
client.unreblog_status(post.id)
|
||||
self.status_bar.showMessage("Post unboosted", 2000)
|
||||
else:
|
||||
client.reblog_status(post.id)
|
||||
self.status_bar.showMessage("Post boosted", 2000)
|
||||
# Refresh timeline to show updated state
|
||||
self.timeline.refresh()
|
||||
except Exception as e:
|
||||
self.status_bar.showMessage(f"Boost failed: {str(e)}", 3000)
|
||||
|
||||
def favorite_post(self, post):
|
||||
"""Favorite/unfavorite a post"""
|
||||
active_account = self.account_manager.get_active_account()
|
||||
if not active_account:
|
||||
return
|
||||
|
||||
try:
|
||||
client = ActivityPubClient(active_account.instance_url, active_account.access_token)
|
||||
if post.favourited:
|
||||
client.unfavourite_status(post.id)
|
||||
self.status_bar.showMessage("Post unfavorited", 2000)
|
||||
else:
|
||||
client.favourite_status(post.id)
|
||||
self.status_bar.showMessage("Post favorited", 2000)
|
||||
# Refresh timeline to show updated state
|
||||
self.timeline.refresh()
|
||||
except Exception as e:
|
||||
self.status_bar.showMessage(f"Favorite failed: {str(e)}", 3000)
|
||||
|
||||
def view_profile(self, post):
|
||||
"""View user profile"""
|
||||
# TODO: Implement profile viewing dialog
|
||||
self.status_bar.showMessage(f"Profile viewing not implemented yet: {post.account.display_name}", 3000)
|
||||
|
||||
def update_status_label(self):
|
||||
"""Update the status label with current account info"""
|
||||
active_account = self.account_manager.get_active_account()
|
||||
if active_account:
|
||||
self.status_label.setText(f"Connected as {active_account.get_display_text()}")
|
||||
else:
|
||||
self.status_label.setText("No account connected")
|
||||
|
||||
def quit_application(self):
|
||||
"""Quit the application with shutdown sound"""
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.play_shutdown()
|
||||
# Wait briefly for sound to start playing
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(500, self.close)
|
||||
else:
|
||||
self.close()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle window close event"""
|
||||
# Play shutdown sound if not already played through quit_application
|
||||
if hasattr(self.timeline, 'sound_manager'):
|
||||
self.timeline.sound_manager.play_shutdown()
|
||||
# Wait briefly for sound to complete
|
||||
from PySide6.QtCore import QTimer, QEventLoop
|
||||
loop = QEventLoop()
|
||||
QTimer.singleShot(500, loop.quit)
|
||||
loop.exec()
|
||||
event.accept()
|
1
src/models/__init__.py
Normal file
1
src/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Data models
|
233
src/models/post.py
Normal file
233
src/models/post.py
Normal file
@ -0,0 +1,233 @@
|
||||
"""
|
||||
Post data model for fediverse posts/statuses
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaAttachment:
|
||||
"""Media attachment data"""
|
||||
id: str
|
||||
type: str # image, video, audio, unknown
|
||||
url: str
|
||||
preview_url: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
meta: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Account:
|
||||
"""Account data"""
|
||||
id: str
|
||||
username: str
|
||||
acct: str # username@domain
|
||||
display_name: str
|
||||
note: str # bio/description
|
||||
url: str
|
||||
avatar: str
|
||||
avatar_static: str
|
||||
header: str
|
||||
header_static: str
|
||||
locked: bool = False
|
||||
bot: bool = False
|
||||
discoverable: bool = True
|
||||
group: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
followers_count: int = 0
|
||||
following_count: int = 0
|
||||
statuses_count: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Post:
|
||||
"""Fediverse post/status data model"""
|
||||
id: str
|
||||
uri: str
|
||||
url: Optional[str]
|
||||
account: Account
|
||||
content: str
|
||||
created_at: datetime
|
||||
visibility: str # public, unlisted, private, direct
|
||||
sensitive: bool = False
|
||||
spoiler_text: str = ""
|
||||
media_attachments: List[MediaAttachment] = None
|
||||
mentions: List[Dict[str, str]] = None
|
||||
tags: List[Dict[str, str]] = None
|
||||
emojis: List[Dict[str, str]] = None
|
||||
reblogs_count: int = 0
|
||||
favourites_count: int = 0
|
||||
replies_count: int = 0
|
||||
reblogged: bool = False
|
||||
favourited: bool = False
|
||||
bookmarked: bool = False
|
||||
muted: bool = False
|
||||
pinned: bool = False
|
||||
reblog: Optional['Post'] = None # For boosts/reblogs
|
||||
in_reply_to_id: Optional[str] = None
|
||||
in_reply_to_account_id: Optional[str] = None
|
||||
language: Optional[str] = None
|
||||
text: Optional[str] = None # Plain text version
|
||||
|
||||
# Notification metadata (when displayed in notifications timeline)
|
||||
notification_type: Optional[str] = None # mention, reblog, favourite, follow, etc.
|
||||
notification_account: Optional[str] = None # account that triggered notification
|
||||
|
||||
def __post_init__(self):
|
||||
if self.media_attachments is None:
|
||||
self.media_attachments = []
|
||||
if self.mentions is None:
|
||||
self.mentions = []
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
if self.emojis is None:
|
||||
self.emojis = []
|
||||
|
||||
@classmethod
|
||||
def from_api_dict(cls, data: Dict[str, Any]) -> 'Post':
|
||||
"""Create Post from API response dictionary"""
|
||||
# Parse account
|
||||
account_data = data['account']
|
||||
account = Account(
|
||||
id=account_data['id'],
|
||||
username=account_data['username'],
|
||||
acct=account_data['acct'],
|
||||
display_name=account_data['display_name'],
|
||||
note=account_data['note'],
|
||||
url=account_data['url'],
|
||||
avatar=account_data['avatar'],
|
||||
avatar_static=account_data['avatar_static'],
|
||||
header=account_data['header'],
|
||||
header_static=account_data['header_static'],
|
||||
locked=account_data.get('locked', False),
|
||||
bot=account_data.get('bot', False),
|
||||
discoverable=account_data.get('discoverable', True),
|
||||
group=account_data.get('group', False),
|
||||
created_at=datetime.fromisoformat(account_data['created_at'].replace('Z', '+00:00')) if account_data.get('created_at') else None,
|
||||
followers_count=account_data.get('followers_count', 0),
|
||||
following_count=account_data.get('following_count', 0),
|
||||
statuses_count=account_data.get('statuses_count', 0)
|
||||
)
|
||||
|
||||
# Parse media attachments
|
||||
media_attachments = []
|
||||
for media_data in data.get('media_attachments', []):
|
||||
media = MediaAttachment(
|
||||
id=media_data['id'],
|
||||
type=media_data['type'],
|
||||
url=media_data['url'],
|
||||
preview_url=media_data.get('preview_url'),
|
||||
description=media_data.get('description'),
|
||||
meta=media_data.get('meta')
|
||||
)
|
||||
media_attachments.append(media)
|
||||
|
||||
# Parse reblog if present
|
||||
reblog = None
|
||||
if data.get('reblog'):
|
||||
reblog = cls.from_api_dict(data['reblog'])
|
||||
|
||||
# Create post
|
||||
post = cls(
|
||||
id=data['id'],
|
||||
uri=data['uri'],
|
||||
url=data.get('url'),
|
||||
account=account,
|
||||
content=data['content'],
|
||||
created_at=datetime.fromisoformat(data['created_at'].replace('Z', '+00:00')),
|
||||
visibility=data['visibility'],
|
||||
sensitive=data.get('sensitive', False),
|
||||
spoiler_text=data.get('spoiler_text', ''),
|
||||
media_attachments=media_attachments,
|
||||
mentions=data.get('mentions', []),
|
||||
tags=data.get('tags', []),
|
||||
emojis=data.get('emojis', []),
|
||||
reblogs_count=data.get('reblogs_count', 0),
|
||||
favourites_count=data.get('favourites_count', 0),
|
||||
replies_count=data.get('replies_count', 0),
|
||||
reblogged=data.get('reblogged', False),
|
||||
favourited=data.get('favourited', False),
|
||||
bookmarked=data.get('bookmarked', False),
|
||||
muted=data.get('muted', False),
|
||||
pinned=data.get('pinned', False),
|
||||
reblog=reblog,
|
||||
in_reply_to_id=data.get('in_reply_to_id'),
|
||||
in_reply_to_account_id=data.get('in_reply_to_account_id'),
|
||||
language=data.get('language'),
|
||||
text=data.get('text')
|
||||
)
|
||||
|
||||
return post
|
||||
|
||||
def get_display_name(self) -> str:
|
||||
"""Get display name for the post author"""
|
||||
if self.reblog:
|
||||
return f"{self.account.display_name or self.account.username} boosted {self.reblog.account.display_name or self.reblog.account.username}"
|
||||
return self.account.display_name or self.account.username
|
||||
|
||||
def get_content_text(self) -> str:
|
||||
"""Get plain text content, handling reblogs"""
|
||||
if self.reblog:
|
||||
return self.reblog.get_content_text()
|
||||
|
||||
# Use plain text if available, otherwise strip HTML from content
|
||||
if self.text:
|
||||
return self.text
|
||||
|
||||
# Basic HTML stripping (should be improved with proper HTML parsing)
|
||||
import re
|
||||
content = re.sub(r'<[^>]+>', '', self.content)
|
||||
content = content.replace('<', '<').replace('>', '>').replace('&', '&')
|
||||
return content.strip()
|
||||
|
||||
def get_summary_for_screen_reader(self) -> str:
|
||||
"""Get a summary suitable for screen reader announcement"""
|
||||
author = self.get_display_name()
|
||||
content = self.get_content_text()
|
||||
|
||||
summary = f"{author}: {content}"
|
||||
|
||||
# Add attachment info
|
||||
if self.media_attachments:
|
||||
attachment_count = len(self.media_attachments)
|
||||
attachment_types = set(media.type for media in self.media_attachments)
|
||||
if len(attachment_types) == 1:
|
||||
media_type = list(attachment_types)[0]
|
||||
if attachment_count == 1:
|
||||
summary += f" (1 {media_type} attachment)"
|
||||
else:
|
||||
summary += f" ({attachment_count} {media_type} attachments)"
|
||||
else:
|
||||
summary += f" ({attachment_count} media attachments)"
|
||||
|
||||
# Add notification context if this is from notifications timeline
|
||||
if self.notification_type and self.notification_account:
|
||||
notification_text = {
|
||||
'mention': f"{self.notification_account} mentioned you",
|
||||
'reblog': f"{self.notification_account} boosted your post",
|
||||
'favourite': f"{self.notification_account} favorited your post",
|
||||
'follow': f"{self.notification_account} followed you"
|
||||
}.get(self.notification_type, f"{self.notification_account} {self.notification_type}")
|
||||
summary = f"[{notification_text}] {summary}"
|
||||
|
||||
# Add interaction counts if significant
|
||||
if self.replies_count > 0:
|
||||
summary += f" ({self.replies_count} replies"
|
||||
if self.reblogs_count > 0 or self.favourites_count > 0:
|
||||
summary += f", {self.reblogs_count} boosts, {self.favourites_count} favourites)"
|
||||
else:
|
||||
summary += ")"
|
||||
elif self.reblogs_count > 0 or self.favourites_count > 0:
|
||||
summary += f" ({self.reblogs_count} boosts, {self.favourites_count} favourites)"
|
||||
|
||||
return summary
|
||||
|
||||
def is_reply(self) -> bool:
|
||||
"""Check if this post is a reply to another post"""
|
||||
return self.in_reply_to_id is not None
|
||||
|
||||
def is_boost(self) -> bool:
|
||||
"""Check if this post is a boost/reblog"""
|
||||
return self.reblog is not None
|
194
src/models/user.py
Normal file
194
src/models/user.py
Normal file
@ -0,0 +1,194 @@
|
||||
"""
|
||||
User data model for fediverse accounts
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Field:
|
||||
"""Profile field (key-value pair with optional verification)"""
|
||||
name: str
|
||||
value: str
|
||||
verified_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
"""Fediverse user/account data model"""
|
||||
id: str
|
||||
username: str
|
||||
acct: str # username@domain format
|
||||
display_name: str
|
||||
note: str # Bio/description
|
||||
url: str
|
||||
avatar: str
|
||||
avatar_static: str
|
||||
header: str
|
||||
header_static: str
|
||||
locked: bool = False
|
||||
bot: bool = False
|
||||
discoverable: bool = True
|
||||
group: bool = False
|
||||
noindex: bool = False
|
||||
suspended: bool = False
|
||||
limited: bool = False
|
||||
created_at: Optional[datetime] = None
|
||||
last_status_at: Optional[datetime] = None
|
||||
followers_count: int = 0
|
||||
following_count: int = 0
|
||||
statuses_count: int = 0
|
||||
fields: List[Field] = None
|
||||
emojis: List[Dict[str, str]] = None
|
||||
moved: Optional['User'] = None
|
||||
|
||||
# Relationship info (when fetched with relationship context)
|
||||
following: Optional[bool] = None
|
||||
followed_by: Optional[bool] = None
|
||||
blocking: Optional[bool] = None
|
||||
blocked_by: Optional[bool] = None
|
||||
muting: Optional[bool] = None
|
||||
muting_notifications: Optional[bool] = None
|
||||
requested: Optional[bool] = None
|
||||
domain_blocking: Optional[bool] = None
|
||||
showing_reblogs: Optional[bool] = None
|
||||
endorsed: Optional[bool] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.fields is None:
|
||||
self.fields = []
|
||||
if self.emojis is None:
|
||||
self.emojis = []
|
||||
|
||||
@classmethod
|
||||
def from_api_dict(cls, data: Dict[str, Any]) -> 'User':
|
||||
"""Create User from API response dictionary"""
|
||||
# Parse fields
|
||||
fields = []
|
||||
for field_data in data.get('fields', []):
|
||||
field = Field(
|
||||
name=field_data['name'],
|
||||
value=field_data['value'],
|
||||
verified_at=datetime.fromisoformat(field_data['verified_at'].replace('Z', '+00:00')) if field_data.get('verified_at') else None
|
||||
)
|
||||
fields.append(field)
|
||||
|
||||
# Parse moved account if present
|
||||
moved = None
|
||||
if data.get('moved'):
|
||||
moved = cls.from_api_dict(data['moved'])
|
||||
|
||||
user = cls(
|
||||
id=data['id'],
|
||||
username=data['username'],
|
||||
acct=data['acct'],
|
||||
display_name=data['display_name'],
|
||||
note=data['note'],
|
||||
url=data['url'],
|
||||
avatar=data['avatar'],
|
||||
avatar_static=data['avatar_static'],
|
||||
header=data['header'],
|
||||
header_static=data['header_static'],
|
||||
locked=data.get('locked', False),
|
||||
bot=data.get('bot', False),
|
||||
discoverable=data.get('discoverable', True),
|
||||
group=data.get('group', False),
|
||||
noindex=data.get('noindex', False),
|
||||
suspended=data.get('suspended', False),
|
||||
limited=data.get('limited', False),
|
||||
created_at=datetime.fromisoformat(data['created_at'].replace('Z', '+00:00')) if data.get('created_at') else None,
|
||||
last_status_at=datetime.fromisoformat(data['last_status_at'].replace('Z', '+00:00')) if data.get('last_status_at') else None,
|
||||
followers_count=data.get('followers_count', 0),
|
||||
following_count=data.get('following_count', 0),
|
||||
statuses_count=data.get('statuses_count', 0),
|
||||
fields=fields,
|
||||
emojis=data.get('emojis', []),
|
||||
moved=moved
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
def get_display_name(self) -> str:
|
||||
"""Get the preferred display name"""
|
||||
return self.display_name if self.display_name else self.username
|
||||
|
||||
def get_full_username(self) -> str:
|
||||
"""Get the full username with domain"""
|
||||
return self.acct
|
||||
|
||||
def get_domain(self) -> str:
|
||||
"""Get the domain part of the account"""
|
||||
if '@' in self.acct:
|
||||
return self.acct.split('@')[1]
|
||||
return "" # Local account
|
||||
|
||||
def is_local(self) -> bool:
|
||||
"""Check if this is a local account (no domain)"""
|
||||
return '@' not in self.acct
|
||||
|
||||
def get_bio_text(self) -> str:
|
||||
"""Get plain text bio, stripping HTML"""
|
||||
if not self.note:
|
||||
return ""
|
||||
|
||||
# Basic HTML stripping (should be improved with proper HTML parsing)
|
||||
import re
|
||||
bio = re.sub(r'<[^>]+>', '', self.note)
|
||||
bio = bio.replace('<', '<').replace('>', '>').replace('&', '&')
|
||||
return bio.strip()
|
||||
|
||||
def get_profile_summary(self) -> str:
|
||||
"""Get a summary of the profile for screen readers"""
|
||||
name = self.get_display_name()
|
||||
username = self.get_full_username()
|
||||
|
||||
summary = f"{name} ({username})"
|
||||
|
||||
if self.bot:
|
||||
summary += " [Bot]"
|
||||
if self.locked:
|
||||
summary += " [Private]"
|
||||
if self.group:
|
||||
summary += " [Group]"
|
||||
|
||||
# Add follower stats
|
||||
summary += f" - {self.followers_count} followers, {self.following_count} following, {self.statuses_count} posts"
|
||||
|
||||
# Add bio if present
|
||||
bio = self.get_bio_text()
|
||||
if bio:
|
||||
# Truncate long bios
|
||||
if len(bio) > 150:
|
||||
bio = bio[:147] + "..."
|
||||
summary += f" - {bio}"
|
||||
|
||||
return summary
|
||||
|
||||
def get_relationship_status(self) -> str:
|
||||
"""Get relationship status description"""
|
||||
if self.following is None:
|
||||
return "Relationship unknown"
|
||||
|
||||
status_parts = []
|
||||
|
||||
if self.following:
|
||||
status_parts.append("Following")
|
||||
if self.followed_by:
|
||||
status_parts.append("Follows you")
|
||||
if self.requested:
|
||||
status_parts.append("Follow requested")
|
||||
if self.blocking:
|
||||
status_parts.append("Blocked")
|
||||
if self.muting:
|
||||
status_parts.append("Muted")
|
||||
|
||||
if not status_parts:
|
||||
return "No relationship"
|
||||
|
||||
return ", ".join(status_parts)
|
||||
|
||||
def has_verified_fields(self) -> bool:
|
||||
"""Check if user has any verified profile fields"""
|
||||
return any(field.verified_at is not None for field in self.fields)
|
7
src/notifications/__init__.py
Normal file
7
src/notifications/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""
|
||||
Desktop notification system for Bifrost
|
||||
"""
|
||||
|
||||
from .notification_manager import NotificationManager
|
||||
|
||||
__all__ = ['NotificationManager']
|
109
src/notifications/notification_manager.py
Normal file
109
src/notifications/notification_manager.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""
|
||||
Desktop notification manager using plyer
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from plyer import notification
|
||||
from config.settings import SettingsManager
|
||||
|
||||
|
||||
class NotificationManager:
|
||||
"""Manages desktop notifications for Bifrost"""
|
||||
|
||||
def __init__(self, settings: SettingsManager):
|
||||
self.settings = settings
|
||||
|
||||
def is_enabled(self, notification_type: str = None) -> bool:
|
||||
"""Check if notifications are enabled globally or for specific type"""
|
||||
if not self.settings.get_bool('notifications', 'enabled', True):
|
||||
return False
|
||||
|
||||
if notification_type:
|
||||
return self.settings.get_bool('notifications', notification_type, True)
|
||||
return True
|
||||
|
||||
def show_notification(self, title: str, message: str, notification_type: str = None):
|
||||
"""Show a desktop notification"""
|
||||
if not self.is_enabled(notification_type):
|
||||
return
|
||||
|
||||
try:
|
||||
notification.notify(
|
||||
title=title,
|
||||
message=message,
|
||||
app_name="Bifrost",
|
||||
timeout=5
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to show notification: {e}")
|
||||
|
||||
def notify_direct_message(self, sender: str, message_preview: str):
|
||||
"""Show notification for direct message"""
|
||||
if not self.is_enabled('direct_messages'):
|
||||
return
|
||||
|
||||
self.show_notification(
|
||||
title=f"Direct message from {sender}",
|
||||
message=message_preview,
|
||||
notification_type='direct_messages'
|
||||
)
|
||||
|
||||
def notify_mention(self, sender: str, post_preview: str):
|
||||
"""Show notification for mention"""
|
||||
if not self.is_enabled('mentions'):
|
||||
return
|
||||
|
||||
self.show_notification(
|
||||
title=f"{sender} mentioned you",
|
||||
message=post_preview,
|
||||
notification_type='mentions'
|
||||
)
|
||||
|
||||
def notify_boost(self, sender: str, post_preview: str):
|
||||
"""Show notification for boost/reblog"""
|
||||
if not self.is_enabled('boosts'):
|
||||
return
|
||||
|
||||
self.show_notification(
|
||||
title=f"{sender} boosted your post",
|
||||
message=post_preview,
|
||||
notification_type='boosts'
|
||||
)
|
||||
|
||||
def notify_favorite(self, sender: str, post_preview: str):
|
||||
"""Show notification for favorite"""
|
||||
if not self.is_enabled('favorites'):
|
||||
return
|
||||
|
||||
self.show_notification(
|
||||
title=f"{sender} favorited your post",
|
||||
message=post_preview,
|
||||
notification_type='favorites'
|
||||
)
|
||||
|
||||
def notify_follow(self, follower: str):
|
||||
"""Show notification for new follower"""
|
||||
if not self.is_enabled('follows'):
|
||||
return
|
||||
|
||||
self.show_notification(
|
||||
title="New follower",
|
||||
message=f"{follower} started following you",
|
||||
notification_type='follows'
|
||||
)
|
||||
|
||||
def notify_timeline_update(self, count: int, timeline_type: str = "timeline"):
|
||||
"""Show notification for timeline updates"""
|
||||
if not self.is_enabled('timeline_updates'):
|
||||
return
|
||||
|
||||
if count == 1:
|
||||
message = f"1 new post in your {timeline_type}"
|
||||
else:
|
||||
message = f"{count} new posts in your {timeline_type}"
|
||||
|
||||
self.show_notification(
|
||||
title="Timeline updated",
|
||||
message=message,
|
||||
notification_type='timeline_updates'
|
||||
)
|
1
src/widgets/__init__.py
Normal file
1
src/widgets/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# UI widgets
|
101
src/widgets/account_selector.py
Normal file
101
src/widgets/account_selector.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""
|
||||
Account selector widget for switching between multiple accounts
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QWidget, QHBoxLayout, QLabel, QPushButton
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
from accessibility.accessible_combo import AccessibleComboBox
|
||||
from config.accounts import AccountManager, Account
|
||||
|
||||
|
||||
class AccountSelector(QWidget):
|
||||
"""Widget for selecting and managing accounts"""
|
||||
|
||||
account_changed = Signal(str) # account_id
|
||||
add_account_requested = Signal()
|
||||
|
||||
def __init__(self, account_manager: AccountManager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.account_manager = account_manager
|
||||
self.setup_ui()
|
||||
self.refresh_accounts()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the account selector UI"""
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Account label
|
||||
self.account_label = QLabel("Account:")
|
||||
layout.addWidget(self.account_label)
|
||||
|
||||
# Account selector combo
|
||||
self.account_combo = AccessibleComboBox()
|
||||
self.account_combo.setAccessibleName("Account Selection")
|
||||
self.account_combo.setAccessibleDescription("Select active account. Use Left/Right arrows or Page Up/Down to switch accounts quickly.")
|
||||
self.account_combo.currentTextChanged.connect(self.on_account_selected)
|
||||
layout.addWidget(self.account_combo)
|
||||
|
||||
# Add account button
|
||||
self.add_button = QPushButton("&Add Account")
|
||||
self.add_button.setAccessibleName("Add New Account")
|
||||
self.add_button.clicked.connect(self.add_account_requested.emit)
|
||||
layout.addWidget(self.add_button)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
def refresh_accounts(self):
|
||||
"""Refresh the account list"""
|
||||
self.account_combo.clear()
|
||||
|
||||
if not self.account_manager.has_accounts():
|
||||
self.account_combo.addItem("No accounts configured")
|
||||
self.account_combo.setEnabled(False)
|
||||
return
|
||||
|
||||
self.account_combo.setEnabled(True)
|
||||
|
||||
# Add all accounts
|
||||
for account in self.account_manager.get_all_accounts():
|
||||
display_text = account.get_display_text()
|
||||
self.account_combo.addItem(display_text)
|
||||
|
||||
# Select active account
|
||||
active_account = self.account_manager.get_active_account()
|
||||
if active_account:
|
||||
active_display = active_account.get_display_text()
|
||||
index = self.account_combo.findText(active_display)
|
||||
if index >= 0:
|
||||
self.account_combo.setCurrentIndex(index)
|
||||
|
||||
def on_account_selected(self, display_name: str):
|
||||
"""Handle account selection"""
|
||||
if display_name == "No accounts configured":
|
||||
return
|
||||
|
||||
account_id = self.account_manager.find_account_by_display_name(display_name)
|
||||
if account_id:
|
||||
self.account_manager.set_active_account(account_id)
|
||||
self.account_changed.emit(account_id)
|
||||
|
||||
def add_account(self, account_data: dict):
|
||||
"""Add a new account"""
|
||||
account_id = self.account_manager.add_account(account_data)
|
||||
self.refresh_accounts()
|
||||
|
||||
# Select the new account
|
||||
new_account = self.account_manager.get_account_by_id(account_id)
|
||||
if new_account:
|
||||
display_text = new_account.get_display_text()
|
||||
index = self.account_combo.findText(display_text)
|
||||
if index >= 0:
|
||||
self.account_combo.setCurrentIndex(index)
|
||||
|
||||
def get_current_account(self) -> Account:
|
||||
"""Get the currently selected account"""
|
||||
return self.account_manager.get_active_account()
|
||||
|
||||
def has_accounts(self) -> bool:
|
||||
"""Check if there are any accounts"""
|
||||
return self.account_manager.has_accounts()
|
326
src/widgets/autocomplete_textedit.py
Normal file
326
src/widgets/autocomplete_textedit.py
Normal file
@ -0,0 +1,326 @@
|
||||
"""
|
||||
Text edit widget with autocomplete for mentions and emojis
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QTextEdit, QCompleter, QListWidget, QListWidgetItem
|
||||
from PySide6.QtCore import Qt, Signal, QStringListModel, QRect
|
||||
from PySide6.QtGui import QTextCursor, QKeyEvent
|
||||
import re
|
||||
from typing import List, Dict
|
||||
|
||||
|
||||
class AutocompleteTextEdit(QTextEdit):
|
||||
"""Text edit with @ mention and : emoji autocomplete"""
|
||||
|
||||
mention_requested = Signal(str) # Emitted when user types @ to request user list
|
||||
emoji_requested = Signal(str) # Emitted when user types : to request emoji list
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Lists for autocomplete
|
||||
self.mention_list = [] # Will be populated from followers/following
|
||||
self.emoji_list = [] # Will be populated from instance custom emojis
|
||||
|
||||
# Autocomplete state
|
||||
self.completer = None
|
||||
self.completion_prefix = ""
|
||||
self.completion_start = 0
|
||||
self.completion_type = None # 'mention' or 'emoji'
|
||||
|
||||
# Load default emojis
|
||||
self.load_default_emojis()
|
||||
|
||||
def load_default_emojis(self):
|
||||
"""Load a comprehensive set of Unicode emojis"""
|
||||
self.emoji_list = [
|
||||
# Faces
|
||||
{"shortcode": "smile", "emoji": "😄", "keywords": ["smile", "happy", "joy", "grin"]},
|
||||
{"shortcode": "laughing", "emoji": "😆", "keywords": ["laugh", "haha", "funny", "lol"]},
|
||||
{"shortcode": "wink", "emoji": "😉", "keywords": ["wink", "flirt", "hint"]},
|
||||
{"shortcode": "thinking", "emoji": "🤔", "keywords": ["thinking", "hmm", "consider", "ponder"]},
|
||||
{"shortcode": "shrug", "emoji": "🤷", "keywords": ["shrug", "dunno", "whatever", "idk"]},
|
||||
{"shortcode": "facepalm", "emoji": "🤦", "keywords": ["facepalm", "disappointed", "doh", "frustrated"]},
|
||||
{"shortcode": "crying", "emoji": "😭", "keywords": ["crying", "tears", "sad", "sob"]},
|
||||
{"shortcode": "angry", "emoji": "😠", "keywords": ["angry", "mad", "furious", "upset"]},
|
||||
{"shortcode": "cool", "emoji": "😎", "keywords": ["cool", "sunglasses", "awesome", "rad"]},
|
||||
{"shortcode": "joy", "emoji": "😂", "keywords": ["joy", "laugh", "tears", "funny"]},
|
||||
{"shortcode": "heart_eyes", "emoji": "😍", "keywords": ["heart", "eyes", "love", "crush"]},
|
||||
{"shortcode": "kiss", "emoji": "😘", "keywords": ["kiss", "love", "smooch", "mwah"]},
|
||||
{"shortcode": "tired", "emoji": "😴", "keywords": ["tired", "sleep", "sleepy", "zzz"]},
|
||||
{"shortcode": "shocked", "emoji": "😱", "keywords": ["shocked", "surprised", "scared", "omg"]},
|
||||
|
||||
# Hearts and symbols
|
||||
{"shortcode": "heart", "emoji": "❤️", "keywords": ["heart", "love", "red", "romance"]},
|
||||
{"shortcode": "blue_heart", "emoji": "💙", "keywords": ["blue", "heart", "love", "cold"]},
|
||||
{"shortcode": "green_heart", "emoji": "💚", "keywords": ["green", "heart", "love", "nature"]},
|
||||
{"shortcode": "yellow_heart", "emoji": "💛", "keywords": ["yellow", "heart", "love", "happy"]},
|
||||
{"shortcode": "purple_heart", "emoji": "💜", "keywords": ["purple", "heart", "love", "royal"]},
|
||||
{"shortcode": "black_heart", "emoji": "🖤", "keywords": ["black", "heart", "love", "dark"]},
|
||||
{"shortcode": "broken_heart", "emoji": "💔", "keywords": ["broken", "heart", "sad", "breakup"]},
|
||||
{"shortcode": "sparkling_heart", "emoji": "💖", "keywords": ["sparkling", "heart", "love", "sparkle"]},
|
||||
|
||||
# Gestures
|
||||
{"shortcode": "thumbsup", "emoji": "👍", "keywords": ["thumbs", "up", "good", "ok", "yes"]},
|
||||
{"shortcode": "thumbsdown", "emoji": "👎", "keywords": ["thumbs", "down", "bad", "no", "dislike"]},
|
||||
{"shortcode": "wave", "emoji": "👋", "keywords": ["wave", "hello", "hi", "goodbye", "bye"]},
|
||||
{"shortcode": "clap", "emoji": "👏", "keywords": ["clap", "applause", "bravo", "good"]},
|
||||
{"shortcode": "pray", "emoji": "🙏", "keywords": ["pray", "thanks", "please", "gratitude"]},
|
||||
{"shortcode": "ok_hand", "emoji": "👌", "keywords": ["ok", "hand", "perfect", "good"]},
|
||||
{"shortcode": "peace", "emoji": "✌️", "keywords": ["peace", "victory", "two", "fingers"]},
|
||||
{"shortcode": "crossed_fingers", "emoji": "🤞", "keywords": ["crossed", "fingers", "luck", "hope"]},
|
||||
|
||||
# Objects and symbols
|
||||
{"shortcode": "fire", "emoji": "🔥", "keywords": ["fire", "hot", "flame", "lit"]},
|
||||
{"shortcode": "star", "emoji": "⭐", "keywords": ["star", "favorite", "best", "top"]},
|
||||
{"shortcode": "rainbow", "emoji": "🌈", "keywords": ["rainbow", "colorful", "pride", "weather"]},
|
||||
{"shortcode": "lightning", "emoji": "⚡", "keywords": ["lightning", "bolt", "fast", "electric"]},
|
||||
{"shortcode": "snowflake", "emoji": "❄️", "keywords": ["snowflake", "cold", "winter", "frozen"]},
|
||||
{"shortcode": "sun", "emoji": "☀️", "keywords": ["sun", "sunny", "bright", "weather"]},
|
||||
{"shortcode": "moon", "emoji": "🌙", "keywords": ["moon", "night", "crescent", "sleep"]},
|
||||
{"shortcode": "cloud", "emoji": "☁️", "keywords": ["cloud", "weather", "sky", "cloudy"]},
|
||||
|
||||
# Food and drinks
|
||||
{"shortcode": "coffee", "emoji": "☕", "keywords": ["coffee", "drink", "morning", "caffeine"]},
|
||||
{"shortcode": "tea", "emoji": "🍵", "keywords": ["tea", "drink", "hot", "green"]},
|
||||
{"shortcode": "beer", "emoji": "🍺", "keywords": ["beer", "drink", "alcohol", "party"]},
|
||||
{"shortcode": "wine", "emoji": "🍷", "keywords": ["wine", "drink", "alcohol", "red"]},
|
||||
{"shortcode": "pizza", "emoji": "🍕", "keywords": ["pizza", "food", "italian", "slice"]},
|
||||
{"shortcode": "burger", "emoji": "🍔", "keywords": ["burger", "food", "meat", "american"]},
|
||||
{"shortcode": "cake", "emoji": "🎂", "keywords": ["cake", "birthday", "dessert", "sweet"]},
|
||||
{"shortcode": "cookie", "emoji": "🍪", "keywords": ["cookie", "dessert", "sweet", "snack"]},
|
||||
{"shortcode": "apple", "emoji": "🍎", "keywords": ["apple", "fruit", "red", "healthy"]},
|
||||
{"shortcode": "banana", "emoji": "🍌", "keywords": ["banana", "fruit", "yellow", "monkey"]},
|
||||
|
||||
# Animals
|
||||
{"shortcode": "cat", "emoji": "🐱", "keywords": ["cat", "kitten", "meow", "feline"]},
|
||||
{"shortcode": "dog", "emoji": "🐶", "keywords": ["dog", "puppy", "woof", "canine"]},
|
||||
{"shortcode": "mouse", "emoji": "🐭", "keywords": ["mouse", "small", "rodent", "squeak"]},
|
||||
{"shortcode": "bear", "emoji": "🐻", "keywords": ["bear", "large", "forest", "cute"]},
|
||||
{"shortcode": "panda", "emoji": "🐼", "keywords": ["panda", "bear", "black", "white"]},
|
||||
{"shortcode": "lion", "emoji": "🦁", "keywords": ["lion", "king", "mane", "roar"]},
|
||||
{"shortcode": "tiger", "emoji": "🐯", "keywords": ["tiger", "stripes", "orange", "wild"]},
|
||||
{"shortcode": "fox", "emoji": "🦊", "keywords": ["fox", "red", "clever", "sly"]},
|
||||
{"shortcode": "wolf", "emoji": "🐺", "keywords": ["wolf", "pack", "howl", "wild"]},
|
||||
{"shortcode": "unicorn", "emoji": "🦄", "keywords": ["unicorn", "magic", "rainbow", "fantasy"]},
|
||||
|
||||
# Activities and celebrations
|
||||
{"shortcode": "party", "emoji": "🎉", "keywords": ["party", "celebration", "confetti", "fun"]},
|
||||
{"shortcode": "birthday", "emoji": "🎂", "keywords": ["birthday", "cake", "celebration", "age"]},
|
||||
{"shortcode": "gift", "emoji": "🎁", "keywords": ["gift", "present", "box", "surprise"]},
|
||||
{"shortcode": "balloon", "emoji": "🎈", "keywords": ["balloon", "party", "float", "celebration"]},
|
||||
{"shortcode": "music", "emoji": "🎵", "keywords": ["music", "notes", "song", "melody"]},
|
||||
{"shortcode": "dance", "emoji": "💃", "keywords": ["dance", "woman", "party", "fun"]},
|
||||
|
||||
# Halloween and seasonal
|
||||
{"shortcode": "jack_o_lantern", "emoji": "🎃", "keywords": ["jack", "lantern", "pumpkin", "halloween"]},
|
||||
{"shortcode": "ghost", "emoji": "👻", "keywords": ["ghost", "spooky", "halloween", "boo"]},
|
||||
{"shortcode": "skull", "emoji": "💀", "keywords": ["skull", "death", "spooky", "halloween"]},
|
||||
{"shortcode": "spider", "emoji": "🕷️", "keywords": ["spider", "web", "spooky", "halloween"]},
|
||||
{"shortcode": "bat", "emoji": "🦇", "keywords": ["bat", "fly", "night", "halloween"]},
|
||||
{"shortcode": "christmas_tree", "emoji": "🎄", "keywords": ["christmas", "tree", "holiday", "winter"]},
|
||||
{"shortcode": "santa", "emoji": "🎅", "keywords": ["santa", "christmas", "holiday", "ho"]},
|
||||
{"shortcode": "snowman", "emoji": "⛄", "keywords": ["snowman", "winter", "cold", "carrot"]},
|
||||
|
||||
# Technology
|
||||
{"shortcode": "computer", "emoji": "💻", "keywords": ["computer", "laptop", "tech", "work"]},
|
||||
{"shortcode": "phone", "emoji": "📱", "keywords": ["phone", "mobile", "cell", "smartphone"]},
|
||||
{"shortcode": "camera", "emoji": "📷", "keywords": ["camera", "photo", "picture", "snap"]},
|
||||
{"shortcode": "video", "emoji": "📹", "keywords": ["video", "camera", "record", "film"]},
|
||||
{"shortcode": "robot", "emoji": "🤖", "keywords": ["robot", "ai", "artificial", "intelligence"]},
|
||||
|
||||
# Transportation
|
||||
{"shortcode": "car", "emoji": "🚗", "keywords": ["car", "drive", "vehicle", "auto"]},
|
||||
{"shortcode": "bike", "emoji": "🚲", "keywords": ["bike", "bicycle", "ride", "cycle"]},
|
||||
{"shortcode": "plane", "emoji": "✈️", "keywords": ["plane", "airplane", "fly", "travel"]},
|
||||
{"shortcode": "rocket", "emoji": "🚀", "keywords": ["rocket", "space", "launch", "fast"]},
|
||||
]
|
||||
|
||||
def set_mention_list(self, mentions: List[str]):
|
||||
"""Set the list of available mentions (usernames)"""
|
||||
self.mention_list = mentions
|
||||
|
||||
def set_emoji_list(self, emojis: List[Dict]):
|
||||
"""Set custom emoji list from instance"""
|
||||
# Combine with default emojis
|
||||
self.emoji_list.extend(emojis)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent):
|
||||
"""Handle key press events for autocomplete"""
|
||||
key = event.key()
|
||||
|
||||
# Handle autocomplete navigation
|
||||
if self.completer and self.completer.popup().isVisible():
|
||||
if key in [Qt.Key_Up, Qt.Key_Down, Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab]:
|
||||
self.handle_completer_key(key)
|
||||
return
|
||||
elif key == Qt.Key_Escape:
|
||||
self.hide_completer()
|
||||
return
|
||||
|
||||
# Normal key processing
|
||||
super().keyPressEvent(event)
|
||||
|
||||
# Check for autocomplete triggers
|
||||
self.check_autocomplete_trigger()
|
||||
|
||||
def handle_completer_key(self, key):
|
||||
"""Handle navigation keys in completer"""
|
||||
popup = self.completer.popup()
|
||||
|
||||
if key in [Qt.Key_Up, Qt.Key_Down]:
|
||||
# Let the popup handle up/down
|
||||
if key == Qt.Key_Up:
|
||||
current = popup.currentIndex().row()
|
||||
if current > 0:
|
||||
popup.setCurrentIndex(popup.model().index(current - 1, 0))
|
||||
else:
|
||||
current = popup.currentIndex().row()
|
||||
if current < popup.model().rowCount() - 1:
|
||||
popup.setCurrentIndex(popup.model().index(current + 1, 0))
|
||||
|
||||
elif key in [Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab]:
|
||||
# Insert the selected completion
|
||||
self.insert_completion()
|
||||
|
||||
def check_autocomplete_trigger(self):
|
||||
"""Check if we should show autocomplete"""
|
||||
cursor = self.textCursor()
|
||||
text = self.toPlainText()
|
||||
pos = cursor.position()
|
||||
|
||||
# Find the current word being typed
|
||||
if pos == 0:
|
||||
return
|
||||
|
||||
# Look backwards for @ or :
|
||||
start_pos = pos - 1
|
||||
while start_pos >= 0 and text[start_pos] not in [' ', '\n', '\t']:
|
||||
start_pos -= 1
|
||||
start_pos += 1
|
||||
|
||||
current_word = text[start_pos:pos]
|
||||
|
||||
if current_word.startswith('@') and len(current_word) > 1:
|
||||
# Check if this is a completed mention (ends with space)
|
||||
if current_word.endswith(' '):
|
||||
self.hide_completer()
|
||||
return
|
||||
# Mention autocomplete
|
||||
prefix = current_word[1:] # Remove @
|
||||
self.show_mention_completer(prefix, start_pos)
|
||||
|
||||
elif current_word.startswith(':') and len(current_word) > 1:
|
||||
# Check if this is a completed emoji (ends with :)
|
||||
if current_word.endswith(':') and len(current_word) > 2:
|
||||
self.hide_completer()
|
||||
return
|
||||
# Emoji autocomplete - remove trailing : if present for matching
|
||||
prefix = current_word[1:] # Remove initial :
|
||||
if prefix.endswith(':'):
|
||||
prefix = prefix[:-1] # Remove trailing : for matching
|
||||
self.show_emoji_completer(prefix, start_pos)
|
||||
|
||||
else:
|
||||
# Hide completer if not in autocomplete mode
|
||||
self.hide_completer()
|
||||
|
||||
def show_mention_completer(self, prefix: str, start_pos: int):
|
||||
"""Show mention autocomplete"""
|
||||
if not self.mention_list:
|
||||
# Request mention list from parent
|
||||
self.mention_requested.emit(prefix)
|
||||
return
|
||||
|
||||
# Filter mentions
|
||||
matches = [name for name in self.mention_list if name.lower().startswith(prefix.lower())]
|
||||
|
||||
if matches:
|
||||
self.show_completer(matches, prefix, start_pos, 'mention')
|
||||
else:
|
||||
self.hide_completer()
|
||||
|
||||
def show_emoji_completer(self, prefix: str, start_pos: int):
|
||||
"""Show emoji autocomplete"""
|
||||
# Filter emojis by shortcode and keywords
|
||||
matches = []
|
||||
prefix_lower = prefix.lower()
|
||||
|
||||
for emoji in self.emoji_list:
|
||||
shortcode = emoji['shortcode']
|
||||
keywords = emoji.get('keywords', [])
|
||||
|
||||
# Check if prefix matches shortcode or any keyword
|
||||
if (shortcode.startswith(prefix_lower) or
|
||||
any(keyword.startswith(prefix_lower) for keyword in keywords)):
|
||||
display_text = f"{shortcode} {emoji['emoji']}"
|
||||
matches.append(display_text)
|
||||
|
||||
if matches:
|
||||
self.show_completer(matches, prefix, start_pos, 'emoji')
|
||||
else:
|
||||
self.hide_completer()
|
||||
|
||||
def show_completer(self, items: List[str], prefix: str, start_pos: int, completion_type: str):
|
||||
"""Show the completer with given items"""
|
||||
self.completion_prefix = prefix
|
||||
self.completion_start = start_pos
|
||||
self.completion_type = completion_type
|
||||
|
||||
# Create or update completer
|
||||
if not self.completer:
|
||||
self.completer = QCompleter(self)
|
||||
self.completer.setWidget(self)
|
||||
self.completer.setCaseSensitivity(Qt.CaseInsensitive)
|
||||
|
||||
# Set up model
|
||||
model = QStringListModel(items)
|
||||
self.completer.setModel(model)
|
||||
|
||||
# Position the popup
|
||||
cursor = self.textCursor()
|
||||
cursor.setPosition(start_pos)
|
||||
rect = self.cursorRect(cursor)
|
||||
rect.setWidth(200)
|
||||
self.completer.complete(rect)
|
||||
|
||||
# Set accessible name for screen readers
|
||||
popup = self.completer.popup()
|
||||
popup.setAccessibleName(f"{completion_type.title()} Autocomplete")
|
||||
|
||||
def hide_completer(self):
|
||||
"""Hide the completer"""
|
||||
if self.completer:
|
||||
self.completer.popup().hide()
|
||||
|
||||
def insert_completion(self):
|
||||
"""Insert the selected completion"""
|
||||
if not self.completer:
|
||||
return
|
||||
|
||||
popup = self.completer.popup()
|
||||
current_index = popup.currentIndex()
|
||||
if not current_index.isValid():
|
||||
return
|
||||
|
||||
completion = self.completer.currentCompletion()
|
||||
|
||||
# Replace the current prefix with the completion
|
||||
cursor = self.textCursor()
|
||||
cursor.setPosition(self.completion_start)
|
||||
cursor.setPosition(cursor.position() + len(self.completion_prefix) + 1, QTextCursor.KeepAnchor) # +1 for @ or :
|
||||
|
||||
if self.completion_type == 'mention':
|
||||
cursor.insertText(f"@{completion} ")
|
||||
elif self.completion_type == 'emoji':
|
||||
# Extract just the shortcode (before the emoji)
|
||||
shortcode = completion.split()[0]
|
||||
cursor.insertText(f":{shortcode}: ")
|
||||
|
||||
self.hide_completer()
|
||||
|
||||
def update_mention_list(self, mentions: List[str]):
|
||||
"""Update mention list (called from parent when data is ready)"""
|
||||
self.mention_list = mentions
|
||||
# Don't re-trigger completer to avoid recursion
|
||||
# The completer will use the updated list on next keystroke
|
255
src/widgets/compose_dialog.py
Normal file
255
src/widgets/compose_dialog.py
Normal file
@ -0,0 +1,255 @@
|
||||
"""
|
||||
Compose post dialog for creating new posts
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QTextEdit,
|
||||
QPushButton, QLabel, QDialogButtonBox, QCheckBox,
|
||||
QComboBox, QGroupBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, QThread
|
||||
from PySide6.QtGui import QKeySequence, QShortcut
|
||||
|
||||
from accessibility.accessible_combo import AccessibleComboBox
|
||||
from audio.sound_manager import SoundManager
|
||||
from config.settings import SettingsManager
|
||||
from activitypub.client import ActivityPubClient
|
||||
from widgets.autocomplete_textedit import AutocompleteTextEdit
|
||||
|
||||
|
||||
class PostThread(QThread):
|
||||
"""Background thread for posting content"""
|
||||
|
||||
post_success = Signal(dict) # Emitted with post data on success
|
||||
post_failed = Signal(str) # Emitted with error message on failure
|
||||
|
||||
def __init__(self, account, content, visibility, content_warning=None):
|
||||
super().__init__()
|
||||
self.account = account
|
||||
self.content = content
|
||||
self.visibility = visibility
|
||||
self.content_warning = content_warning
|
||||
|
||||
def run(self):
|
||||
"""Post the content in background"""
|
||||
try:
|
||||
client = ActivityPubClient(self.account.instance_url, self.account.access_token)
|
||||
|
||||
result = client.post_status(
|
||||
content=self.content,
|
||||
visibility=self.visibility,
|
||||
content_warning=self.content_warning
|
||||
)
|
||||
|
||||
self.post_success.emit(result)
|
||||
|
||||
except Exception as e:
|
||||
self.post_failed.emit(str(e))
|
||||
|
||||
|
||||
class ComposeDialog(QDialog):
|
||||
"""Dialog for composing new posts"""
|
||||
|
||||
post_sent = Signal(dict) # Emitted when a post is ready to send
|
||||
|
||||
def __init__(self, account_manager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.settings = SettingsManager()
|
||||
self.sound_manager = SoundManager(self.settings)
|
||||
self.account_manager = account_manager
|
||||
self.setup_ui()
|
||||
self.setup_shortcuts()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the compose dialog UI"""
|
||||
self.setWindowTitle("Compose Post")
|
||||
self.setMinimumSize(500, 300)
|
||||
self.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Character count label
|
||||
self.char_count_label = QLabel("Characters: 0/500")
|
||||
self.char_count_label.setAccessibleName("Character Count")
|
||||
layout.addWidget(self.char_count_label)
|
||||
|
||||
# Main text area with autocomplete
|
||||
self.text_edit = AutocompleteTextEdit()
|
||||
self.text_edit.setAccessibleName("Post Content")
|
||||
self.text_edit.setAccessibleDescription("Enter your post content here. Type @ for mentions, : for emojis. Press Tab to move to post options.")
|
||||
self.text_edit.setPlaceholderText("What's on your mind? Type @ for mentions, : for emojis")
|
||||
self.text_edit.setTabChangesFocus(True) # Allow Tab to exit the text area
|
||||
self.text_edit.textChanged.connect(self.update_char_count)
|
||||
self.text_edit.mention_requested.connect(self.load_mention_suggestions)
|
||||
self.text_edit.emoji_requested.connect(self.load_emoji_suggestions)
|
||||
layout.addWidget(self.text_edit)
|
||||
|
||||
# Options group
|
||||
options_group = QGroupBox("Post Options")
|
||||
options_layout = QVBoxLayout(options_group)
|
||||
|
||||
# Visibility settings
|
||||
visibility_layout = QHBoxLayout()
|
||||
visibility_layout.addWidget(QLabel("Visibility:"))
|
||||
|
||||
self.visibility_combo = AccessibleComboBox()
|
||||
self.visibility_combo.setAccessibleName("Post Visibility")
|
||||
self.visibility_combo.addItems([
|
||||
"Public",
|
||||
"Unlisted",
|
||||
"Followers Only",
|
||||
"Direct Message"
|
||||
])
|
||||
visibility_layout.addWidget(self.visibility_combo)
|
||||
visibility_layout.addStretch()
|
||||
options_layout.addLayout(visibility_layout)
|
||||
|
||||
# Content warnings
|
||||
self.cw_checkbox = QCheckBox("Add Content Warning")
|
||||
self.cw_checkbox.setAccessibleName("Content Warning Toggle")
|
||||
self.cw_checkbox.toggled.connect(self.toggle_content_warning)
|
||||
options_layout.addWidget(self.cw_checkbox)
|
||||
|
||||
self.cw_edit = QTextEdit()
|
||||
self.cw_edit.setAccessibleName("Content Warning Text")
|
||||
self.cw_edit.setAccessibleDescription("Enter content warning description. Press Tab to move to next field.")
|
||||
self.cw_edit.setPlaceholderText("Describe what this post contains...")
|
||||
self.cw_edit.setMaximumHeight(60)
|
||||
self.cw_edit.setTabChangesFocus(True) # Allow Tab to exit the content warning field
|
||||
self.cw_edit.hide()
|
||||
options_layout.addWidget(self.cw_edit)
|
||||
|
||||
layout.addWidget(options_group)
|
||||
|
||||
# Button box
|
||||
button_box = QDialogButtonBox()
|
||||
|
||||
# Post button
|
||||
self.post_button = QPushButton("&Post")
|
||||
self.post_button.setAccessibleName("Send Post")
|
||||
self.post_button.setDefault(True)
|
||||
self.post_button.clicked.connect(self.send_post)
|
||||
button_box.addButton(self.post_button, QDialogButtonBox.AcceptRole)
|
||||
|
||||
# Cancel button
|
||||
cancel_button = QPushButton("&Cancel")
|
||||
cancel_button.setAccessibleName("Cancel Post")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_box.addButton(cancel_button, QDialogButtonBox.RejectRole)
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Set initial focus
|
||||
self.text_edit.setFocus()
|
||||
|
||||
def setup_shortcuts(self):
|
||||
"""Set up keyboard shortcuts"""
|
||||
# Ctrl+Enter to send post
|
||||
send_shortcut = QShortcut(QKeySequence("Ctrl+Return"), self)
|
||||
send_shortcut.activated.connect(self.send_post)
|
||||
|
||||
# Escape to cancel
|
||||
cancel_shortcut = QShortcut(QKeySequence.Cancel, self)
|
||||
cancel_shortcut.activated.connect(self.reject)
|
||||
|
||||
def toggle_content_warning(self, enabled: bool):
|
||||
"""Toggle content warning field visibility"""
|
||||
if enabled:
|
||||
self.cw_edit.show()
|
||||
# Don't automatically focus - let user tab to it naturally
|
||||
else:
|
||||
self.cw_edit.hide()
|
||||
self.cw_edit.clear()
|
||||
|
||||
def update_char_count(self):
|
||||
"""Update character count display"""
|
||||
text = self.text_edit.toPlainText()
|
||||
char_count = len(text)
|
||||
self.char_count_label.setText(f"Characters: {char_count}/500")
|
||||
|
||||
# Enable/disable post button based on content
|
||||
has_content = bool(text.strip())
|
||||
within_limit = char_count <= 500
|
||||
self.post_button.setEnabled(has_content and within_limit)
|
||||
|
||||
# Update accessibility
|
||||
if char_count > 500:
|
||||
self.char_count_label.setAccessibleDescription("Character limit exceeded")
|
||||
else:
|
||||
self.char_count_label.setAccessibleDescription(f"{500 - char_count} characters remaining")
|
||||
|
||||
def send_post(self):
|
||||
"""Send the post"""
|
||||
content = self.text_edit.toPlainText().strip()
|
||||
if not content:
|
||||
return
|
||||
|
||||
# Get active account
|
||||
active_account = self.account_manager.get_active_account()
|
||||
if not active_account:
|
||||
QMessageBox.warning(self, "No Account", "Please add an account before posting.")
|
||||
return
|
||||
|
||||
# Get post settings
|
||||
visibility_text = self.visibility_combo.currentText()
|
||||
visibility_map = {
|
||||
"Public": "public",
|
||||
"Unlisted": "unlisted",
|
||||
"Followers Only": "private",
|
||||
"Direct Message": "direct"
|
||||
}
|
||||
visibility = visibility_map.get(visibility_text, "public")
|
||||
|
||||
content_warning = None
|
||||
if self.cw_checkbox.isChecked():
|
||||
content_warning = self.cw_edit.toPlainText().strip()
|
||||
|
||||
# Start background posting
|
||||
post_data = {
|
||||
'account': active_account,
|
||||
'content': content,
|
||||
'visibility': visibility,
|
||||
'content_warning': content_warning
|
||||
}
|
||||
|
||||
# Play sound when post button is pressed
|
||||
self.sound_manager.play_event("post")
|
||||
|
||||
# Emit signal with all post data for background processing
|
||||
self.post_sent.emit(post_data)
|
||||
|
||||
# Close dialog immediately
|
||||
self.accept()
|
||||
|
||||
def load_mention_suggestions(self, prefix: str):
|
||||
"""Load mention suggestions based on prefix"""
|
||||
# TODO: Implement fetching followers/following from API
|
||||
# For now, use expanded sample suggestions with realistic fediverse usernames
|
||||
sample_mentions = [
|
||||
"alice", "bob", "charlie", "diana", "eve", "frank", "grace", "henry", "ivy", "jack",
|
||||
"admin", "moderator", "announcements", "news", "updates", "support", "help",
|
||||
"alex_dev", "jane_artist", "mike_writer", "sarah_photographer", "tom_musician",
|
||||
"community", "local_news", "tech_updates", "fedi_tips", "open_source",
|
||||
"cat_lover", "dog_walker", "book_reader", "movie_fan", "game_dev", "web_designer",
|
||||
"climate_activist", "space_enthusiast", "food_blogger", "travel_tales", "art_gallery"
|
||||
]
|
||||
|
||||
# Filter by prefix (case insensitive)
|
||||
filtered = [name for name in sample_mentions if name.lower().startswith(prefix.lower())]
|
||||
self.text_edit.update_mention_list(filtered)
|
||||
|
||||
def load_emoji_suggestions(self, prefix: str):
|
||||
"""Load emoji suggestions based on prefix"""
|
||||
# The AutocompleteTextEdit already has a built-in emoji list
|
||||
# This method is called when the signal is emitted, but the
|
||||
# autocomplete logic is handled internally by the text edit widget
|
||||
# We don't need to do anything here since emojis are pre-loaded
|
||||
pass
|
||||
|
||||
def get_post_data(self) -> dict:
|
||||
"""Get the composed post data"""
|
||||
return {
|
||||
'content': self.text_edit.toPlainText().strip(),
|
||||
'visibility': self.visibility_combo.currentText().lower().replace(" ", "_"),
|
||||
'content_warning': self.cw_edit.toPlainText().strip() if self.cw_checkbox.isChecked() else None
|
||||
}
|
274
src/widgets/login_dialog.py
Normal file
274
src/widgets/login_dialog.py
Normal file
@ -0,0 +1,274 @@
|
||||
"""
|
||||
Login dialog for adding new fediverse accounts
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLineEdit,
|
||||
QPushButton, QLabel, QDialogButtonBox, QMessageBox,
|
||||
QProgressBar, QTextEdit
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, QThread
|
||||
from PySide6.QtGui import QKeySequence, QShortcut
|
||||
import requests
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from activitypub.client import ActivityPubClient
|
||||
from activitypub.oauth import OAuth2Handler
|
||||
|
||||
|
||||
class InstanceTestThread(QThread):
|
||||
"""Thread for testing instance connectivity"""
|
||||
|
||||
result_ready = Signal(bool, str) # success, message
|
||||
|
||||
def __init__(self, instance_url):
|
||||
super().__init__()
|
||||
self.instance_url = instance_url
|
||||
|
||||
def run(self):
|
||||
"""Test if the instance is reachable and supports ActivityPub"""
|
||||
try:
|
||||
# Clean up the URL
|
||||
if not self.instance_url.startswith(('http://', 'https://')):
|
||||
test_url = f"https://{self.instance_url}"
|
||||
else:
|
||||
test_url = self.instance_url
|
||||
|
||||
# Test instance info endpoint
|
||||
response = requests.get(f"{test_url}/api/v1/instance", timeout=10)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
instance_name = data.get('title', 'Unknown Instance')
|
||||
version = data.get('version', 'Unknown')
|
||||
self.result_ready.emit(True, f"Connected to {instance_name} (Version: {version})")
|
||||
else:
|
||||
self.result_ready.emit(False, f"Instance returned status {response.status_code}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
self.result_ready.emit(False, "Could not connect to instance. Check URL and network connection.")
|
||||
except requests.exceptions.Timeout:
|
||||
self.result_ready.emit(False, "Connection timed out. Instance may be slow or unreachable.")
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.result_ready.emit(False, f"Connection error: {str(e)}")
|
||||
except Exception as e:
|
||||
self.result_ready.emit(False, f"Unexpected error: {str(e)}")
|
||||
|
||||
|
||||
class LoginDialog(QDialog):
|
||||
"""Dialog for adding new fediverse accounts"""
|
||||
|
||||
account_added = Signal(dict) # Emitted when account is successfully added
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.test_thread = None
|
||||
self.oauth_handler = None
|
||||
self.setup_ui()
|
||||
self.setup_shortcuts()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the login dialog UI"""
|
||||
self.setWindowTitle("Add Fediverse Account")
|
||||
self.setMinimumSize(500, 400)
|
||||
self.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Instructions
|
||||
instructions = QLabel(
|
||||
"Enter your fediverse instance URL\n"
|
||||
"Examples: mastodon.social, social.wolfe.casa, fediverse.social\n\n"
|
||||
"You will be redirected to your instance to authorize Bifrost."
|
||||
)
|
||||
instructions.setWordWrap(True)
|
||||
instructions.setAccessibleName("Login Instructions")
|
||||
layout.addWidget(instructions)
|
||||
|
||||
# Instance URL input
|
||||
instance_layout = QHBoxLayout()
|
||||
instance_layout.addWidget(QLabel("Instance URL:"))
|
||||
|
||||
self.instance_edit = QLineEdit()
|
||||
self.instance_edit.setAccessibleName("Instance URL")
|
||||
self.instance_edit.setAccessibleDescription("Enter your fediverse instance URL")
|
||||
self.instance_edit.setPlaceholderText("Example: mastodon.social")
|
||||
self.instance_edit.textChanged.connect(self.on_instance_changed)
|
||||
self.instance_edit.returnPressed.connect(self.test_instance)
|
||||
instance_layout.addWidget(self.instance_edit)
|
||||
|
||||
self.test_button = QPushButton("&Test Connection")
|
||||
self.test_button.setAccessibleName("Test Instance Connection")
|
||||
self.test_button.clicked.connect(self.test_instance)
|
||||
self.test_button.setEnabled(False)
|
||||
instance_layout.addWidget(self.test_button)
|
||||
|
||||
layout.addLayout(instance_layout)
|
||||
|
||||
# Progress bar for testing
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setAccessibleName("Connection Test Progress")
|
||||
self.progress_bar.setVisible(False)
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# Results area
|
||||
self.result_text = QTextEdit()
|
||||
self.result_text.setAccessibleName("Connection Test Results")
|
||||
self.result_text.setMaximumHeight(100)
|
||||
self.result_text.setReadOnly(True)
|
||||
self.result_text.setVisible(False)
|
||||
layout.addWidget(self.result_text)
|
||||
|
||||
# Username input (for display purposes)
|
||||
username_layout = QHBoxLayout()
|
||||
username_layout.addWidget(QLabel("Username (optional):"))
|
||||
|
||||
self.username_edit = QLineEdit()
|
||||
self.username_edit.setAccessibleName("Username")
|
||||
self.username_edit.setAccessibleDescription("Your username on this instance (for display only)")
|
||||
self.username_edit.setPlaceholderText("username")
|
||||
username_layout.addWidget(self.username_edit)
|
||||
|
||||
layout.addLayout(username_layout)
|
||||
|
||||
# Button box
|
||||
button_box = QDialogButtonBox()
|
||||
|
||||
# Login button
|
||||
self.login_button = QPushButton("&Login")
|
||||
self.login_button.setAccessibleName("Login to Instance")
|
||||
self.login_button.setDefault(True)
|
||||
self.login_button.clicked.connect(self.start_login)
|
||||
self.login_button.setEnabled(False)
|
||||
button_box.addButton(self.login_button, QDialogButtonBox.AcceptRole)
|
||||
|
||||
# Cancel button
|
||||
cancel_button = QPushButton("&Cancel")
|
||||
cancel_button.setAccessibleName("Cancel Login")
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_box.addButton(cancel_button, QDialogButtonBox.RejectRole)
|
||||
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Set initial focus
|
||||
self.instance_edit.setFocus()
|
||||
|
||||
def setup_shortcuts(self):
|
||||
"""Set up keyboard shortcuts"""
|
||||
# Escape to cancel
|
||||
cancel_shortcut = QShortcut(QKeySequence.Cancel, self)
|
||||
cancel_shortcut.activated.connect(self.reject)
|
||||
|
||||
def on_instance_changed(self, text):
|
||||
"""Handle instance URL text changes"""
|
||||
has_text = bool(text.strip())
|
||||
self.test_button.setEnabled(has_text)
|
||||
self.login_button.setEnabled(False) # Require testing first
|
||||
|
||||
if not has_text:
|
||||
self.result_text.setVisible(False)
|
||||
self.progress_bar.setVisible(False)
|
||||
|
||||
def test_instance(self):
|
||||
"""Test connection to the instance"""
|
||||
instance_url = self.instance_edit.text().strip()
|
||||
if not instance_url:
|
||||
return
|
||||
|
||||
self.test_button.setEnabled(False)
|
||||
self.login_button.setEnabled(False)
|
||||
self.progress_bar.setVisible(True)
|
||||
self.progress_bar.setRange(0, 0) # Indeterminate progress
|
||||
self.result_text.setVisible(False)
|
||||
|
||||
# Start test thread
|
||||
self.test_thread = InstanceTestThread(instance_url)
|
||||
self.test_thread.result_ready.connect(self.on_test_result)
|
||||
self.test_thread.start()
|
||||
|
||||
def on_test_result(self, success, message):
|
||||
"""Handle instance test results"""
|
||||
self.progress_bar.setVisible(False)
|
||||
self.result_text.setVisible(True)
|
||||
self.result_text.setText(message)
|
||||
|
||||
self.test_button.setEnabled(True)
|
||||
self.login_button.setEnabled(success)
|
||||
|
||||
if success:
|
||||
self.result_text.setStyleSheet("color: green;")
|
||||
# Show success message box
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Connection Test Successful",
|
||||
f"Connection test successful!\n\n{message}\n\nYou can now proceed to login."
|
||||
)
|
||||
else:
|
||||
self.result_text.setStyleSheet("color: red;")
|
||||
# Show error message box
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Connection Test Failed",
|
||||
f"Connection test failed:\n\n{message}\n\nPlease check your instance URL and try again."
|
||||
)
|
||||
|
||||
def start_login(self):
|
||||
"""Start the OAuth login process"""
|
||||
instance_url = self.instance_edit.text().strip()
|
||||
|
||||
if not instance_url:
|
||||
return
|
||||
|
||||
# Clean up URL
|
||||
if not instance_url.startswith(('http://', 'https://')):
|
||||
instance_url = f"https://{instance_url}"
|
||||
|
||||
# Disable UI during authentication
|
||||
self.login_button.setEnabled(False)
|
||||
self.test_button.setEnabled(False)
|
||||
self.instance_edit.setEnabled(False)
|
||||
self.username_edit.setEnabled(False)
|
||||
|
||||
# Start real OAuth2 flow
|
||||
self.oauth_handler = OAuth2Handler(instance_url)
|
||||
self.oauth_handler.authentication_complete.connect(self.on_auth_success)
|
||||
self.oauth_handler.authentication_failed.connect(self.on_auth_failed)
|
||||
|
||||
if not self.oauth_handler.start_authentication():
|
||||
self.on_auth_failed("Failed to start authentication process")
|
||||
|
||||
def on_auth_success(self, account_data):
|
||||
"""Handle successful authentication"""
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Login Successful",
|
||||
f"Successfully authenticated as {account_data['display_name']} "
|
||||
f"({account_data['username']}) on {account_data['instance_url']}"
|
||||
)
|
||||
|
||||
self.account_added.emit(account_data)
|
||||
self.accept()
|
||||
|
||||
def on_auth_failed(self, error_message):
|
||||
"""Handle authentication failure"""
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Authentication Failed",
|
||||
f"Failed to authenticate with the instance:\n\n{error_message}"
|
||||
)
|
||||
|
||||
# Re-enable UI
|
||||
self.login_button.setEnabled(True)
|
||||
self.test_button.setEnabled(True)
|
||||
self.instance_edit.setEnabled(True)
|
||||
self.username_edit.setEnabled(True)
|
||||
|
||||
def get_instance_url(self) -> str:
|
||||
"""Get the cleaned instance URL"""
|
||||
url = self.instance_edit.text().strip()
|
||||
if not url.startswith(('http://', 'https://')):
|
||||
url = f"https://{url}"
|
||||
return url
|
||||
|
||||
def get_username(self) -> str:
|
||||
"""Get the entered username"""
|
||||
return self.username_edit.text().strip()
|
308
src/widgets/settings_dialog.py
Normal file
308
src/widgets/settings_dialog.py
Normal file
@ -0,0 +1,308 @@
|
||||
"""
|
||||
Settings dialog for Bifrost configuration
|
||||
"""
|
||||
|
||||
import os
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||
QLabel, QComboBox, QPushButton, QDialogButtonBox,
|
||||
QGroupBox, QCheckBox, QSpinBox, QTabWidget, QWidget
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
from config.settings import SettingsManager
|
||||
from accessibility.accessible_combo import AccessibleComboBox
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""Main settings dialog for Bifrost"""
|
||||
|
||||
settings_changed = Signal() # Emitted when settings are saved
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.settings = SettingsManager()
|
||||
self.setup_ui()
|
||||
self.load_current_settings()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the settings dialog UI"""
|
||||
self.setWindowTitle("Bifrost Settings")
|
||||
self.setMinimumSize(500, 400)
|
||||
self.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Create tab widget for organized settings
|
||||
self.tabs = QTabWidget()
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
# Audio settings tab
|
||||
self.setup_audio_tab()
|
||||
|
||||
# Desktop notifications tab
|
||||
self.setup_notifications_tab()
|
||||
|
||||
# Accessibility settings tab
|
||||
self.setup_accessibility_tab()
|
||||
|
||||
# Button box
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.Ok | QDialogButtonBox.Cancel | QDialogButtonBox.Apply
|
||||
)
|
||||
button_box.accepted.connect(self.save_and_close)
|
||||
button_box.rejected.connect(self.reject)
|
||||
button_box.button(QDialogButtonBox.Apply).clicked.connect(self.apply_settings)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
def setup_audio_tab(self):
|
||||
"""Set up the audio settings tab"""
|
||||
audio_widget = QWidget()
|
||||
layout = QVBoxLayout(audio_widget)
|
||||
|
||||
# Sound pack selection
|
||||
sound_group = QGroupBox("Sound Pack")
|
||||
sound_layout = QFormLayout(sound_group)
|
||||
|
||||
self.sound_pack_combo = AccessibleComboBox()
|
||||
self.sound_pack_combo.setAccessibleName("Sound Pack Selection")
|
||||
self.sound_pack_combo.setAccessibleDescription("Choose which sound pack to use for audio feedback")
|
||||
|
||||
# Populate sound packs
|
||||
self.load_sound_packs()
|
||||
|
||||
sound_layout.addRow("Sound Pack:", self.sound_pack_combo)
|
||||
layout.addWidget(sound_group)
|
||||
|
||||
# Audio volume settings
|
||||
volume_group = QGroupBox("Volume Settings")
|
||||
volume_layout = QFormLayout(volume_group)
|
||||
|
||||
self.master_volume = QSpinBox()
|
||||
self.master_volume.setRange(0, 100)
|
||||
self.master_volume.setSuffix("%")
|
||||
self.master_volume.setAccessibleName("Master Volume")
|
||||
self.master_volume.setAccessibleDescription("Overall volume for all sounds")
|
||||
volume_layout.addRow("Master Volume:", self.master_volume)
|
||||
|
||||
self.notification_volume = QSpinBox()
|
||||
self.notification_volume.setRange(0, 100)
|
||||
self.notification_volume.setSuffix("%")
|
||||
self.notification_volume.setAccessibleName("Notification Volume")
|
||||
self.notification_volume.setAccessibleDescription("Volume for notification sounds")
|
||||
volume_layout.addRow("Notification Volume:", self.notification_volume)
|
||||
|
||||
layout.addWidget(volume_group)
|
||||
|
||||
# Audio enable/disable options
|
||||
options_group = QGroupBox("Audio Options")
|
||||
options_layout = QVBoxLayout(options_group)
|
||||
|
||||
self.enable_sounds = QCheckBox("Enable sound effects")
|
||||
self.enable_sounds.setAccessibleName("Enable Sound Effects")
|
||||
self.enable_sounds.setAccessibleDescription("Turn sound effects on or off globally")
|
||||
options_layout.addWidget(self.enable_sounds)
|
||||
|
||||
self.enable_post_sounds = QCheckBox("Play sounds for post actions")
|
||||
self.enable_post_sounds.setAccessibleName("Post Action Sounds")
|
||||
self.enable_post_sounds.setAccessibleDescription("Play sounds when posting, boosting, or favoriting")
|
||||
options_layout.addWidget(self.enable_post_sounds)
|
||||
|
||||
self.enable_timeline_sounds = QCheckBox("Play sounds for timeline updates")
|
||||
self.enable_timeline_sounds.setAccessibleName("Timeline Update Sounds")
|
||||
self.enable_timeline_sounds.setAccessibleDescription("Play sounds when the timeline refreshes")
|
||||
options_layout.addWidget(self.enable_timeline_sounds)
|
||||
|
||||
layout.addWidget(options_group)
|
||||
layout.addStretch()
|
||||
|
||||
self.tabs.addTab(audio_widget, "&Audio")
|
||||
|
||||
def setup_notifications_tab(self):
|
||||
"""Set up the desktop notifications settings tab"""
|
||||
notifications_widget = QWidget()
|
||||
layout = QVBoxLayout(notifications_widget)
|
||||
|
||||
# Desktop notifications group
|
||||
desktop_group = QGroupBox("Desktop Notifications")
|
||||
desktop_layout = QVBoxLayout(desktop_group)
|
||||
|
||||
self.enable_desktop_notifications = QCheckBox("Enable desktop notifications")
|
||||
self.enable_desktop_notifications.setAccessibleName("Enable Desktop Notifications")
|
||||
self.enable_desktop_notifications.setAccessibleDescription("Show desktop notifications for various events")
|
||||
desktop_layout.addWidget(self.enable_desktop_notifications)
|
||||
|
||||
# Notification types
|
||||
types_group = QGroupBox("Notification Types")
|
||||
types_layout = QVBoxLayout(types_group)
|
||||
|
||||
self.notify_direct_messages = QCheckBox("Direct/Private messages")
|
||||
self.notify_direct_messages.setAccessibleName("Direct Message Notifications")
|
||||
self.notify_direct_messages.setAccessibleDescription("Show notifications for direct messages")
|
||||
types_layout.addWidget(self.notify_direct_messages)
|
||||
|
||||
self.notify_mentions = QCheckBox("Mentions")
|
||||
self.notify_mentions.setAccessibleName("Mention Notifications")
|
||||
self.notify_mentions.setAccessibleDescription("Show notifications when you are mentioned")
|
||||
types_layout.addWidget(self.notify_mentions)
|
||||
|
||||
self.notify_boosts = QCheckBox("Boosts/Reblogs")
|
||||
self.notify_boosts.setAccessibleName("Boost Notifications")
|
||||
self.notify_boosts.setAccessibleDescription("Show notifications when your posts are boosted")
|
||||
types_layout.addWidget(self.notify_boosts)
|
||||
|
||||
self.notify_favorites = QCheckBox("Favorites")
|
||||
self.notify_favorites.setAccessibleName("Favorite Notifications")
|
||||
self.notify_favorites.setAccessibleDescription("Show notifications when your posts are favorited")
|
||||
types_layout.addWidget(self.notify_favorites)
|
||||
|
||||
self.notify_follows = QCheckBox("New followers")
|
||||
self.notify_follows.setAccessibleName("Follow Notifications")
|
||||
self.notify_follows.setAccessibleDescription("Show notifications for new followers")
|
||||
types_layout.addWidget(self.notify_follows)
|
||||
|
||||
self.notify_timeline_updates = QCheckBox("Timeline updates")
|
||||
self.notify_timeline_updates.setAccessibleName("Timeline Update Notifications")
|
||||
self.notify_timeline_updates.setAccessibleDescription("Show notifications for new posts in timeline")
|
||||
types_layout.addWidget(self.notify_timeline_updates)
|
||||
|
||||
layout.addWidget(desktop_group)
|
||||
layout.addWidget(types_group)
|
||||
layout.addStretch()
|
||||
|
||||
self.tabs.addTab(notifications_widget, "&Notifications")
|
||||
|
||||
def setup_accessibility_tab(self):
|
||||
"""Set up the accessibility settings tab"""
|
||||
accessibility_widget = QWidget()
|
||||
layout = QVBoxLayout(accessibility_widget)
|
||||
|
||||
# Navigation settings
|
||||
nav_group = QGroupBox("Navigation Settings")
|
||||
nav_layout = QFormLayout(nav_group)
|
||||
|
||||
self.page_step_size = QSpinBox()
|
||||
self.page_step_size.setRange(1, 20)
|
||||
self.page_step_size.setAccessibleName("Page Step Size")
|
||||
self.page_step_size.setAccessibleDescription("Number of posts to jump when using Page Up/Down")
|
||||
nav_layout.addRow("Page Step Size:", self.page_step_size)
|
||||
|
||||
layout.addWidget(nav_group)
|
||||
|
||||
# Screen reader options
|
||||
sr_group = QGroupBox("Screen Reader Options")
|
||||
sr_layout = QVBoxLayout(sr_group)
|
||||
|
||||
self.verbose_announcements = QCheckBox("Verbose announcements")
|
||||
self.verbose_announcements.setAccessibleName("Verbose Announcements")
|
||||
self.verbose_announcements.setAccessibleDescription("Provide detailed descriptions for screen readers")
|
||||
sr_layout.addWidget(self.verbose_announcements)
|
||||
|
||||
self.announce_thread_state = QCheckBox("Announce thread expand/collapse state")
|
||||
self.announce_thread_state.setAccessibleName("Thread State Announcements")
|
||||
self.announce_thread_state.setAccessibleDescription("Announce when threads are expanded or collapsed")
|
||||
sr_layout.addWidget(self.announce_thread_state)
|
||||
|
||||
layout.addWidget(sr_group)
|
||||
layout.addStretch()
|
||||
|
||||
self.tabs.addTab(accessibility_widget, "A&ccessibility")
|
||||
|
||||
def load_sound_packs(self):
|
||||
"""Load available sound packs from the sounds directory"""
|
||||
self.sound_pack_combo.clear()
|
||||
|
||||
# Add default "None" option
|
||||
self.sound_pack_combo.addItem("None (No sounds)", "none")
|
||||
|
||||
# Look for sound pack directories
|
||||
sounds_dir = "sounds"
|
||||
if os.path.exists(sounds_dir):
|
||||
for item in os.listdir(sounds_dir):
|
||||
pack_dir = os.path.join(sounds_dir, item)
|
||||
if os.path.isdir(pack_dir):
|
||||
pack_json = os.path.join(pack_dir, "pack.json")
|
||||
if os.path.exists(pack_json):
|
||||
# Valid sound pack
|
||||
self.sound_pack_combo.addItem(item, item)
|
||||
|
||||
# Also check in XDG data directory
|
||||
try:
|
||||
data_sounds_dir = self.settings.get_sounds_dir()
|
||||
if os.path.exists(data_sounds_dir) and data_sounds_dir != sounds_dir:
|
||||
for item in os.listdir(data_sounds_dir):
|
||||
pack_dir = os.path.join(data_sounds_dir, item)
|
||||
if os.path.isdir(pack_dir):
|
||||
pack_json = os.path.join(pack_dir, "pack.json")
|
||||
if os.path.exists(pack_json):
|
||||
# Avoid duplicates
|
||||
if self.sound_pack_combo.findData(item) == -1:
|
||||
self.sound_pack_combo.addItem(f"{item} (System)", item)
|
||||
except Exception:
|
||||
pass # Ignore errors in system sound pack detection
|
||||
|
||||
def load_current_settings(self):
|
||||
"""Load current settings into the dialog"""
|
||||
# Audio settings
|
||||
current_pack = self.settings.get('audio', 'sound_pack', 'none')
|
||||
index = self.sound_pack_combo.findData(current_pack)
|
||||
if index >= 0:
|
||||
self.sound_pack_combo.setCurrentIndex(index)
|
||||
|
||||
self.master_volume.setValue(int(self.settings.get('audio', 'master_volume', 100) or 100))
|
||||
self.notification_volume.setValue(int(self.settings.get('audio', 'notification_volume', 100) or 100))
|
||||
|
||||
self.enable_sounds.setChecked(bool(self.settings.get('audio', 'enabled', True)))
|
||||
self.enable_post_sounds.setChecked(bool(self.settings.get('audio', 'post_sounds', True)))
|
||||
self.enable_timeline_sounds.setChecked(bool(self.settings.get('audio', 'timeline_sounds', True)))
|
||||
|
||||
# Desktop notification settings (defaults: DMs, mentions, follows ON; others OFF)
|
||||
self.enable_desktop_notifications.setChecked(bool(self.settings.get('notifications', 'enabled', True)))
|
||||
self.notify_direct_messages.setChecked(bool(self.settings.get('notifications', 'direct_messages', True)))
|
||||
self.notify_mentions.setChecked(bool(self.settings.get('notifications', 'mentions', True)))
|
||||
self.notify_boosts.setChecked(bool(self.settings.get('notifications', 'boosts', False)))
|
||||
self.notify_favorites.setChecked(bool(self.settings.get('notifications', 'favorites', False)))
|
||||
self.notify_follows.setChecked(bool(self.settings.get('notifications', 'follows', True)))
|
||||
self.notify_timeline_updates.setChecked(bool(self.settings.get('notifications', 'timeline_updates', False)))
|
||||
|
||||
# Accessibility settings
|
||||
self.page_step_size.setValue(int(self.settings.get('accessibility', 'page_step_size', 5) or 5))
|
||||
self.verbose_announcements.setChecked(bool(self.settings.get('accessibility', 'verbose_announcements', True)))
|
||||
self.announce_thread_state.setChecked(bool(self.settings.get('accessibility', 'announce_thread_state', True)))
|
||||
|
||||
def apply_settings(self):
|
||||
"""Apply the current settings without closing the dialog"""
|
||||
# Audio settings
|
||||
selected_pack = self.sound_pack_combo.currentData()
|
||||
self.settings.set('audio', 'sound_pack', selected_pack)
|
||||
self.settings.set('audio', 'master_volume', self.master_volume.value())
|
||||
self.settings.set('audio', 'notification_volume', self.notification_volume.value())
|
||||
|
||||
self.settings.set('audio', 'enabled', self.enable_sounds.isChecked())
|
||||
self.settings.set('audio', 'post_sounds', self.enable_post_sounds.isChecked())
|
||||
self.settings.set('audio', 'timeline_sounds', self.enable_timeline_sounds.isChecked())
|
||||
|
||||
# Desktop notification settings
|
||||
self.settings.set('notifications', 'enabled', self.enable_desktop_notifications.isChecked())
|
||||
self.settings.set('notifications', 'direct_messages', self.notify_direct_messages.isChecked())
|
||||
self.settings.set('notifications', 'mentions', self.notify_mentions.isChecked())
|
||||
self.settings.set('notifications', 'boosts', self.notify_boosts.isChecked())
|
||||
self.settings.set('notifications', 'favorites', self.notify_favorites.isChecked())
|
||||
self.settings.set('notifications', 'follows', self.notify_follows.isChecked())
|
||||
self.settings.set('notifications', 'timeline_updates', self.notify_timeline_updates.isChecked())
|
||||
|
||||
# Accessibility settings
|
||||
self.settings.set('accessibility', 'page_step_size', self.page_step_size.value())
|
||||
self.settings.set('accessibility', 'verbose_announcements', self.verbose_announcements.isChecked())
|
||||
self.settings.set('accessibility', 'announce_thread_state', self.announce_thread_state.isChecked())
|
||||
|
||||
# Save to file
|
||||
self.settings.save_settings()
|
||||
|
||||
# Emit signal so other components can update
|
||||
self.settings_changed.emit()
|
||||
|
||||
def save_and_close(self):
|
||||
"""Save settings and close the dialog"""
|
||||
self.apply_settings()
|
||||
self.accept()
|
298
src/widgets/timeline_view.py
Normal file
298
src/widgets/timeline_view.py
Normal file
@ -0,0 +1,298 @@
|
||||
"""
|
||||
Timeline view widget for displaying posts and threads
|
||||
"""
|
||||
|
||||
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem, QHeaderView, QMenu
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QAction
|
||||
from typing import Optional
|
||||
|
||||
from accessibility.accessible_tree import AccessibleTreeWidget
|
||||
from audio.sound_manager import SoundManager
|
||||
from notifications.notification_manager import NotificationManager
|
||||
from config.settings import SettingsManager
|
||||
from config.accounts import AccountManager
|
||||
from activitypub.client import ActivityPubClient
|
||||
from models.post import Post
|
||||
|
||||
|
||||
class TimelineView(AccessibleTreeWidget):
|
||||
"""Main timeline display widget"""
|
||||
|
||||
# Signals for post actions
|
||||
reply_requested = Signal(object) # Post object
|
||||
boost_requested = Signal(object) # Post object
|
||||
favorite_requested = Signal(object) # Post object
|
||||
profile_requested = Signal(object) # Post object
|
||||
|
||||
def __init__(self, account_manager: AccountManager, parent=None):
|
||||
super().__init__(parent)
|
||||
self.timeline_type = "home"
|
||||
self.settings = SettingsManager()
|
||||
self.sound_manager = SoundManager(self.settings)
|
||||
self.notification_manager = NotificationManager(self.settings)
|
||||
self.account_manager = account_manager
|
||||
self.activitypub_client = None
|
||||
self.posts = [] # Store loaded posts
|
||||
self.setup_ui()
|
||||
self.refresh()
|
||||
|
||||
# Connect sound events
|
||||
self.item_state_changed.connect(self.on_state_changed)
|
||||
|
||||
# Enable context menu
|
||||
self.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self.show_context_menu)
|
||||
|
||||
def setup_ui(self):
|
||||
"""Initialize the timeline UI"""
|
||||
# Set up columns
|
||||
self.setColumnCount(1)
|
||||
self.setHeaderLabels(["Posts"])
|
||||
|
||||
# Hide the header
|
||||
self.header().hide()
|
||||
|
||||
# Set selection behavior
|
||||
self.setSelectionBehavior(QTreeWidget.SelectRows)
|
||||
self.setSelectionMode(QTreeWidget.SingleSelection)
|
||||
|
||||
# Enable keyboard navigation
|
||||
self.setFocusPolicy(Qt.StrongFocus)
|
||||
|
||||
# Set accessible properties
|
||||
self.setAccessibleName("Timeline")
|
||||
self.setAccessibleDescription("Timeline showing posts and conversations")
|
||||
|
||||
def set_timeline_type(self, timeline_type: str):
|
||||
"""Set the timeline type (home, local, federated)"""
|
||||
self.timeline_type = timeline_type
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the timeline content"""
|
||||
self.clear()
|
||||
|
||||
# Get active account
|
||||
active_account = self.account_manager.get_active_account()
|
||||
if not active_account:
|
||||
self.show_empty_message("Nothing to see here. To get content connect to an instance.")
|
||||
return
|
||||
|
||||
# Create ActivityPub client for active account
|
||||
self.activitypub_client = ActivityPubClient(
|
||||
active_account.instance_url,
|
||||
active_account.access_token
|
||||
)
|
||||
|
||||
try:
|
||||
# Fetch timeline or notifications
|
||||
if self.timeline_type == "notifications":
|
||||
timeline_data = self.activitypub_client.get_notifications(limit=20)
|
||||
else:
|
||||
timeline_data = self.activitypub_client.get_timeline(self.timeline_type, limit=20)
|
||||
self.load_timeline_data(timeline_data)
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch timeline: {e}")
|
||||
# Show error message instead of sample data
|
||||
self.show_empty_message(f"Failed to load timeline: {str(e)}\nCheck your connection and try refreshing.")
|
||||
|
||||
def load_timeline_data(self, timeline_data):
|
||||
"""Load real timeline data from ActivityPub API"""
|
||||
self.posts = []
|
||||
|
||||
if self.timeline_type == "notifications":
|
||||
# Handle notifications data structure
|
||||
for notification_data in timeline_data:
|
||||
try:
|
||||
notification_type = notification_data['type']
|
||||
sender = notification_data['account']['display_name'] or notification_data['account']['username']
|
||||
|
||||
# Notifications with status (mentions, boosts, favorites)
|
||||
if 'status' in notification_data:
|
||||
post = Post.from_api_dict(notification_data['status'])
|
||||
# Add notification metadata to post
|
||||
post.notification_type = notification_type
|
||||
post.notification_account = notification_data['account']['acct']
|
||||
self.posts.append(post)
|
||||
|
||||
# Show desktop notification
|
||||
content_preview = post.get_content_text()[:100] + "..." if len(post.get_content_text()) > 100 else post.get_content_text()
|
||||
|
||||
if notification_type == 'mention':
|
||||
self.notification_manager.notify_mention(sender, content_preview)
|
||||
elif notification_type == 'reblog':
|
||||
self.notification_manager.notify_boost(sender, content_preview)
|
||||
elif notification_type == 'favourite':
|
||||
self.notification_manager.notify_favorite(sender, content_preview)
|
||||
elif notification_type == 'follow':
|
||||
# Handle follow notifications without status
|
||||
self.notification_manager.notify_follow(sender)
|
||||
except Exception as e:
|
||||
print(f"Error parsing notification: {e}")
|
||||
continue
|
||||
else:
|
||||
# Handle regular timeline data structure
|
||||
new_posts = []
|
||||
for status_data in timeline_data:
|
||||
try:
|
||||
post = Post.from_api_dict(status_data)
|
||||
self.posts.append(post)
|
||||
new_posts.append(post)
|
||||
except Exception as e:
|
||||
print(f"Error parsing post: {e}")
|
||||
continue
|
||||
|
||||
# Show timeline update notification if new posts were loaded
|
||||
if new_posts and len(new_posts) > 0:
|
||||
timeline_name = {
|
||||
'home': 'home timeline',
|
||||
'local': 'local timeline',
|
||||
'federated': 'federated timeline'
|
||||
}.get(self.timeline_type, 'timeline')
|
||||
|
||||
self.notification_manager.notify_timeline_update(len(new_posts), timeline_name)
|
||||
|
||||
# Build thread structure
|
||||
self.build_threaded_timeline()
|
||||
|
||||
def build_threaded_timeline(self):
|
||||
"""Build threaded timeline from posts"""
|
||||
# Group posts by conversation
|
||||
conversations = {}
|
||||
top_level_posts = []
|
||||
|
||||
for post in self.posts:
|
||||
if post.in_reply_to_id:
|
||||
# This is a reply
|
||||
if post.in_reply_to_id not in conversations:
|
||||
conversations[post.in_reply_to_id] = []
|
||||
conversations[post.in_reply_to_id].append(post)
|
||||
else:
|
||||
# This is a top-level post
|
||||
top_level_posts.append(post)
|
||||
|
||||
# Create tree items
|
||||
for post in top_level_posts:
|
||||
post_item = self.create_post_item(post)
|
||||
self.addTopLevelItem(post_item)
|
||||
|
||||
# Add replies if any
|
||||
if post.id in conversations:
|
||||
self.add_replies(post_item, conversations[post.id], conversations)
|
||||
|
||||
# Collapse all initially
|
||||
self.collapseAll()
|
||||
|
||||
def create_post_item(self, post: Post) -> QTreeWidgetItem:
|
||||
"""Create a tree item for a post"""
|
||||
# Get display text
|
||||
summary = post.get_summary_for_screen_reader()
|
||||
|
||||
# Create item
|
||||
item = QTreeWidgetItem([summary])
|
||||
item.setData(0, Qt.UserRole, post) # Store post object
|
||||
item.setData(0, Qt.AccessibleTextRole, summary)
|
||||
|
||||
return item
|
||||
|
||||
def add_replies(self, parent_item: QTreeWidgetItem, replies, all_conversations):
|
||||
"""Recursively add replies to a post"""
|
||||
for reply in replies:
|
||||
reply_item = self.create_post_item(reply)
|
||||
parent_item.addChild(reply_item)
|
||||
|
||||
# Add nested replies
|
||||
if reply.id in all_conversations:
|
||||
self.add_replies(reply_item, all_conversations[reply.id], all_conversations)
|
||||
|
||||
def show_empty_message(self, message: str):
|
||||
"""Show an empty timeline with a message"""
|
||||
item = QTreeWidgetItem([message])
|
||||
item.setData(0, Qt.AccessibleTextRole, message)
|
||||
item.setDisabled(True) # Make it non-selectable
|
||||
self.addTopLevelItem(item)
|
||||
|
||||
def get_selected_post(self) -> Optional[Post]:
|
||||
"""Get the currently selected post"""
|
||||
current = self.currentItem()
|
||||
if current:
|
||||
return current.data(0, Qt.UserRole)
|
||||
return None
|
||||
|
||||
def get_current_post_info(self) -> str:
|
||||
"""Get information about the currently selected post"""
|
||||
current = self.currentItem()
|
||||
if not current:
|
||||
return "No post selected"
|
||||
|
||||
# Check if this is a top-level post with replies
|
||||
if current.parent() is None and current.childCount() > 0:
|
||||
child_count = current.childCount()
|
||||
expanded = "expanded" if current.isExpanded() else "collapsed"
|
||||
return f"{current.text(0)} ({child_count} replies, {expanded})"
|
||||
elif current.parent() is not None:
|
||||
return f"Reply: {current.text(0)}"
|
||||
else:
|
||||
return current.text(0)
|
||||
|
||||
def on_state_changed(self, item, state):
|
||||
"""Handle item state changes with sound feedback"""
|
||||
if state == "expanded":
|
||||
self.sound_manager.play_expand()
|
||||
elif state == "collapsed":
|
||||
self.sound_manager.play_collapse()
|
||||
|
||||
def add_new_posts(self, posts):
|
||||
"""Add new posts to timeline with sound notification"""
|
||||
# TODO: Implement adding real posts from API
|
||||
if posts:
|
||||
self.sound_manager.play_timeline_update()
|
||||
|
||||
def show_context_menu(self, position):
|
||||
"""Show context menu for post actions"""
|
||||
item = self.itemAt(position)
|
||||
if not item:
|
||||
return
|
||||
|
||||
post = item.data(0, Qt.UserRole)
|
||||
if not post:
|
||||
return
|
||||
|
||||
menu = QMenu(self)
|
||||
|
||||
# Reply action
|
||||
reply_action = QAction("&Reply", self)
|
||||
reply_action.setStatusTip("Reply to this post")
|
||||
reply_action.triggered.connect(lambda: self.reply_requested.emit(post))
|
||||
menu.addAction(reply_action)
|
||||
|
||||
# Boost action
|
||||
boost_text = "Un&boost" if post.reblogged else "&Boost"
|
||||
boost_action = QAction(boost_text, self)
|
||||
boost_action.setStatusTip(f"{boost_text.replace('&', '')} this post")
|
||||
boost_action.triggered.connect(lambda: self.boost_requested.emit(post))
|
||||
menu.addAction(boost_action)
|
||||
|
||||
# Favorite action
|
||||
fav_text = "&Unfavorite" if post.favourited else "&Favorite"
|
||||
fav_action = QAction(fav_text, self)
|
||||
fav_action.setStatusTip(f"{fav_text.replace('&', '')} this post")
|
||||
fav_action.triggered.connect(lambda: self.favorite_requested.emit(post))
|
||||
menu.addAction(fav_action)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
# View profile action
|
||||
profile_action = QAction("View &Profile", self)
|
||||
profile_action.setStatusTip(f"View profile of {post.account.display_name}")
|
||||
profile_action.triggered.connect(lambda: self.profile_requested.emit(post))
|
||||
menu.addAction(profile_action)
|
||||
|
||||
# Show menu
|
||||
menu.exec(self.mapToGlobal(position))
|
||||
|
||||
def announce_current_item(self):
|
||||
"""Announce the current item for screen readers"""
|
||||
# This will be handled by the AccessibleTreeWidget
|
||||
pass
|
Reference in New Issue
Block a user