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:
Storm Dragon
2025-07-20 03:39:47 -04:00
commit 460dfc52a5
31 changed files with 5320 additions and 0 deletions

133
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
# Bifrost package

View File

@ -0,0 +1 @@
# Accessibility widgets and helpers

View 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)

View 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

View File

@ -0,0 +1 @@
# ActivityPub client implementation

232
src/activitypub/client.py Normal file
View 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
View 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
View File

@ -0,0 +1 @@
# Audio and sound management

398
src/audio/sound_manager.py Normal file
View 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
View File

@ -0,0 +1 @@
# Configuration management

171
src/config/accounts.py Normal file
View 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
View 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
View 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
View File

@ -0,0 +1 @@
# Data models

233
src/models/post.py Normal file
View 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('&lt;', '<').replace('&gt;', '>').replace('&amp;', '&')
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
View 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('&lt;', '<').replace('&gt;', '>').replace('&amp;', '&')
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)

View File

@ -0,0 +1,7 @@
"""
Desktop notification system for Bifrost
"""
from .notification_manager import NotificationManager
__all__ = ['NotificationManager']

View 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
View File

@ -0,0 +1 @@
# UI widgets

View 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()

View 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

View 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
View 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()

View 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()

View 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