summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/pull_request_template.md17
-rw-r--r--.gitlab-ci.yml76
-rw-r--r--.travis.yml2
-rw-r--r--CHANGELOG251
-rw-r--r--COPYING684
-rw-r--r--Dockerfile2
-rw-r--r--MANIFEST.in1
-rw-r--r--README.rst96
-rw-r--r--data/default_config.cfg63
-rw-r--r--data/doap.xml687
-rw-r--r--data/io.poez.Poezio.appdata.xml55
-rw-r--r--data/io.poez.Poezio.json2
-rw-r--r--data/poezio.144
-rw-r--r--data/poezio_logo.svg34
-rw-r--r--data/poezio_logs.12
-rw-r--r--data/scripts-manpages.xml2
-rw-r--r--data/themes/clean.py193
-rw-r--r--doc/source/commands.rst94
-rw-r--r--doc/source/conf.py16
-rw-r--r--doc/source/configuration.rst165
-rw-r--r--doc/source/dev/contributing.rst6
-rw-r--r--doc/source/dev/e2ee.rst52
-rw-r--r--doc/source/dev/events.rst2
-rw-r--r--doc/source/dev/index.rst1
-rw-r--r--doc/source/dev/plugin.rst31
-rw-r--r--doc/source/dev/slix.rst4
-rw-r--r--doc/source/install.rst39
-rw-r--r--doc/source/keys.rst13
-rw-r--r--doc/source/plugins/index.rst20
-rw-r--r--doc/source/plugins/sticker.rst6
-rw-r--r--doc/source/plugins/user_extras.rst6
-rw-r--r--doc/source/themes.rst12
-rw-r--r--doc/source/usage.rst26
-rwxr-xr-xlaunch.sh16
-rw-r--r--plugins/admin.py8
-rw-r--r--plugins/alias.py4
-rw-r--r--plugins/amsg.py6
-rw-r--r--plugins/b64.py70
-rw-r--r--plugins/bob.py6
-rw-r--r--plugins/code.py10
-rw-r--r--plugins/contact.py56
-rw-r--r--plugins/day_change.py9
-rw-r--r--plugins/dice.py39
-rw-r--r--plugins/disco.py55
-rw-r--r--plugins/display_corrections.py12
-rw-r--r--plugins/embed.py30
-rw-r--r--plugins/emoji_ascii.py60
-rw-r--r--plugins/exec.py2
-rw-r--r--plugins/figlet.py22
-rw-r--r--plugins/irc.py246
-rw-r--r--plugins/lastlog.py61
-rw-r--r--plugins/link.py17
-rw-r--r--plugins/marquee.py15
-rw-r--r--plugins/mirror.py2
-rw-r--r--plugins/mpd_client.py2
-rw-r--r--plugins/otr.py151
-rw-r--r--plugins/ping.py57
-rwxr-xr-xplugins/qr.py184
-rw-r--r--plugins/quote.py18
-rw-r--r--plugins/rainbow.py2
-rw-r--r--plugins/remove_get_trackers.py24
-rw-r--r--plugins/reorder.py60
-rw-r--r--plugins/replace.py8
-rw-r--r--plugins/screen_detach.py4
-rw-r--r--plugins/send_delayed.py5
-rw-r--r--plugins/server_part.py16
-rw-r--r--plugins/simple_notify.py5
-rw-r--r--plugins/sticker.py97
-rw-r--r--plugins/stoi.py2
-rw-r--r--plugins/tell.py5
-rw-r--r--plugins/time_marker.py10
-rw-r--r--plugins/untrackme.py140
-rw-r--r--plugins/upload.py40
-rw-r--r--plugins/uptime.py30
-rw-r--r--plugins/user_extras.py634
-rw-r--r--plugins/vcard.py48
-rw-r--r--poezio/args.py61
-rw-r--r--poezio/asyncio_fix.py (renamed from poezio/asyncio.py)0
-rw-r--r--poezio/bookmarks.py161
-rw-r--r--poezio/colors.py50
-rw-r--r--poezio/common.py141
-rw-r--r--poezio/config.py293
-rw-r--r--poezio/connection.py94
-rw-r--r--poezio/contact.py65
-rw-r--r--poezio/core/command_defs.py452
-rw-r--r--poezio/core/commands.py857
-rw-r--r--poezio/core/completions.py130
-rw-r--r--poezio/core/core.py1024
-rw-r--r--poezio/core/handlers.py974
-rw-r--r--poezio/core/structs.py81
-rw-r--r--poezio/core/tabs.py98
-rwxr-xr-xpoezio/daemon.py2
-rw-r--r--poezio/decorators.py169
-rw-r--r--poezio/events.py113
-rw-r--r--poezio/fixes.py35
-rw-r--r--poezio/hsluv.py360
-rwxr-xr-xpoezio/keyboard.py6
-rw-r--r--poezio/log_loader.py395
-rw-r--r--poezio/logger.py361
-rw-r--r--poezio/mam.py211
-rw-r--r--poezio/multiuserchat.py258
-rw-r--r--poezio/pep.py207
-rw-r--r--poezio/plugin.py54
-rw-r--r--poezio/plugin_e2ee.py685
-rw-r--r--poezio/plugin_manager.py99
-rw-r--r--poezio/poezio.py37
-rw-r--r--poezio/poezio_shlex.pyi45
-rw-r--r--poezio/poopt.py185
-rw-r--r--poezio/poopt.pyi7
-rw-r--r--poezio/pooptmodule.c2
-rw-r--r--poezio/py.typed0
-rw-r--r--poezio/roster.py60
-rw-r--r--poezio/size_manager.py12
-rw-r--r--poezio/tabs/adhoc_commands_list.py4
-rw-r--r--poezio/tabs/basetabs.py549
-rw-r--r--poezio/tabs/bookmarkstab.py48
-rw-r--r--poezio/tabs/confirmtab.py6
-rw-r--r--poezio/tabs/conversationtab.py288
-rw-r--r--poezio/tabs/data_forms.py6
-rw-r--r--poezio/tabs/listtab.py8
-rw-r--r--poezio/tabs/muclisttab.py8
-rw-r--r--poezio/tabs/muctab.py1461
-rw-r--r--poezio/tabs/privatetab.py338
-rw-r--r--poezio/tabs/rostertab.py717
-rw-r--r--poezio/tabs/xmltab.py67
-rw-r--r--poezio/text_buffer.py336
-rwxr-xr-xpoezio/theming.py65
-rw-r--r--poezio/timed_events.py10
-rw-r--r--poezio/types.py8
-rw-r--r--poezio/ui/__init__.py0
-rw-r--r--poezio/ui/consts.py4
-rw-r--r--poezio/ui/funcs.py (renamed from poezio/windows/funcs.py)14
-rw-r--r--poezio/ui/render.py280
-rw-r--r--poezio/ui/types.py260
-rw-r--r--poezio/user.py44
-rw-r--r--poezio/utils.py21
-rw-r--r--poezio/version.py2
-rw-r--r--poezio/windows/__init__.py4
-rw-r--r--poezio/windows/base_wins.py60
-rw-r--r--poezio/windows/bookmark_forms.py96
-rw-r--r--poezio/windows/data_forms.py39
-rw-r--r--poezio/windows/image.py56
-rw-r--r--poezio/windows/info_bar.py103
-rw-r--r--poezio/windows/info_wins.py153
-rw-r--r--poezio/windows/input_placeholders.py2
-rw-r--r--poezio/windows/inputs.py50
-rw-r--r--poezio/windows/list.py24
-rw-r--r--poezio/windows/misc.py8
-rw-r--r--poezio/windows/muc.py22
-rw-r--r--poezio/windows/roster_win.py116
-rw-r--r--poezio/windows/text_win.py578
-rw-r--r--poezio/xdg.py6
-rw-r--r--poezio/xhtml.py31
-rw-r--r--requirements-plugins.txt1
-rw-r--r--requirements.txt1
-rwxr-xr-xscripts/poezio_logs53
-rwxr-xr-xsetup.py147
-rw-r--r--test/test_common.py23
-rw-r--r--test/test_completion.py10
-rw-r--r--test/test_config.py16
-rw-r--r--test/test_logger.py98
-rw-r--r--test/test_tabs.py50
-rw-r--r--test/test_text_buffer.py200
-rw-r--r--test/test_ui/test_funcs.py46
-rw-r--r--test/test_ui/test_render.py144
-rw-r--r--test/test_ui/test_types.py126
-rw-r--r--test/test_user.py44
-rw-r--r--test/test_windows.py20
-rw-r--r--test/test_xhtml.py6
-rw-r--r--tools/sticker-picker/Cargo.toml16
-rw-r--r--tools/sticker-picker/src/main.rs93
-rw-r--r--tools/sticker-picker/src/sticker.rs106
-rwxr-xr-xupdate.sh8
173 files changed, 13694 insertions, 6182 deletions
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..cd50ef46
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,17 @@
+Please use Gitlab instead of Github
+###################################
+
+Hello, thank you for contributing to poezio!
+
+You’re about to open a pull request on github. However this github repository is *not* the official
+place for contributions on poezio.
+
+Please open your merge request on https://lab.louiz.org/poezio/poezio/
+
+You should be able to log in there with your github credentials, clone the poezio repository in your namespace,
+push your existing pull request into a new branch, and then open a merge request with one click, within 3 minutes.
+
+This will help us review your contribution, avoid spreading things everywhere and it will even run the tests
+automatically with your changes.
+
+Thank you.
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 93129d0d..e8bd5415 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,17 +1,19 @@
stages:
+ - lint
- test
- build
build-doc:
stage: build
only:
- - branches@poezio/poezio
+ - main@poezio/poezio
tags:
- www
- image: docker.louiz.org/poezio/poezio/doc-builder
+ image: python:3
script:
- ./update.sh
- . ./poezio-venv/bin/activate
+ - pip install sphinx --ignore-installed
- make doc
- rm -rf /www/latest
- mv doc/build/html/ /www/latest
@@ -27,21 +29,21 @@ build-ubuntu:
paths:
- dist/
only:
- - master
+ - main
security-check:
- stage: test
+ stage: lint
image: python:3
script:
- pip3 install safety
- safety check -r requirements.txt
-pytest-3.5:
+pytest-3.7:
stage: test
- image: python:3.5
+ image: python:3.7
script:
- apt-get update && apt-get install -y libidn11-dev
- - git clone git://git.louiz.org/slixmpp
+ - git clone https://lab.louiz.org/poezio/slixmpp.git
- pip3 install pytest pyasn1-modules cffi --upgrade
- cd slixmpp
- python3 setup.py install
@@ -49,12 +51,12 @@ pytest-3.5:
- python3 setup.py install
- py.test -v test/
-pytest-3.6:
+pytest-3.8:
stage: test
- image: python:3.6
+ image: python:3.8
script:
- apt-get update && apt-get install -y libidn11-dev
- - git clone git://git.louiz.org/slixmpp
+ - git clone https://lab.louiz.org/poezio/slixmpp.git
- pip3 install pytest pyasn1-modules cffi --upgrade
- cd slixmpp
- python3 setup.py install
@@ -62,12 +64,12 @@ pytest-3.6:
- python3 setup.py install
- py.test -v test/
-pytest-latest:
+pytest-3.9:
stage: test
- image: python:3
+ image: python:3.9
script:
- apt-get update && apt-get install -y libidn11-dev
- - git clone git://git.louiz.org/slixmpp
+ - git clone https://lab.louiz.org/poezio/slixmpp.git
- pip3 install pytest pyasn1-modules cffi --upgrade
- cd slixmpp
- python3 setup.py install
@@ -75,14 +77,58 @@ pytest-latest:
- python3 setup.py install
- py.test -v test/
-pylint-plugins:
+pytest-3.10:
+ stage: test
+ image: python:3.10
+ script:
+ - apt-get update && apt-get install -y libidn11-dev
+ - git clone https://lab.louiz.org/poezio/slixmpp.git
+ - pip3 install pytest pyasn1-modules cffi --upgrade
+ - cd slixmpp
+ - python3 setup.py install
+ - cd ..
+ - python3 setup.py install
+ - py.test -v test/
+
+pytest-3.11:
stage: test
+ image: python:3.11-rc
+ allow_failure: true
+ script:
+ - apt-get update && apt-get install -y libidn11-dev
+ - git clone https://lab.louiz.org/poezio/slixmpp.git
+ - pip3 install pytest pyasn1-modules cffi --upgrade
+ - cd slixmpp
+ - python3 setup.py install
+ - cd ..
+ - python3 setup.py install
+ - py.test -v test/
+
+pylint-plugins:
+ stage: lint
image: python:3
allow_failure: true
script:
- apt-get update && apt-get install -y libidn11-dev
- pip3 install pylint pyasn1-modules cffi --upgrade
- - pip3 install -e git+git://git.louiz.org/slixmpp#egg=slixmpp
+ - pip3 install -e git+https://lab.louiz.org/poezio/slixmpp.git#egg=slixmpp
- pip3 install -r requirements-plugins.txt
- python3 setup.py install
- pylint -E plugins
+
+mypy-fixed:
+ stage: lint
+ image: python:3
+ script:
+ - pip3 install mypy==0.971 types-setuptools
+ - mypy --ignore-missing-imports ./poezio
+ - mypy --ignore-missing-imports ./plugins
+
+mypy-latest:
+ stage: lint
+ image: python:3
+ allow_failure: true
+ script:
+ - pip3 install mypy types-setuptools
+ - mypy --ignore-missing-imports ./poezio
+ - mypy --ignore-missing-imports ./plugins
diff --git a/.travis.yml b/.travis.yml
index 923df969..3877ad41 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,7 +1,5 @@
language: python
python:
- - "3.5"
- - "3.6"
- "3.7"
- "3.8-dev"
install:
diff --git a/CHANGELOG b/CHANGELOG
index 1923306f..9480db1b 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,8 +1,253 @@
This file describes the new features in each poezio release.
-For more detailed changelog, see the roadmap:
-https://dev.louiz.org/projects/poezio/roadmap
-* Poezio 0.13 - dev
+* Poezio XXX-dev
+
+* Poezio 0.14
+
+# Configuration changes:
+
+- ADDED: `mam_sync`, `mam_sync_limit`.
+- ADDED: `autocolor_tab_names`.
+- CHANGED: `ca_cert_path` is commented out. Poezio will attempt to guess
+ common CA bundle paths. The option can be used to overwrite this behaviour.
+- REMOVED: `force_remote_bookmarks`.
+- REMOVED: options related to activity, mood, gaming, tune:
+ `enable_user_activity`, `enable_user_mood`, `enable_user_gaming`,
+ `enable_user_tune`, `display_activity_notifications`,
+ `display_mood_notifications`, `display_gaming_notifications`,
+ `display_tune_notifications`.
+- REMOVED: `deterministic_muc_colors`.
+
+# Changes
+
+- DEPRECATION: /leave is deprecated. Use /part or /close instead.
+- REMOVED: rich presence (activity, mood, gaming, tune) from poezio, alongside
+ with configuration options and commands: /activity, /mood,
+ and /gaming. These are moved to the new user_extras plugin.
+- REMOVED: non-deterministic nick colors in MUC.
+- REMOVED: XEP-0319 support removed for privacy and performance.
+- REMOVED: Support for pypy3 with the removal of poopt.py
+- ADDED: New /request_voice command for moderated rooms
+- ADDED: /join: support using an XMPP URI (xmpp:...?join)
+- ADDED: /destroy_room: new optional reason and altroom arguments
+- ADDED: Newlines now considered as word separator in input manipulation (#3411)
+- ADDED: Colored tab names or number using Consistent Color Generation behind
+ `autocolor_tab_names` (thanks jonas!)
+- Stop highlighting on MUC history messages just because they're delayed
+- Stop displaying the traceback in debug log when /xhtml fails
+- Ensure launch.sh can be use outside the repository (Thanks kaliko)
+- Detect `/set option = value` pattern and do as it `/set option value` was
+ called. (#3517)
+- Newlines taken into account on completion (#3385)
+- Allow resizing certificate confirmation window (#3371)
+- Make theming more configurable around nicks
+- Log MUC <destroy/> payload in the information buffer
+- impromptu: wait for room subject before configuring and inviting
+- Impromptu: rooms now have pronouceable short names
+- impromptu: ensure a room is empty before joining
+- impromptu: now uses mediated invites
+- Fetch from MAM by default when use_log is False.
+- xhtml: Add a new line after a blockquote
+
+## Bug fixes
+
+- Ensure bookmark is present before removing it in /close.
+- Ensure bookmarks are saved correctly on method config change, and on /close.
+- Ensure nick is added to bookmark when specified
+- Do not crash on bookmark without a nickname (Thanks Ge0rG)
+- Ensure the correct tab is bookmarked on /bookmark and /join
+- /bookmark: treat empty nick as no nick to avoid failing on empty resource
+- Fix closing a tab not in bookmarks
+- Disco barejid instead of domains on sent carbons. Follow-up of 5e40437.
+ (Thanks Ge0rG)
+- Only use JID internally when handling affiliations. Add nick if present.
+ (#3536)
+- /last_activity: prevent traceback
+- Fix bad error handling when checking bookmarks storage
+- `/join / password` works again
+- Report available presence in tabs correctly
+- /display_correction: now reports the correct time for private messages (#3532)
+- Fix composing indicators not showing (#3527)
+- Fix pasting text in data forms and bookmarkstab (#3519)
+- Fix /me logging (#3432)
+- Retrieve nick colors from the correct section
+- Do not scroll right by default in dataforms/bookmark text
+- Hack around the time limit for topic messages
+- Ensure MUC-PM logging filenames are generated as expected (Thanks Ge0rG, southerntofu)
+- Fix poezio displaying many times the same participant in the user list.
+- Fix default dataform field handling (#3554)
+- Fix MUCListTab not joining selected MUCs (#3553)
+- Fix /color completion (Thanks eijebong)
+- /info: Don't display comma before status message if not available
+
+## Plugins
+
+- ADDED: untrackme plugin. based on remove_get_trackers.
+- ADDED: user_extras plugin. /activity, /mood and /gaming moved from core.
+- ADDED: sticker plugin.
+- DEPRECATED: remove_get_trackers
+- REMOVED: /irc_login from the irc plugin.
+- Reorder: Prevent GapTabs from being serialized and ignore when serialized as
+ they're recreated automatically.
+- Code: prevent traceback when not enough arguments
+- Link: Add support for aesgcm, gemini and gopher URIs
+- Contact: iterate all data forms (Thanks Ge0rG)
+- Fix plugins (embed, lastlog, otr, quote, time_marker) to use poezio.ui.types
+- Disco: Added error handling
+- IRC: Fix the plugin to work with the various refactors, and use
+ irc.jabberfr.org as a default gateway
+
+## API
+
+- BREAKING: E2EEPlugin.decrypt's `tab` parameter is now of type
+ Optional[ChatTab] instead of ChatTab.
+- BREAKING: E2EEPlugin.supported_tab_types is now required
+- BREAKING: decrypt method is now async
+- E2EEPlugin decrypts encrypted messages even when they have no body.
+- E2EEPlugin lets through already encrypted messages without giving them
+ to the user lib (poezio-omemo, for example).
+- Correctly pass realjid to decrypt call for MUC messages
+- /<encryption_name>_fingerprint command is added. Plugins can implement
+ `get_fingerprints` and `format_fingerprint` for it to return a (formatted)
+ value.
+
+# Under the hood
+
+- Moved development from 'master' to 'main' branch
+- Lots of type hints added (decorators, multiuserchat, shlex, common, muctab,
+ etc.) fixing many bugs
+- Lots of event handlers and calls are now async in poezio. Many callbacks removed.
+- Lots of refactoring
+- Performance improvements:
+ - Trim all messages by 24 bytes on 64-bit systems
+ - Reduce log parsing by a lot
+- No more safeJID calls. (#3457)
+- Rework some features to use slixmpp's API instead of custom poezio code
+ (i.e., muc's set_subject, set_role, set_affiliation, destroy_room,
+ cancel_config, set_room_config, and most events)
+- Split commands from Core
+- Require typing_extensions package for python
+- Require setuptools package explicitly because of pkg_resources' import
+ (Thanks Thomas)
+- Replace asyncio.ensure_future with asyncio.create_task
+
+# Meta
+
+- Improve README, badges, new text, more links
+- Update install.txt with instructions for guix (Thanks Raghav)
+- Remove references to dev.louiz.org. Everything is happening at
+ lab.louiz.org.
+
+* Poezio 0.13.1
+
+# Bug fixes
+
+- Contacts won’t be seen playing games or music when they actually stop doing
+ so.
+- /leave now toggles off the autojoin flag instead of removing the bookmark.
+- Only add auotjoin on new bookmarks for synchronise_open_rooms on /join
+- /affiliation displays things in the info win instead of directly in the room,
+ and additionally displays which room it refers to.
+- List the correct required versions for package maintainers.
+- Fix the AppStream manifest to get Flathub to accept it.
+- Add a warning when the terminal doesn’t support 256color mode.
+- Display our own nick properly in messages received from MAM.
+- Only send an unavailable presence on closing a room if we are joined.
+- Don’t display the current date for history messages received today.
+- Fix marquee and dice plugin to use newer Last Message Correction format.
+- Bookmarks tab properly displays bookmarks method (local/remote) and allows
+ to switch between them again.
+- Updated manpages and added manpages built from the doc in the setup process.
+ Packagers need to run setup.py build_man to have them built.
+
+# Forgotten additions
+
+- Add a clean theme for light terminals, thanks Armael!
+
+* Poezio 0.13
+
+# Thanks
+
+- madhur for the MAM code and various other fixes (GSoC 2019)
+- Ge0rG for fixes in the reconnect and carbons code
+- fiaxh for the nice SVG logo
+
+Zash, PS Le Stang, Karthikeyan Singaravelan, Jonas Schäfer, Célestin Matte,
+Andrey Utkin, root.
+
+# Meta changes
+
+- Packages: poezio is now packaged for Gentoo and Debian buster
+ (poezio v0.12.1)
+- Source: the repository is now hosted at https://lab.louiz.org/poezio/poezio
+
+# Breaking changes
+
+- BREAKING: requires python3.7 (previously python 3.5)
+- DEPRECATION: `load_log` configuration was removed
+- DEPRECATION: `bookmark_on_join` configuration option was renamed
+ `synchronise_open_rooms`, and now defaults to true. This makes /join
+ automatically create a bookmark, use /leave to remove it
+
+# New features and plugins
+
+- `/impromptu <jid> [jid ..]` command to create a new chat with these persons
+- `/scrollback` to scroll back to a specific line/message in the current window
+- `/invite <jid> [jid ..]` command in single user chat does like `/impromptu`
+ creating a new MUC
+- `/affiliation` command now returns the list of privileged users on the
+ current MUC when no argument is supplied
+- `default_muc_service` configuration for replacing the server's default MUC
+- `unique_prefix_tab_names` display option to show the shorter tab name
+ prefixes in the tab list
+- `/list` defaults to `default_muc_service` when no argument is specified
+- XEP-0392 support
+- New Plugin: OMEMO (experimental) - lives as an external plugin at
+ https://lab.louiz.org/poezio/poezio-omemo
+- New Plugin: Contact - queries an entity for contact addresses (XEP-0157)
+- New Plugin: Upload - adds a `/upload <filename>` command in chats for HTTP
+ upload
+- New Plugin: remove_get_trackers - Remove GET trackers from URLs in sent
+ messages.
+- New Plugin: QR
+
+
+# Under the hood
+
+- Plugin API: `E2EEPlugin` is a new experimental API for message encryption
+ (used by the OMEMO plugin)
+- Plugin API: plugins can now declare a string list of dependencies
+- Plugin API: plugins can now be setup using a well-known setuptools entrypoint
+- Reworked Tab handling
+- Split Message rendering
+- Typing improvements
+
+# Minor changes (bugfixes)
+
+- Properly advertize gaming status
+- Improve error reporting to users
+- Add SVG support for avatars
+- Plugin: Disco - allow node to be specified
+- Don't always treat carbons from biboumi as MUC-PMs (#3705)
+- Read newer Last Message Correction rules while still reading older ones
+ (#3462)
+- Allow /block and /unblock in ConversationTab (#3346)
+- Experimental: Fetch archives (MAM) on scroll up in MUC (#3052)
+- Allow /add in ConversationTab (#3395)
+- Prevent Chat State Notifications from being stored in the archive (#3518)
+- Rework MUC-PM Carbons handling (#3294)
+- /scrollback (#3481)
+- Read <delay/> in <subject/> (#3451)
+- Only use MUC <subject/> in specific cases (#3452)
+- Allow /reconnect in all tabs (#3471)
+- Allow /embed in chat tabs (#3449)
+- Allow /upload in chat tabs
+- Properly identify MUC-PMs for normal messages and chatstates (#3491)
+- /server_cycle: stricly match specified domain (#3412)
+- Breaking: -v/--version previously for internal purposes now returns version
+ as expected (#3429)
+- Improve highlighting regex (#3433)
+- Generate static resource at first launch (#3400)
* Poezio 0.12
diff --git a/COPYING b/COPYING
index 589e4764..20d40b6b 100644
--- a/COPYING
+++ b/COPYING
@@ -1,20 +1,674 @@
-Copyright (c) 2010-2018 Florent Le Coz, Mathieu Pasquet and Emmanuel Gil Peyrot
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
-This software is provided 'as-is', without any express or implied
-warranty. In no event will the authors be held liable for any damages
-arising from the use of this software.
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
-Permission is granted to anyone to use this software for any purpose,
-including commercial applications, and to alter it and redistribute it
-freely, subject to the following restrictions:
+ Preamble
- 1. The origin of this software must not be misrepresented; you must not
- claim that you wrote the original software. If you use this software
- in a product, an acknowledgment in the product documentation would be
- appreciated but is not required.
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
- 2. Altered source versions must be plainly marked as such, and must not be
- misrepresented as being the original software.
+ 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.
- 3. This notice may not be removed or altered from any source
- distribution.
+ 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 <http://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
+<http://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
+<http://www.gnu.org/philosophy/why-not-lgpl.html>. \ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index 0283483f..6b2bb7fb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,7 +4,7 @@ RUN apk add --update build-base git python3 python3-dev libidn-dev && python3 -m
WORKDIR /tmp/
ARG version=HEAD
# Don’t ADD local files in order to keep layers at a minimal size
-RUN git clone https://git.poez.io/poezio.git poezio-git-dir && \
+RUN git clone https://lab.louiz.org/poezio/poezio.git poezio-git-dir && \
cd poezio-git-dir && \
git archive --prefix="poezio-archive-${version}/" -o /tmp/poezio-archive.tar "${version}" && \
cd /tmp/ && tar xvf poezio-archive.tar && \
diff --git a/MANIFEST.in b/MANIFEST.in
index 962aa000..6f4000db 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,4 +1,5 @@
recursive-include doc/source *
+recursive-include tools
include data/poezio.1
include data/io.poez.Poezio.appdata.xml
include data/io.poez.Poezio.desktop
diff --git a/README.rst b/README.rst
index 454d2f42..d377c82a 100644
--- a/README.rst
+++ b/README.rst
@@ -1,35 +1,52 @@
poezio
======
+.. image:: https://lab.louiz.org/poezio/poezio/-/raw/main/data/poezio_logo.svg
+ :alt: Poezio logo
+ :width: 200
+
+|pipeline| |python versions| |license|
+
+|discuss|
+
Homepage: https://poez.io
-Forge Page: https://dev.poez.io
+Forge Page: https://lab.louiz.org/poezio/poezio
-Poezio is a console Jabber/XMPP client. Its goal is to use anonymous
-connections to simply let the user join MultiUserChats. This way, the user
-doesn't have to create a Jabber account, exactly like people are using
-IRC. Poezio's commands are designed to be (if possible) like commonly
-used IRC clients (weechat, irssi, etc).
+Poezio is a console Jabber/XMPP client. The initial goal was to provide a
+way of connecting easily to XMPP without the need for an account, exactly like
+IRC clients. Poezio's commands are also designed to be close, if possible,
+to the ones commonly used in IRC clients (weechat, irssi, etc).
-Since version 0.7, poezio can handle real Jabber accounts along with
-roster and one-to-one conversations, making it a full-featured console
-Jabber client, but still MultiUserChats-centered.
-In the future, poezio should implement at a 100% level all XEP related to
-MUCs, especially XEP 0045.
+For this reason, the experience is still centered around chatrooms, despite
+poezio being a full-featured XMPP client for a very long while.
Install
-=======
+-------
+
+Packages
+~~~~~~~~
+
+The stable version of poezio is packaged in
+`a number of GNU/Linux (and OpenBSD) distributions <https://doc.poez.io/install.html#poezio-in-the-gnu-linux-distributions>`_.
+
+
+If it is not packaged in your distribution, you can run the
+`flatpak <https://flathub.org/apps/details/io.poez.Poezio>`_ or use pip
+to install the package from `Pypi <https://pypi.org/project/slixmpp/>`_.
+
+
+From git
+~~~~~~~~
+
+`Documentation <https://doc.poez.io/install.html#install-from-source>`_
-You need python 3.5 or higher (preferably the latest) and the associated devel
+
+You need python 3.7 or higher (preferably the latest) and the associated devel
package, to build C modules, and the slixmpp python library.
You also need aiodns if you want SRV record support.
-Additionally, you’ll need sphinx to build the documentation pages.
-To read the documentation without these dependancies just read the rst
-files in the doc/source/ directory or the generated documentation on the
-website.
-
-The simplest way to have up-to-date dependencies and to be able to test
+The easiest way to have up-to-date dependencies and to be able to test
this developement version is to use the ``update.sh`` script that downloads
them, places them in the right directory, and builds the C module.
@@ -39,7 +56,6 @@ You can then launch poezio with
$ ./launch.sh
-you can now simply launch ``poezio``
You can edit the configuration file which is located in
``~/.config/poezio/poezio.cfg`` by default, and you will have to copy
@@ -58,48 +74,50 @@ Please DO report any bug you encounter and ask for any feature you want
(we may implement it or not, but it’s always better to ask).
Authors
-=======
+-------
- Florent Le Coz (louiz’) <louiz@louiz.org> (developer)
- Mathieu Pasquet (mathieui) <mathieui@mathieui.net> (developer)
- Emmanuel Gil Peyrot (Link Mauve) <linkmauve@linkmauve.fr> (developer)
+- Maxime Buquet (pep.) <pep@bouah.net> (developer)
Contact/support
-===============
+---------------
-Jabber ChatRoom: `poezio@muc.poez.io <xmpp:poezio@muc.poez.io?join>`_
+Jabber chat room: `poezio@muc.poez.io <xmpp:poezio@muc.poez.io?join>`_
+(`web chat`_)
-Report a bug: https://dev.poez.io/new
+Report a bug: https://lab.louiz.org/poezio/poezio/issues/new
License
-=======
+-------
Poezio is Free Software.
(learn more: http://www.gnu.org/philosophy/free-sw.html)
-Poezio is released under the zlib License.
+Poezio is released under the GPL-3.0+ License.
Please read the COPYING file for details.
The artwork logo was made by Gaëtan Ribémont and released under
-the Creative Commons BY license (http://creativecommons.org/licenses/by/2.0/)
+the `Creative Commons BY license <http://creativecommons.org/licenses/by/2.0/>`_.
Hacking
-=======
+-------
If you want to contribute, you will be welcome on
-`poezio@muc.poez.io <xmpp:poezio@muc.poez.io?join>`_ to announce your
-ideas, what you are going to do, or to seek help if you have trouble
-understanding some of the code.
+`poezio@muc.poez.io <xmpp:poezio@muc.poez.io?join>`_ (`web chat`_)
+to announce your ideas, what you are going to do, or to seek help if you have
+trouble understanding some of the code.
The preferred way to submit changes is through a merge request on gitlab,
at https://lab.louiz.org/poezio/poezio, but we also accept contributions
on github, or with a simple “please fetch my code on my personal git
-repository hosted somewhere”
+repository hosted somewhere”.
Thanks
-======
+------
- People:
- Todd Eisenberger - Plugin system and OTR support
@@ -124,3 +142,15 @@ Thanks
- FlashCode (weechat dev) - Useful advices on how to use ncurses efficiently
- And all the people using and testing poezio, and especially the ones present
on the jabber chatroom doing bug reports and/or feature requests.
+
+
+.. |pipeline| image:: https://lab.louiz.org/poezio/poezio/badges/main/pipeline.svg
+
+.. |python versions| image:: https://img.shields.io/pypi/pyversions/poezio.svg
+
+.. |license| image:: https://img.shields.io/badge/license-gpl--3.0--or--later-blue.svg
+
+.. |discuss| image:: https://inverse.chat/badge.svg?room=poezio@muc.poez.io
+ :target: https://chat.jabberfr.org/converse.js/poezio@muc.poez.io
+
+.. _web chat: https://chat.jabberfr.org/converse.js/poezio@muc.poez.io
diff --git a/data/default_config.cfg b/data/default_config.cfg
index 908a3d70..8e926c0e 100644
--- a/data/default_config.cfg
+++ b/data/default_config.cfg
@@ -79,12 +79,14 @@ certificate =
# value to the services default.
#whitespace_interval = 300
-# Path to the certificate authenticating the Authority
+# Path to the certificate authenticating the Authority.
# A server may have several certificates, but if it uses a CA, it will often
# keep the same for obvious reasons, so this is a good option if your server
# does this, rather than skipping all verifications.
# This is not affected by ignore_certificate
-ca_cert_path =
+# Poezio attempts to guess this value automatically if empty. To override this
+# behaviour, set the value to another path.
+#ca_cert_path =
# Auto-reconnects you when you get disconnected from the server
#auto_reconnect = true
@@ -132,17 +134,13 @@ use_bookmarks_method =
# possible values are: anything/false
#use_remote_bookmarks = true
-# Force the retrieval of the remote bookmarks even when the server
-# doesn't advertise support for your method
-#force_remote_bookmarks = false
-
# Whether you want all bookmarks, even those without
# autojoin, to be open on startup
#open_all_bookmarks = false
# Will create a bookmark on manual /join, using your preferred
-# storage method
-#bookmark_on_join = false
+# storage method. Use /leave to remove the bookmark.
+#synchronise_open_rooms = true
# What will be put after the name, when using autocompletion at the
# beginning of the input. A space will always be added after that
@@ -253,6 +251,9 @@ use_bookmarks_method =
# in files.
#use_log = true
+# set to 'false' to not sync the local lgos with the MAM server history
+#mam_sync = true
+
# The number of lines to preload in a chat buffer when it opens
# (the lines are preloaded from the log files)
# 0 or a negative value disable that option
@@ -408,9 +409,6 @@ use_bookmarks_method =
# possible values: desc, asc
#user_list_sort = desc
-# If the chatroom nicks should receive a fixed color based on their text or not
-#deterministic_nick_colors = true
-
# If _nick, nick_, _nick_, nick__ etc. should have the same color as nick
#nick_color_aliases = true
@@ -418,9 +416,6 @@ use_bookmarks_method =
# will be displayed using their nick color if true.
#display_user_color_in_join_part = true
-# Display user tune notifications as information messages or not
-#display_tune_notifications = false
-
# Change the tab state when receiving chatroom messages.
# useful if you are connected to a high-volume chatroom and do not
# want it to appear in your tab bar as active. Highlights are still
@@ -453,39 +448,6 @@ use_bookmarks_method =
# defaults to $XDG_CACHE_HOME/poezio/images.
#tmp_image_dir =
-# Receive the tune notifications or not (in order to display information
-# in the contact list).
-# If this is set to false, then the display_tune_notifications
-# option will be ignored.
-#enable_user_tune = true
-
-# Display user gaming notifications as information messages or not
-#display_gaming_notifications = false
-
-# Receive the gaming notifications or not (in order to display information
-# in the contact list).
-# If this is set to false, then the display_gaming_notifications
-# option will be ignored.
-#enable_user_gaming = true
-
-# Display user mood notifications as information messages or not
-#display_mood_notifications = false
-
-# Receive the mood notifications or not (in order to display information
-# in the contact list).
-# If this is set to false, then the display_mood_notifications
-# option will be ignored.
-#enable_user_mood = true
-
-# Display user activity notifications as information messages or not
-#display_activity_notifications = false
-
-# Receive the activity notifications or not (in order to display information
-# in the contact list).
-# If this is set to false, then the display_activity_notifications
-# option will be ignored.
-#enable_user_activity = true
-
# If set to true, use the nickname broadcasted by the user if none has been
# set manually.
#enable_user_nick = true
@@ -548,6 +510,13 @@ use_bookmarks_method =
# “true” should be the most comfortable value
#lazy_resize = true
+# If set to true and if show_tab_names is set, the info bar will only show
+# the unique prefix of each tab name instead of the full name. This saves a
+# lot of space if many tabs exist or are active.
+# Best used with the `/wup` command or the `_go_to_room_name` action to select
+# a tab based on the prefix.
+#unique_prefix_tab_names = false
+
[bindings]
# Bindings are keyboard shortcut aliases. You can use them
# to define your own keys and bind them with some functions
diff --git a/data/doap.xml b/data/doap.xml
new file mode 100644
index 00000000..6a1330b7
--- /dev/null
+++ b/data/doap.xml
@@ -0,0 +1,687 @@
+<?xml version="1.0"?>
+<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+<Project xmlns="http://usefulinc.com/ns/doap#" xmlns:foaf="http://xmlns.com/foaf/0.1/" xmlns:xmpp="https://linkmauve.fr/ns/xmpp-doap#" xmlns:schema="https://schema.org/">
+ <name>poezio</name>
+
+ <created>2010-01-10</created>
+
+ <shortdesc xml:lang="en">Free console XMPP client</shortdesc>
+ <shortdesc xml:lang="fr">Client XMPP libre en console</shortdesc>
+
+ <description xml:lang="en">Free and modern console XMPP client written in Python with the ncurses library</description>
+ <description xml:lang="fr">Client console XMPP libre et moderne, écrit en Python avec la bibliothèque ncurses</description>
+
+ <homepage rdf:resource="https://poez.io/"/>
+ <!-- Until https://github.com/ewilderj/doap/issues/51 is done, we’ll use schema.org instead. -->
+ <schema:documentation rdf:resource="https://doc.poez.io/"/>
+ <download-page rdf:resource="https://poez.io/#download"/>
+ <bug-database rdf:resource="https://lab.louiz.org/poezio/poezio/-/issues"/>
+ <!-- See https://github.com/ewilderj/doap/issues/53 -->
+ <developer-forum rdf:resource="xmpp:poezio@muc.poez.io?join"/>
+ <support-forum rdf:resource="xmpp:poezio@muc.poez.io?join"/>
+
+ <license rdf:resource="https://lab.louiz.org/poezio/poezio/-/raw/main/COPYING"/>
+
+ <!-- See https://github.com/ewilderj/doap/issues/49 -->
+ <language>en</language>
+
+ <!-- Until https://github.com/ewilderj/doap/pull/68 is merged, we’ll use schema.org instead. -->
+ <schema:logo rdf:resource="https://poez.io/img/logo.svg"/>
+ <!-- Same here, see https://github.com/ewilderj/doap/issues/50 -->
+ <schema:screenshot rdf:resource="https://poez.io/img/screenshot.png"/>
+
+ <programming-language>Python</programming-language>
+
+ <os>Linux</os>
+ <os>macOS</os>
+ <os>FreeBSD</os>
+ <os>OpenBSD</os>
+ <os>NetBSD</os>
+
+ <category rdf:resource="https://linkmauve.fr/ns/xmpp-doap#category-client"/>
+ <!-- TODO: Find a good set of resource URIs for these. -->
+ <category rdf:resource="https://example.com/doap#category-console"/>
+ <category rdf:resource="https://example.com/doap#category-ncurses"/>
+
+ <maintainer>
+ <foaf:Person>
+ <foaf:name>Link Mauve</foaf:name>
+ <foaf:homepage rdf:resource="https://linkmauve.fr/"/>
+ <foaf:mbox_sha1sum>aaa4dac2b31c1be4ee8f8e2ab986d34fb261974f</foaf:mbox_sha1sum>
+ </foaf:Person>
+ </maintainer>
+ <maintainer>
+ <foaf:Person>
+ <foaf:name>louiz’</foaf:name>
+ <foaf:homepage rdf:resource="https://louiz.org/"/>
+ <foaf:mbox_sha1sum>a867767905969a4915147374e3a064f97cdf5d61</foaf:mbox_sha1sum>
+ </foaf:Person>
+ </maintainer>
+ <maintainer>
+ <foaf:Person>
+ <foaf:name>mathieui</foaf:name>
+ <foaf:homepage rdf:resource="https://mathieui.net/"/>
+ <foaf:mbox_sha1sum>c14292b375a7cec3f39872aa8524c66a1d9106cf</foaf:mbox_sha1sum>
+ </foaf:Person>
+ </maintainer>
+ <maintainer>
+ <foaf:Person>
+ <foaf:name>pep.</foaf:name>
+ <foaf:homepage rdf:resource="https://bouah.net/"/>
+ <foaf:mbox_sha1sum>29ed31759e39e0da3f3634e91b667275ba5e4ac6</foaf:mbox_sha1sum>
+ </foaf:Person>
+ </maintainer>
+
+ <repository>
+ <GitRepository>
+ <browse rdf:resource="https://lab.louiz.org/poezio/poezio"/>
+ <location rdf:resource="https://lab.louiz.org/poezio/poezio.git"/>
+ </GitRepository>
+ </repository>
+
+ <implements rdf:resource="https://xmpp.org/rfcs/rfc6120.html"/>
+ <implements rdf:resource="https://xmpp.org/rfcs/rfc6121.html"/>
+ <implements rdf:resource="https://xmpp.org/rfcs/rfc6122.html"/>
+ <implements rdf:resource="https://xmpp.org/rfcs/rfc7590.html"/>
+ <!-- TODO: Report a bug to support that in poezio. -->
+ <!--<implements rdf:resource="https://xmpp.org/rfcs/rfc5122.html"/>-->
+
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0004.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>2.9</xmpp:version>
+ <xmpp:since>0.7.2</xmpp:since>
+ <xmpp:note>used for ad-hoc commands</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0012.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>2.0</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0027.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.4</xmpp:version>
+ <xmpp:since>0.7.5</xmpp:since>
+ <xmpp:until>6cc1360a3a999c4384531e4f6576144040886768</xmpp:until>
+ <xmpp:note>plugin</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0030.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>2.4</xmpp:version>
+ <xmpp:since>0.5</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0045.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.27.1</xmpp:version>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0048.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.1</xmpp:version>
+ <xmpp:since>0.7.5</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0049.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.2</xmpp:version>
+ <xmpp:since>0.7.5</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0050.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.2.2</xmpp:version>
+ <xmpp:since>0.9</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0054.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.2</xmpp:version>
+ <xmpp:since>0.10</xmpp:since>
+ <xmpp:note>viewing only</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0060.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.13.5</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ <xmpp:note>only the PEP subset</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0070.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.0.1</xmpp:version>
+ <xmpp:since>0.10</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0071.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.5.1</xmpp:version>
+ <xmpp:since>0.7.2</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0077.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>2.4</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ <xmpp:note>only for password change</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0084.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.1.3</xmpp:version>
+ <xmpp:since>0.12</xmpp:since>
+ <xmpp:note>viewing only, in roster</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0085.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>2.1</xmpp:version>
+ <xmpp:since>0.7.2</xmpp:since>
+ <xmpp:note>also displayed in group chat</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0091.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.4</xmpp:version>
+ <xmpp:since>0.7.2</xmpp:since>
+ <xmpp:note>deprecated, will be removed in a future release</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0092.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.1</xmpp:version>
+ <xmpp:since>0.7</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0107.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.2</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0108.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.3</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0115.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.5.1</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0118.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.2</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0153.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.1</xmpp:version>
+ <xmpp:since>0.12</xmpp:since>
+ <xmpp:note>viewing only, in roster</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0157.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.0.1</xmpp:version>
+ <xmpp:since>0.13</xmpp:since>
+ <xmpp:note>contact plugin</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0163.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.2</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0172.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.1</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0175.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.2</xmpp:version>
+ <xmpp:since>0.5</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0178.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.1</xmpp:version>
+ <xmpp:since>0.9</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0184.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.2</xmpp:version>
+ <xmpp:since>0.9</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0191.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.3</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ <xmpp:note>missing a view to manage blocks</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0196.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>0.3</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0198.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.5.2</xmpp:version>
+ <xmpp:since>0.10</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0199.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>2.0</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0202.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>2.0</xmpp:version>
+ <xmpp:since>0.7</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0203.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>2.0</xmpp:version>
+ <xmpp:since>0.7.2</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0224.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.0</xmpp:version>
+ <xmpp:since>0.7.5</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0231.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.0</xmpp:version>
+ <xmpp:since>0.10</xmpp:since>
+ <xmpp:note>plugin, sending-only</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0245.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.0</xmpp:version>
+ <xmpp:since>0.6</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0249.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>1.2</xmpp:version>
+ <xmpp:since>0.9</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0257.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>0.3</xmpp:version>
+ <xmpp:since>0.9</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0280.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>0.11.0</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0296.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>0.2</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0297.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.0</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ <xmpp:note>only used for Carbons</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0308.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.1.0</xmpp:version>
+ <xmpp:since>0.8</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0313.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>0.6.3</xmpp:version>
+ <xmpp:since>0.13</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0319.html"/>
+ <xmpp:status>removed</xmpp:status>
+ <xmpp:version>1.0</xmpp:version>
+ <xmpp:since>0.14</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0334.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>0.2</xmpp:version>
+ <xmpp:since>0.10</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0352.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>0.2</xmpp:version>
+ <xmpp:since>0.10</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0363.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>1.0.0</xmpp:version>
+ <xmpp:since>0.13</xmpp:since>
+ <xmpp:note>upload plugin</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0364.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>0.3</xmpp:version>
+ <xmpp:since>0.7.5</xmpp:since>
+ <xmpp:note>plugin</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0378.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>0.0.1</xmpp:version>
+ <xmpp:since>0.10</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0380.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>0.1</xmpp:version>
+ <xmpp:since>0.11</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0384.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>0.3</xmpp:version>
+ <xmpp:since>0.13</xmpp:since>
+ <xmpp:note>Available at https://lab.louiz.org/poezio/poezio-omemo. UI largely missing, trust management missing</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0392.html"/>
+ <xmpp:status>complete</xmpp:status>
+ <xmpp:version>0.5</xmpp:version>
+ <xmpp:since>0.13</xmpp:since>
+ </xmpp:SupportedXep>
+ </implements>
+ <implements>
+ <xmpp:SupportedXep>
+ <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0454.html"/>
+ <xmpp:status>partial</xmpp:status>
+ <xmpp:version>0.1</xmpp:version>
+ <xmpp:since>0.14</xmpp:since>
+ <xmpp:note>No thumbnails support</xmpp:note>
+ </xmpp:SupportedXep>
+ </implements>
+
+
+ <release>
+ <Version>
+ <revision>0.14</revision>
+ <created>2022-04-10</created>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.14/poezio-v0.14.tar.gz"/>
+ </Version>
+ <Version>
+ <revision>0.13.1</revision>
+ <created>2020-05-31</created>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.13.1/poezio-v0.13.1.tar.gz"/>
+ </Version>
+ </release>
+ <!-- TODO: https://github.com/ewilderj/doap/issues/52 -->
+ <release>
+ <Version>
+ <revision>0.13</revision>
+ <created>2020-05-24</created>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.13/poezio-v0.13.tar.gz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.12.1</revision>
+ <created>2018-09-12</created>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.12.1/poezio-v0.12.1.tar.gz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.12</revision>
+ <created>2018-08-13</created>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.12/poezio-v0.12.tar.gz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.11</revision>
+ <created>2017-01-31</created>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.11/poezio-v0.11.tar.gz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.10</revision>
+ <created>2016-10-09</created>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.10/poezio-v0.10.tar.gz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.9</revision>
+ <created>2015-07-31</created>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.9/poezio-v0.9.tar.gz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.8.1</revision>
+ <created>2014-03-20</created>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.8.1/poezio-v0.8.1.tar.gz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.8</revision>
+ <created>2014-02-22</created>
+ <file-release rdf:resource="https://lab.louiz.org/poezio/poezio/-/archive/v0.8/poezio-v0.8.tar.gz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.7.5.2</revision>
+ <created>2012-??-??</created>
+ <file-release rdf:resource="https://dev.louiz.org/attachments/19/poezio-0.7.5.2.tar.xz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.7.5.1</revision>
+ <created>2012-??-??</created>
+ <file-release rdf:resource="https://dev.louiz.org/attachments/18/poezio-0.7.5.1.tar.xz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.7.5</revision>
+ <created>2012-05-25</created>
+ <file-release rdf:resource="https://dev.louiz.org/attachments/20/poezio-0.7.5.tar.gz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.7.2</revision>
+ <created>2011-11-08</created>
+ <file-release rdf:resource="https://dev.louiz.org/attachments/21/poezio-0.7.2.tar.xz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.7.1</revision>
+ <created>2011-02-02</created>
+ <file-release rdf:resource="https://dev.louiz.org/attachments/22/poezio-0.7.1.tar.xz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.7</revision>
+ <created>2011-01-14</created>
+ <file-release rdf:resource="https://dev.louiz.org/attachments/23/poezio-0.7.tar.xz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.6.2</revision>
+ <created>2010-07-21</created>
+ <file-release rdf:resource="https://dev.louiz.org/attachments/24/poezio-0.6.2.tar.xz"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.6.1</revision>
+ <created>2010-06-13</created>
+ <file-release rdf:resource="https://dev.louiz.org/attachments/26/poezio-0.6.1.tar.bz2"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.6</revision>
+ <created>2010-06-13</created>
+ <file-release rdf:resource="https://dev.louiz.org/attachments/27/poezio-0.6.tar.bz2"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.5.1</revision>
+ <created>2010-02-02</created>
+ <file-release rdf:resource="https://dev.louiz.org/attachments/28/poezio-0.5.1.tar.bz2"/>
+ </Version>
+ </release>
+ <release>
+ <Version>
+ <revision>0.5</revision>
+ <created>2010-02-01</created>
+ <file-release rdf:resource="https://dev.louiz.org/attachments/29/poezio-0.5.tar.bz2"/>
+ </Version>
+ </release>
+</Project>
+</rdf:RDF>
diff --git a/data/io.poez.Poezio.appdata.xml b/data/io.poez.Poezio.appdata.xml
index ce0d1a86..d6f479a3 100644
--- a/data/io.poez.Poezio.appdata.xml
+++ b/data/io.poez.Poezio.appdata.xml
@@ -1,17 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
-<!-- Copyright 2018 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> -->
+<!-- Copyright 2018-2020 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr> -->
<component type="console-application">
<id>io.poez.Poezio</id>
<name>Poezio</name>
<summary>Poezio is a free console XMPP client</summary>
- <project_license>Zlib</project_license>
+ <project_license>GPL-3.0+</project_license>
<metadata_license>CC0-1.0</metadata_license>
+ <developer_name>Poezio Team</developer_name>
+ <icon type="remote">https://poez.io/img/logo.svg</icon>
<url type="homepage">https://poez.io</url>
- <url type="bugtracker">https://dev.louiz.org/projects/poezio/issues</url>
- <url type="help">https://doc.poez.io/</url>
+ <url type="bugtracker">https://lab.louiz.org/poezio/poezio/-/issues</url>
+ <url type="help">https://doc.poez.io</url>
+
+ <description>
+ <p>
+ Poezio is a free console XMPP client (the protocol on which the Jabber IM
+ network is built).
+ </p>
+ <p>
+ Its goal is to let you connect very easily (no account creation needed)
+ to the network and join various chatrooms, immediately. It tries to look
+ like the most famous IRC clients (weechat, irssi, etc). Many commands are
+ identical and you won’t be lost if you already know these clients.
+ Configuration can be made in a configuration file or directly from the
+ client.
+ </p>
+ <p>
+ You’ll find the light, fast, geeky and anonymous spirit of IRC while
+ using a powerful, standard and open protocol.
+ </p>
+
+ <p>Features</p>
+
+ <ul>
+ <li>Encryption: TLS, OMEMO, OTR, always chat with encryption.</li>
+ <li>Chatrooms: Discuss on your favorite topics with your friends or strangers.</li>
+ <li>Carbon copies: Switch devices while staying in the same conversations without losing messages.</li>
+ <li>Plugins: Add the features you want through official or other plugins.</li>
+
+ <li>Corrections: Fix your last sent messages.</li>
+ <li>Rich text: Send and receive messages with colors and formatting.</li>
+ <li>Chat state notifications: See the writing status of your contacts.</li>
+ <li>Anonymous: Use XMPP without an account.</li>
+ </ul>
+ </description>
- <icon width="80" height="80">data/poezio_80.png</icon>
<screenshots>
<screenshot type="default">
<image>https://poez.io/img/screenshot.png</image>
@@ -24,11 +58,20 @@
<category>InstantMessaging</category>
<category>Network</category>
</categories>
+
+ <content_rating type="oars-1.1">
+ <content_attribute id="social-chat">intense</content_attribute>
+ </content_rating>
+
<provides>
<binary>poezio</binary>
</provides>
- <developer_name>Poezio Team</developer_name>
+
<releases>
+ <release version="0.14" date="2022-04-10"/>
+ <release version="0.13.1" date="2020-05-31"/>
+ <release version="0.13" date="2020-05-24"/>
+ <release version="0.12.1" date="2018-09-12"/>
<release version="0.12" date="2018-08-13"/>
<release version="0.11" date="2017-01-31"/>
<release version="0.10" date="2016-10-09"/>
diff --git a/data/io.poez.Poezio.json b/data/io.poez.Poezio.json
index 79539257..98a56571 100644
--- a/data/io.poez.Poezio.json
+++ b/data/io.poez.Poezio.json
@@ -88,7 +88,7 @@
"sources": [
{
"type": "git",
- "url": "git://git.poez.io/poezio.git"
+ "url": "https://lab.louiz.org/poezio/poezio.git"
}
]
}
diff --git a/data/poezio.1 b/data/poezio.1
index d54e991c..4e9fbf12 100644
--- a/data/poezio.1
+++ b/data/poezio.1
@@ -1,45 +1,48 @@
.\" Copyright 2010 Le Coz Florent
.\" This man page is distributed under the GPLv3 license.
.\" See COPYING file
-.TH "Poezio" "1" "September 26, 2011" "Poezio dev team" ""
+.TH "Poezio" "1" "May 31, 2020" "Poezio dev team" ""
.SH "NAME"
-Poezio \- a ncurses jabber client written in python3
+Poezio \- a ncurses jabber client written in python
.SH "SYNOPSIS"
.B poezio [\-f \fICONFIG_FILE\fR] [\-d \fIDEBUG_FILE\fR] [\-h]
.SH "DESCRIPTION"
.B Poezio
-is a console jabber (XMPP) client written in Python and using ncurses to draw its interface. It aims at being similar to the most famous IRC clients, like weechat or irssi. The keyboard shortcuts are inspired from emacs. For more information on XMPP see http://xmpp.org and on Poezio see https://poez.io
+is a console jabber (XMPP) client written in Python and using ncurses to draw its interface. It aims at being similar to the most famous IRC clients, like weechat or irssi. Some keyboard shortcuts are inspired from emacs. For more information on XMPP see http://xmpp.org and on Poezio see https://poez.io
.PP
.SH "OPTIONS"
.TP
\fB\-f\fR, \fB\-\-file \fICONFIG_FILE\fR
-Run poezio using \fICONFIG_FILE\fR as the config file instead of ~/.config/poezio/poezio.cfg
+Run poezio using \fICONFIG_FILE\fR as the config file instead of ~/.config/poezio/poezio.cfg.
.TP
\fB\-d\fR, \fB\-\-debug \fIDEBUG_FILE\fR
-Log debug from both poezio and SleekXMPP in \fIDEBUG_FILE\fR. Debug contains incoming and outgoing stanzas in addition to various message helping poezio's debugging.
+Log debug from both poezio and slixmpp in \fIDEBUG_FILE\fR. Debug contains incoming and outgoing stanzas in addition to various message helping poezio's debugging.
.TP
-\fB\-h\fR
-Display an help message
+\fB\-c\fR, \fB\-\-check\-config\fR
+Display the list of modified/unmodified config options, with their changes from the default.
+.TP
+\fB\-h\fR, \fB\-\-help\fR
+Display the poezio help message.
+
+.SH "BASICS"
+
+The following sections will give you a short overview on how to use poezio. Poezio has many more options, commands and key bindings, please refer to \fIpoezio.cfg(7)\fR, \fIpoezio.commands(7)\fR, \fIpoezio.keys(7)\fR or the full documentation which should have been provided alongside the source code, or check it online at https://doc.poez.io/.
-.SH "TABS"
-A \fItab\fR, in Poezio, is the base structure of the interface. A tab may contains one or more \fIwindows\fR, and can be of different types:
+A \fItab\fR, in Poezio, is the base structure of the interface. A tab may contains one or more \fIwindows\fR, the main types are:
.RS
.TP 6
.I Roster \fRtab
-It contains a list of your contacts on the left, as well as an info window on the right.
+It contains a browsable list of your contacts on the left, as well as an info window on the right.
.TP
-.I MUC \fRtab
-MUC stands for "Multi-User Chat".
+.I Chatroom \fRtab
+This tab displays the contents of a multi-user chat.
.TP
.I Conversation \fRtab
It is used for one-to-one communication, usually when using a real Jabber account.
-.TP
-.I Private \fRtab
-It is used to privately communicate with someone in a MUC.
.SH "KEY BINDINGS"
While most of the keyboard shortcuts are common to all types of tabs, some of them are tab-specific.
-.SS Text edition
+.SS Text edition
These shortcuts work in any kind of tab; most of them are identical to emacs' ones.
.RS
.TP 8
@@ -146,9 +149,6 @@ The opposite of \fI/accept\fR.
.SS MUC-specific commands
.RS
.TP 5
-.B /recolor
-Change the color of the nicknames in the conversation. Useful when a few people are talking and their random color happen to be the same: using this command will let you differentiate them more easily.
-.TP
.B /kick <user>
Kick the specified user from the room.
.TP
@@ -165,7 +165,7 @@ View or change the topic of the room.
Talk privately with the specified participant.
.TP
.B /part
-Leave the current room.
+Leave the current room.
.SH "BUGS"
Sure.
@@ -174,8 +174,8 @@ Sure.
If you're using a terminal multiplexer such as \fIscreen\fR or \fItmux\fR, it may be setting $TERM to "screen", which breaks 256-color support. Consider setting your $TERM to something like "screen-256color".
.SH "FEEDBACK"
-You are encouraged to report bugs or feature requests on https://dev.louiz.org/projects/poezio.
-You can also find us on the Jabber chatroom poezio@muc.poez.io
+You are encouraged to report bugs or feature requests on https://lab.louiz.org/poezio/poezio.
+You can also find us on the Jabber chatroom xmpp:poezio@muc.poez.io?join
.SH "AUTHORS"
Written by Florent Le Coz <louiz@louiz.org>
diff --git a/data/poezio_logo.svg b/data/poezio_logo.svg
new file mode 100644
index 00000000..30a93907
--- /dev/null
+++ b/data/poezio_logo.svg
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg version="1.1" viewBox="0 -3.3 162.31 162.31" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<linearGradient id="a" x1="-236.83" x2="-194.71" y1="320.77" y2="225.75" gradientTransform="matrix(3.7795 0 0 3.7795 -851.06 -1965.2)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#c6b8a3" offset="0"/>
+<stop stop-color="#77695c" offset="1"/>
+</linearGradient>
+</defs>
+<g transform="translate(499.5 303.22)">
+<path transform="scale(.26458)" d="m-1521-1142.2c-17.827-0.2414-39.066 2.9981-62.785 9.3535-13.901 3.7246-39.093 17.053-56.918 33.52-51.059 47.17-91.261 130.22-108.24 184.17-13.324 42.33-24.891 85.997-27.275 130.31-4.3362 80.586 58.173 59.693 84.348 38.385 81.527-66.369 134.42-161.49 189.8-251.87 6.0591-9.8888 13.177-23.153 19.6-37.438l-86.26 15.389c32.221-11.002 65.773-22.199 95.547-38.42 8.1982-23.203 12.362-45.896 5.5645-58.891-8.5603-16.365-27.326-24.153-53.381-24.506z" fill="url(#a)"/>
+<path d="m-424.55-272.28c-0.63089-0.36425-7.5629 17.611-11.896 29.094-5.9646 15.809-13.932 33.535-21.722 50.624l3.2103-0.50809c1.815-4.0728 3.1592-8.3581 4.9451-12.405 3.2298-7.3189 6.1711-14.688 9.0878-21.999 3.2027-8.028 6.3879-16.108 9.5072-24.281 2.5993-6.8106 7.2954-20.278 6.8669-20.525z" fill="#39332f" opacity=".5"/>
+<path transform="scale(.26458)" d="m-1524.4-1142.2c-17.11 0.1804-37.168 3.4031-59.404 9.3614-13.901 3.7246-39.092 17.051-56.916 33.518-51.059 47.17-91.261 130.22-108.24 184.17-13.324 42.331-24.891 85.995-27.275 130.31-4.3362 80.586 58.173 59.695 84.348 38.387 81.527-66.369 134.42-161.49 189.8-251.87 6.0592-9.8889 13.177-23.154 19.6-37.439l-20.914 3.7305c-4.1436 8.2332-8.3026 15.753-11.941 21.691-55.663 90.845-107.84 183.78-185.62 247.09-9.7204 7.9131-29.956 16.035-39.449 14.904-4.7468-0.56542-6.5747-1.3447-9.2872-5.6777-2.7125-4.3332-5.768-14.192-4.8125-31.949 2.2441-41.705 13.281-83.879 26.428-125.65 16.084-51.101 56.473-132.94 103.04-175.96 15.039-13.893 40.458-26.857 48.986-29.143 22.545-6.0409 42.327-8.8545 57.644-8.6953 3.5348 0.038 6.8309 0.2317 9.8691 0.5821 16.204 1.8676 23.269 6.6131 27.012 13.768 1.125 2.1507 1.06 21.752-6.2598 42.793-2.3806 6.8432-5.2161 13.866-8.252 20.719 7.7933-3.486 15.434-7.2044 22.838-11.238 8.1982-23.204 12.362-45.896 5.5645-58.891-8.5602-16.365-27.325-24.153-53.381-24.506-1.1142-0.015-2.2421-0.018-3.3828-0.01z" color="#000000" color-rendering="auto" dominant-baseline="auto" fill="#77695c" image-rendering="auto" shape-rendering="auto" solid-color="#000000" style="font-feature-settings:normal;font-variant-alternates:normal;font-variant-caps:normal;font-variant-ligatures:normal;font-variant-numeric:normal;font-variant-position:normal;isolation:auto;mix-blend-mode:normal;shape-padding:0;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-orientation:mixed;text-transform:none;white-space:normal"/>
+<path d="m-424.83-272.31c-0.63089-0.36426-8.5433 17.646-12.876 29.13-5.9646 15.809-13.554 32.543-21.344 49.632-4.7386 10.396-10.324 20.692-15.54 31.032-2.4827 4.9216-7.3389 14.817-6.642 15.004 0.66387 0.17788 0.35257-2.5702 5.4039-4.3181 0.5507-0.19056-0.92829-3.1055 0.90094-5.4384 4.7387-8.0609 13.279-23.609 18.71-35.795 1.815-4.0728 3.1592-8.3581 4.9451-12.405 3.2298-7.3189 6.1711-14.688 9.0878-21.999 3.2027-8.028 6.3879-16.108 9.5072-24.281 2.5993-6.8106 8.2758-20.313 7.8473-20.561z" fill="#39332f"/>
+<path d="m-469.06-191.1c4.69-0.93912 6.4839 0.75453 7.7485 2.9293l2.4805-4.7483c-3.4097 0.43204-6.8193 0.61843-10.229 1.819z" fill="#39332f"/>
+<g transform="rotate(15)" fill="#39332f" aria-label="o">
+<path d="m-387.12-106.62q-1.8059 0.16417-3.1192 0.98502-1.3134 0.82086-2.1616 2.1069-0.82085 1.2586-1.2313 2.8456-0.41043 1.5596-0.41043 3.2013 0 1.5596 0.41043 3.2013 0.43779 1.6417 1.3134 2.9824 0.90294 1.3407 2.271 2.2163 1.3954 0.84822 3.3381 0.84822 1.6691 0 2.9824-0.79349t2.2163-2.0795q0.90294-1.286 1.3681-2.9004 0.49251-1.6143 0.49251-3.2561 0-1.1218-0.19153-2.4899-0.16417-1.3681-0.5746-2.5446-0.41042-1.2039-1.0945-1.9974-0.65668-0.79349-1.6144-0.79349-0.65668 0-1.3681 0.71141-0.71141 0.7114-1.3407 1.8059-0.62932 1.0945-1.1492 2.4078-0.49252 1.286-0.76614 2.4352-0.27361 1.1492-0.30098 1.9974 0 0.82086 0.38307 1.0124 0.51987 0.24626 0.9303-0.08209 0.43779-0.32834 0.73877-0.9303 0.32834-0.60196 0.54724-1.3407 0.21889-0.73877 0.3557-1.3134 0.24626 0.10945 0.65668 0.32834 0.43779 0.19153 0.87558 0.41043 0.46515 0.2189 0.87558 0.41043 0.43779 0.19153 0.68405 0.27362-0.24626 0.82086-0.7935 1.6143-0.51987 0.79349-1.2586 1.4228t-1.6144 1.0397q-0.84821 0.38306-1.7238 0.38306t-1.4228-0.30098q-0.54723-0.30098-0.87558-0.82085-0.30098-0.51988-0.43778-1.1766-0.10945-0.68404-0.10945-1.3955 0-1.1766 0.27362-2.4626 0.27361-1.286 0.73877-2.5173 0.46515-1.2586 1.0945-2.3805 0.65668-1.1218 1.3954-1.97t1.5323-1.3407q0.7935-0.51987 1.6144-0.51987 1.7238 0 3.174 0.82085 1.4775 0.82086 2.5173 2.189 1.0671 1.3681 1.6691 3.174 0.60196 1.7785 0.60196 3.7212 0 2.2984-0.84821 4.4053-0.84822 2.0795-2.3258 3.6665t-3.4476 2.5173q-1.97 0.95766-4.1864 0.95766-2.5446 0-4.6789-1.0124-2.1342-0.98503-3.6938-2.5994-1.5323-1.6417-2.3805-3.7212-0.84821-2.0795-0.79349-4.2137 0.0274-2.2437 0.84822-4.2137 0.82085-1.9974 2.2437-3.5297 1.4228-1.5323 3.3655-2.4899 1.97-0.95766 4.2958-1.1492z" fill="#39332f" stroke-width=".28019"/>
+</g>
+<g transform="rotate(8.5566)" fill="#39332f" aria-label="i">
+<path d="m-398.51-150.16q0.20437-1.051 0.58389-2.248 0.40872-1.197 0.99261-2.1896 0.61308-1.0218 1.4305-1.6641 0.81745-0.67147 1.8976-0.67147 1.0218 0 1.7808 0.70067 0.78825 0.67147 1.3429 1.8684 0.58389 1.197 0.96342 2.8318 0.37952 1.6349 0.58389 3.5325 0.23355 1.8976 0.32113 3.9996 0.0876 2.0728 0.0876 4.1748 0 1.0218-0.0292 1.9852-0.0292 0.93422 0 1.7517t0.17517 1.4889q0.17516 0.64228 0.5255 1.051-0.29195-0.0292-1.2554-0.0292-0.93422 0-2.0144 0.0292t-2.0436 0.0292q-0.93422 0.0292-1.2262-0.0292 0.29194-0.23356 0.55469-1.2554 0.26275-1.051 0.49631-2.5691 0.23355-1.5473 0.40872-3.4449 0.20436-1.8976 0.29194-3.8245 0.0876-1.9268 0.0876-3.7369 0.0292-1.8392-0.0876-3.2406-0.0876-1.4013-0.35033-2.2188-0.23356-0.84664-0.61308-0.81745-0.75906 0.11678-1.2262 0.61309-0.43791 0.4963-0.70066 1.197-0.26275 0.70066-0.40872 1.5181-0.11678 0.81744-0.23356 1.5765zm6.6271-17.166q0 0.35033-0.11677 0.93422-0.0876 0.58389-0.14597 1.1678-0.0584 0.58388-0.0292 1.051 0.0584 0.43792 0.35033 0.49631 0.43792 0.0876 0.93422-0.43792 0.5255-0.5255 0.93422-1.2554 0.43792-0.75905 0.64228-1.4889 0.23355-0.72986 0.0876-1.0802-0.23355-0.5255-0.87583-0.72986t-1.4597-0.11678q-0.78824 0.0584-1.6933 0.37953-0.87583 0.32114-1.6349 0.84664-0.75906 0.5255-1.2846 1.2262-0.52549 0.67147-0.61308 1.4597-0.0584 0.72986 0.14597 1.2554 0.23356 0.4963 0.61309 0.90502 0.40872 0.40872 0.87583 0.75905 0.46711 0.35034 0.90502 0.78825-0.40872 0.40872-0.81744 0.84664-0.40872 0.40872-0.87583 0.81744-0.55469-0.55469-1.1678-1.0802-0.58388-0.55469-1.0802-1.1386-0.46711-0.61308-0.78824-1.3138-0.32114-0.70066-0.32114-1.5473 0-1.2262 0.78824-2.3647 0.78825-1.1678 2.0144-2.0436 1.2554-0.87583 2.7443-1.4013 1.5181-0.52549 2.9486-0.52549 0.70066 0 1.4013 0.20436 0.70066 0.20436 1.2554 0.55469 0.58389 0.35033 0.93422 0.84664 0.35033 0.46711 0.35033 1.0218 0 0.61308-0.20436 1.3721-0.17516 0.75906-0.4963 1.5765-0.32114 0.78825-0.78825 1.5473-0.46711 0.72986-1.0218 1.3429-0.55469 0.58389-1.197 0.93422-0.64227 0.35033-1.3138 0.35033-0.70066 0-1.3721-0.32114-0.67147-0.32113-1.1678-0.84663-0.49631-0.55469-0.78825-1.2554-0.26275-0.72986-0.17517-1.5181z" fill="#39332f" stroke-width=".28366"/>
+</g>
+<g transform="rotate(-13.506)" fill="#39332f" aria-label="Z">
+<path d="m-322.39-279.02q0.0877-0.7307 0.55532-1.4906 0.49687-0.78914 1.2276-1.4029 0.75991-0.64301 1.6952-1.023 0.96451-0.40919 1.9875-0.40919 0.81838 0 1.4614 0.5261 0.64301 0.49687 1.0814 1.3152 0.46764 0.78915 0.70146 1.7537 0.23382 0.96452 0.23382 1.8706 0 1.1106-0.3215 2.1628-0.29228 1.023-0.87683 1.8414-0.58455 0.78914-1.4614 1.286-0.84761 0.49687-1.9583 0.49687-2.9812 0-5.9624-0.29227-2.952-0.29228-6.0794-0.29228-2.3382 0-4.7641 0.11691-2.4259 0.14614-4.9979 0.46764-0.58455-0.0877-0.93528-0.61378-0.32151-0.5261-0.32151-1.1106 0-0.14614 0-0.23382 0-0.0877 0.0585-0.23382 2.8351-4.5888 5.4656-8.8268 2.6597-4.238 5.2318-8.3591 2.6013-4.1503 5.1441-8.3591t5.1733-8.7683q-1.7537 0-3.3612-0.14614-1.5783-0.14614-3.0981-0.32151-1.5198-0.20459-3.0104-0.35073t-3.0397-0.14614q-0.67224 0-1.4906 0.0292-0.81838 0-1.6368 0.11691-0.81838 0.0877-1.6075 0.32151-0.75992 0.20459-1.3737 0.61378-0.58456 0.40918-0.96452 1.0522-0.35073 0.61378-0.35073 1.5198 0 0.40919 0.0877 0.87683 0.0877 0.43842 0.26305 0.8476 0.17537 0.37996 0.43842 0.70147 0.26305 0.3215 0.64301 0.46764 0.43841-0.14614 0.73069-0.46764 0.3215-0.32151 0.5261-0.70147 0.20459-0.40918 0.29227-0.8476 0.0877-0.43841 0.14614-0.81837 0.29228 0.0585 0.81838 0.29227 0.52609 0.23382 1.0814 0.49687 0.58455 0.26305 1.1106 0.5261 0.5261 0.23382 0.81838 0.26305-0.29228 1.0522-0.96452 1.7537-0.64301 0.67224-1.4614 1.0814-0.78915 0.40919-1.6952 0.58456-0.87683 0.14614-1.6367 0.14614-0.75992 0-1.4029-0.37996-0.64301-0.40919-1.1399-1.1106-0.46764-0.70147-0.73069-1.6368-0.26305-0.96452-0.26305-2.0752 0-1.0814 0.29227-2.1628 0.29228-1.0814 0.87683-1.929 0.61379-0.8476 1.4906-1.3737 0.87683-0.55533 2.0752-0.58456 2.7182 0.23383 5.3194 0.37996 2.6013 0.11691 5.2318 0.11691 2.7182 0 5.3779-0.0877 2.6597-0.11691 5.3779-0.26305 0.64301 0.0585 1.1106 0.55533 0.46764 0.46764 0.46764 1.1106 0 0.29228-0.14613 0.58455-3.1274 4.4718-5.7871 8.7391-2.6305 4.238-5.1441 8.476-2.4844 4.238-4.9979 8.5345-2.4844 4.2965-5.261 8.856 1.6368 0 3.1274 0.0877 1.4906 0.0585 2.9228 0.17537 1.4322 0.0877 2.8351 0.17537 1.4322 0.0584 2.9228 0.0584 1.5491 0 3.0981-0.14614 1.5783-0.14613 3.3027-0.46764 1.0814-0.23382 1.5198-1.023 0.46765-0.81837 0.46765-1.8413 0-0.99374-0.32151-1.7829-0.29228-0.81837-1.0522-1.2276-0.46764 0.14614-0.78914 0.49687-0.29228 0.3215-0.49687 0.75992-0.2046 0.40919-0.32151 0.87683-0.0877 0.46764-0.14614 0.87683z" fill="#39332f" stroke-width=".24941"/>
+</g>
+<g transform="rotate(15.886)" fill="#39332f" aria-label="e">
+<path d="m-448.47-74.974q-0.62631-0.76247-0.98031-1.4705-0.32677-0.708-0.32677-1.8789t0.708-1.9606q0.70801-0.7897 1.77-1.1709 1.062-0.38123 2.2874-0.354 1.2526 0 2.3146 0.40846t1.77 1.2254q0.70801 0.81693 0.70801 2.0151 0 1.7155-0.81693 3.0771-0.7897 1.3343-2.0968 2.2602-1.3071 0.92585-2.9954 1.3888-1.6611 0.46293-3.4039 0.40846-2.6686-0.10892-4.5476-1.062-1.8789-0.92585-3.0771-2.478-1.1982-1.5794-1.77-3.6489-0.54462-2.0696-0.54462-4.4114 0-2.124 0.68077-4.1119 0.70801-2.0151 2.0423-3.5673 1.3343-1.5794 3.2677-2.5052 1.9334-0.95308 4.4386-0.92585 2.3963 0.02723 4.0302 0.89862 1.6611 0.87139 2.7231 2.2602 1.062 1.3888 1.6066 3.1043 0.54462 1.7155 0.68077 3.4039-0.0817 1.0075-1.1709 1.1709h-14.514q-0.10893 0.62631-0.0272 1.5794 0.0817 0.95308 0.38123 2.0423 0.29954 1.062 0.81693 2.1512 0.54462 1.062 1.3071 1.9334 0.76247 0.84416 1.77 1.3888 1.0348 0.54462 2.3419 0.54462 1.0892 0 2.2874-0.40846 1.1982-0.4357 2.1785-1.1709 1.0075-0.73524 1.6066-1.7155 0.62631-0.98031 0.57185-2.0696-0.0272-0.54462-0.57185-0.76247-0.54462-0.21785-1.2798-0.19062-0.70801 0.02723-1.4432 0.27231-0.70801 0.24508-1.0892 0.65354-0.354 0.40846-0.21785 0.95308 0.16339 0.54462 1.1982 1.1437zm5.1739-10.048q-0.0545-1.1437-0.40846-2.2874-0.354-1.1709-1.062-2.0968-0.68077-0.92585-1.7156-1.4977-1.0075-0.59908-2.3963-0.59908t-2.5325 0.51739q-1.1437 0.49016-2.0151 1.3615-0.84416 0.87139-1.416 2.0151t-0.84416 2.4236z" fill="#39332f" stroke-width=".26458"/>
+</g>
+<g transform="rotate(-15.194)" fill="#39332f" aria-label="p">
+<path d="m-428.73-299.51q0.44486-3.1975 0.52828-6.6452 0.11121-3.4477 0.0278-6.8954-0.0556-3.4477-0.19463-6.812-0.11122-3.3643-0.11122-6.395 0-1.696 0.36146-3.1975 0.38926-1.5292 1.279-2.6692t2.3356-1.7795q1.4736-0.6673 3.6424-0.6673 2.2243 0 4.5877 0.6395 2.3634 0.63949 4.3096 2.0575 1.9741 1.418 3.2253 3.6702 1.2512 2.2243 1.2512 5.4218 0 2.6414-0.75071 4.7267-0.75071 2.0575-2.0575 3.5033t-3.0585 2.1965q-1.7517 0.75072-3.7536 0.77852-1.0288 0.0278-2.1687-0.25024-1.14-0.27804-2.1131-0.88973-0.97314-0.6395-1.6126-1.6682-0.6395-1.0288-0.6395-2.5302 0-1.4458 0.69511-2.4468 0.6951-1.001 1.7239-1.4736 1.0566-0.50047 2.3078-0.38926 1.2512 0.11122 2.3356 0.88974-0.16682 0.22243-0.38926 0.6673-0.22243 0.41706-0.47267 0.88973-0.25024 0.44487-0.47267 0.86193-0.22243 0.41707-0.38926 0.6395-0.55608-0.38926-1.1122-0.72291-0.52828-0.36145-1.1678-0.36145-0.30585 0-0.50048 0.33365-0.19463 0.30584-0.22243 0.80632-0.0278 0.50047 0.13902 1.0566 0.16682 0.55608 0.58389 1.0566 0.44486 0.50047 1.1678 0.83412 0.72291 0.30585 1.8073 0.30585 1.1956 0 2.3356-0.52828 1.14-0.55608 2.0297-1.6126 0.88974-1.0566 1.4458-2.6136 0.55609-1.5848 0.55609-3.6702 0-1.9741-0.69511-3.7536-0.6673-1.8073-1.8629-3.1697-1.1678-1.3902-2.8082-2.1965-1.6404-0.80632-3.5589-0.83413-1.0288 0-1.7517 0.38926-0.6951 0.38926-1.1678 1.0844-0.44487 0.6951-0.72291 1.6126-0.27804 0.88974-0.44487 1.8907-0.13902 1.001-0.22243 2.0297-0.0556 1.001-0.13902 1.9185-0.13902 1.5014-0.22244 3.3921-0.0834 1.8629-0.0834 3.9482t0.0556 4.2818q0.0834 2.2243 0.22243 4.3931 0.13902 2.1687 0.33365 4.1984 0.22244 2.0575 0.50048 3.8092-1.14 0.0556-2.4746-0.0556-1.3068-0.11122-2.4468-0.0556z" fill="#39332f" stroke-width=".21354"/>
+</g>
+<path d="m-440.72-202.19c-0.19715-3e-3 -0.39404-2e-3 -0.59015 3e-3v2e-3c-0.26148 7e-3 -0.52202 0.0197-0.78186 0.0398v-5e-4c-1.0394 0.0804-2.0658 0.26979-3.082 0.56843l-4e-3 2e-3h-4e-3c-1.9742 0.56314-3.6856 1.588-5.1878 3.113-0.62021 0.62963-1.0401 1.4397-1.2578 2.5254l-4e-3 0.0119-2e-3 0.0114c-0.24866 1.0998-0.32689 2.355-0.21859 3.7698 0.10896 1.4234 0.38127 2.9474 0.81804 4.5682v4e-3c0.46116 1.5899 1.0585 3.169 1.7952 4.7382l2e-3 2e-3 2e-3 4e-3c0.71363 1.5489 1.5387 3.0333 2.4768 4.455 0.93176 1.412 1.9313 2.6386 2.9941 3.6856 0.63315 0.62371 1.3012 1.1825 2.002 1.6836 0.65889 0.42211 1.3083 0.7121 1.957 0.88265 0.63641 0.13636 1.2451 0.11554 1.8867-0.076 0.58246-0.17386 1.2607-0.59934 1.9999-1.3498 0.71662-0.72753 1.2612-1.5942 1.6464-2.6366 2.7e-4 -1e-3 2e-3 -1e-3 2e-3 -2e-3 0.39732-1.1287 0.63975-2.2877 0.72657-3.4866 0.0869-1.1997 5e-3 -2.3669-0.24184-3.5099l-2e-3 -0.0114-2e-3 -0.0119c-0.16232-0.84764-0.47739-1.5386-0.78134-2.2422-0.14924 0.0624-0.23203 0.1075-0.40257 0.17568l-0.0114 6e-3 -0.0119 4e-3c-0.56733 0.21119-1.1721 0.43432-1.8164 0.67181l-0.0119 6e-3 -0.0114 2e-3c-0.60203 0.20571-1.1902 0.4245-1.7658 0.65474l-0.0372 0.0155-0.0372 0.0114c-0.0914 0.0286-0.12404 0.0431-0.20878 0.0703 0.2543 0.43291 0.49267 0.81587 0.76947 1.3167 0.52248 0.94541 0.92582 1.8897 1.2128 2.8319 0.32538 0.95544 0.45044 1.8936 0.33228 2.7911-0.11177 1.0172-0.86313 1.9081-1.8361 2.2639l-0.02 8e-3c-0.54066 0.18479-1.133 0.10504-1.6268-0.11319s-0.93381-0.55755-1.3792-0.99631l-0.0114-0.0114-0.01-0.0119c-0.84432-0.88714-1.6877-2.0161-2.5491-3.3925-0.86307-1.3791-1.68-2.921-2.4567-4.6271l-4e-3 -6e-3 -2e-3 -6e-3c-0.77882-1.7628-1.4017-3.4798-1.8671-5.1542-0.46951-1.6892-0.71769-3.2563-0.72863-4.7072-0.0113-1.4958 0.32717-2.7769 1.1953-3.6582 1.087-1.1035 2.5805-1.6156 4.1817-1.4805 5.2327 0.74146 10.487 3.0078 14.164 6.6061 4.7401 4.9082 6.896 12.677 5.3893 19.218-0.51889 2.1732-1.6425 4.1353-3.3161 5.8343-1.9206 1.9498-4.0535 3.2535-6.3655 3.836l-0.0114 2e-3 -0.01 4e-3c-2.2412 0.50972-4.5374 0.98856-9.1568 0.16184-3.7083-0.66374-6.712-2.7876-10.013-6.0062-4.7399-4.8633-7.325-11.654-5.8952-18.321 0.42281-1.8974 1.3686-3.7028 2.6443-5.4374l-1.8728-1.6718c-1.5814 2.0604-2.829 4.2263-3.6153 6.5547-4.1e-4 9.8e-4 4e-4 3e-3 0 4e-3l-2e-3 2e-3c-0.87094 2.6316-1.2437 5.2678-1.125 7.9292 0.11887 2.6657 0.71766 5.2612 1.803 7.8068v2e-3l1e-3 2e-3c1.053 2.5035 2.5807 4.7662 4.6038 6.8084l4e-3 6e-3 6e-3 4e-3c1.8792 1.9484 4.1285 3.4627 6.781 4.5548 2.658 1.0943 5.4123 1.6792 8.2853 1.7575 2.8644 0.0265 5.6966-0.48863 8.5194-1.5529l4e-3 -2e-3h6e-3c2.8152-1.0277 5.3541-2.6884 7.643-5.0121 1.9735-2.0035 3.3904-4.301 4.2773-6.9298l2e-3 -6e-3 2e-3 -4e-3c0.91938-2.6268 1.317-5.3105 1.1973-8.076-0.11998-2.7661-0.74325-5.4613-1.8792-8.1077-1.1579-2.6627-2.7766-5.0151-4.8731-7.0802-1.7725-1.746-3.7283-3.1351-5.8823-4.1796l-2e-3 -2e-3h-2e-3c-2.1882-1.0736-4.3655-1.7293-6.5431-1.9818h-6e-3l-8e-3 -2e-3c-0.6026-0.0775-1.1986-0.1203-1.7901-0.12866z" fill="#39332f"/>
+<path transform="scale(.26458)" d="m-1521-1146c-1.4099-0.02-2.8377-0.018-4.2852 0-17.37 0.2684-37.424 3.5618-59.535 9.4864-14.975 4.0125-40.12 17.411-58.502 34.392-51.957 47.999-92.12 131.29-109.28 185.81-13.359 42.443-25.033 86.409-27.445 131.24-1.1097 20.624 1.9973 35.217 8.2461 45.199 6.2489 9.9824 15.75 14.959 25.814 16.158 20.13 2.398 42.688-8.6395 56.449-19.842 82.277-66.98 135.32-162.54 190.64-252.82 6.4896-10.591 14.123-24.881 20.879-40.219l-9.0215 1.6094c-6.0697 13.232-12.673 25.472-18.303 34.66-55.435 90.473-108.18 185.15-188.96 250.91-12.414 10.106-34.203 20.171-50.787 18.195-8.2922-0.98781-15.239-4.5609-20.309-12.66-5.0701-8.0993-8.1522-21.115-7.0938-40.783 2.3564-43.792 13.819-87.165 27.107-129.38 16.802-53.381 57.038-136.19 107.2-182.53 17.267-15.952 42.508-29.206 55.334-32.643 28.904-7.7446 53.96-10.738 73.154-8.5253 19.194 2.2122 32.166 9.3198 38.682 21.775 5.575 10.658 2.431 32.318-5.3809 54.774-0.5792 1.6649-1.1873 3.3352-1.8086 5.0058 3.3511-1.7412 6.6642-3.533 9.9219-5.3984 7.4407-22.152 11.44-43.586 3.9629-57.879-7.9019-15.106-23.817-23.401-44.506-25.785-3.8792-0.4471-7.9382-0.6983-12.168-0.7578z" color="#000000" color-rendering="auto" dominant-baseline="auto" fill="#39332f" image-rendering="auto" shape-rendering="auto" solid-color="#000000" style="font-feature-settings:normal;font-variant-alternates:normal;font-variant-caps:normal;font-variant-ligatures:normal;font-variant-numeric:normal;font-variant-position:normal;isolation:auto;mix-blend-mode:normal;shape-padding:0;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-orientation:mixed;text-transform:none;white-space:normal"/>
+</g>
+</svg>
+
diff --git a/data/poezio_logs.1 b/data/poezio_logs.1
index 0be422aa..48b787da 100644
--- a/data/poezio_logs.1
+++ b/data/poezio_logs.1
@@ -70,5 +70,5 @@ Copyright \(co 2016 Tanguy Ortolo
.PP
This manual page was written for the Debian system (and may be used by others).
.PP
-Permission is granted to copy, distribute and/or modify this document under the terms of the Zlib License.
+Permission is granted to copy, distribute and/or modify this document under the terms of the GPL-3.0+ License.
.sp
diff --git a/data/scripts-manpages.xml b/data/scripts-manpages.xml
index 241ba6ea..06cf79ba 100644
--- a/data/scripts-manpages.xml
+++ b/data/scripts-manpages.xml
@@ -62,7 +62,7 @@ man(1), man(7), http://www.tldp.org/HOWTO/Man-Page/
<para>This manual page was written for the Debian system
(and may be used by others).</para>
<para>Permission is granted to copy, distribute and/or modify this
- document under the terms of the Zlib License.</para>
+ document under the terms of the GPL-3.0+ License.</para>
</legalnotice>
</info>
diff --git a/data/themes/clean.py b/data/themes/clean.py
new file mode 100644
index 00000000..66a18a6c
--- /dev/null
+++ b/data/themes/clean.py
@@ -0,0 +1,193 @@
+import poezio.theming
+
+class CleanTheme(poezio.theming.Theme):
+ # Message text color
+ COLOR_NORMAL_TEXT = (-1, -1)
+ COLOR_INFORMATION_TEXT = (12, -1) # TODO
+ COLOR_WARNING_TEXT = (1, -1)
+
+ # Color of the commands in the help message
+ COLOR_HELP_COMMANDS = (208, -1)
+
+ # "reverse" is a special value, available only for this option. It just
+ # takes the nick colors and reverses it. A theme can still specify a
+ # fixed color if need be.
+ COLOR_HIGHLIGHT_NICK = "reverse"
+
+ # Color of the participant JID in a MUC
+ COLOR_MUC_JID = (4, -1)
+
+ # User list color
+ COLOR_USER_VISITOR = (239, -1)
+ COLOR_USER_PARTICIPANT = (4, -1)
+ COLOR_USER_NONE = (0, -1)
+ COLOR_USER_MODERATOR = (1, -1)
+
+ # nickname colors
+ COLOR_REMOTE_USER = (13, -1)
+
+ # The character printed in color (COLOR_STATUS_*) before the nickname
+ # in the user list
+ CHAR_STATUS = '┃'
+ #CHAR_STATUS = '●'
+ #CHAR_STATUS = '◆'
+
+ # The characters used for the chatstates in the user list
+ # in a MUC
+ CHAR_CHATSTATE_ACTIVE = 'A'
+ CHAR_CHATSTATE_COMPOSING = 'X'
+ CHAR_CHATSTATE_PAUSED = 'p'
+
+ # These characters are used for the affiliation in the user list
+ # in a MUC
+ CHAR_AFFILIATION_OWNER = '~'
+ CHAR_AFFILIATION_ADMIN = '&'
+ CHAR_AFFILIATION_MEMBER = '+'
+ CHAR_AFFILIATION_NONE = '-'
+
+
+ # XML Tab
+ CHAR_XML_IN = 'IN '
+ CHAR_XML_OUT = 'OUT'
+ COLOR_XML_IN = (1, -1)
+ COLOR_XML_OUT = (2, -1)
+
+ # Color for the /me message
+ COLOR_ME_MESSAGE = (6, -1)
+
+ # Color for the number of revisions of a message
+ COLOR_REVISIONS_MESSAGE = (3, -1, 'b')
+
+ # Color for various important text. For example the "?" before JIDs in
+ # the roster that require an user action.
+ COLOR_IMPORTANT_TEXT = (3, 5, 'b')
+
+ # Separators
+ COLOR_VERTICAL_SEPARATOR = (4, -1)
+ COLOR_NEW_TEXT_SEPARATOR = (2, -1)
+ COLOR_MORE_INDICATOR = (6, 4)
+
+ # Time
+ CHAR_TIME_LEFT = ''
+ CHAR_TIME_RIGHT = ''
+ COLOR_TIME_STRING = (-1, -1)
+
+ # Tabs
+ COLOR_TAB_NORMAL = (-1, 0)
+ COLOR_TAB_NONEMPTY = (7, 4)
+ COLOR_TAB_SCROLLED = (5, 4)
+ COLOR_TAB_JOINED = (82, 4)
+ COLOR_TAB_CURRENT = (0, 13)
+ COLOR_TAB_COMPOSING = (7, 5)
+ COLOR_TAB_NEW_MESSAGE = (7, 5)
+ COLOR_TAB_HIGHLIGHT = (7, 3)
+ COLOR_TAB_PRIVATE = (7, 2)
+ COLOR_TAB_ATTENTION = (7, 1)
+ COLOR_TAB_DISCONNECTED = (7, 8)
+
+ COLOR_VERTICAL_TAB_NORMAL = (4, -1)
+ COLOR_VERTICAL_TAB_NONEMPTY = (4, -1)
+ COLOR_VERTICAL_TAB_JOINED = (82, -1)
+ COLOR_VERTICAL_TAB_SCROLLED = (66, -1)
+ COLOR_VERTICAL_TAB_CURRENT = (7, 4)
+ COLOR_VERTICAL_TAB_NEW_MESSAGE = (5, -1)
+ COLOR_VERTICAL_TAB_COMPOSING = (5, -1)
+ COLOR_VERTICAL_TAB_HIGHLIGHT = (3, -1)
+ COLOR_VERTICAL_TAB_PRIVATE = (2, -1)
+ COLOR_VERTICAL_TAB_ATTENTION = (1, -1)
+ COLOR_VERTICAL_TAB_DISCONNECTED = (8, -1)
+
+ # Nickname colors
+ # A list of colors randomly attributed to nicks in MUCs
+ # Setting more colors makes it harder to have two nicks with the same color,
+ # avoiding confusions.
+ LIST_COLOR_NICKNAMES = [
+ (1, -1), (2, -1), (3, -1), (4, -1), (5, -1), (6, -1), (7, -1),
+ (8, -1), (9, -1), (10, -1), (11, -1), (12, -1), (13, -1), (14, -1)
+ ]
+
+ # This is your own nickname
+ COLOR_OWN_NICK = (-1, -1)
+
+ COLOR_LOG_MSG = (8, -1)
+ # This is for in-tab error messages
+ COLOR_ERROR_MSG = (9, -1, 'b')
+ # Status color
+ COLOR_STATUS_XA = (90, 0)
+ COLOR_STATUS_NONE = (4, 0)
+ COLOR_STATUS_DND = (1, 0)
+ COLOR_STATUS_AWAY = (3, 0)
+ COLOR_STATUS_CHAT = (2, 0)
+ COLOR_STATUS_UNAVAILABLE = (8, 0)
+ COLOR_STATUS_ONLINE = (4, 0)
+
+ # Bars
+ COLOR_WARNING_PROMPT = (16, 1, 'b')
+ COLOR_INFORMATION_BAR = (7, 0)
+ COLOR_TOPIC_BAR = (7, 0)
+ COLOR_SCROLLABLE_NUMBER = (220, 4, 'b')
+ COLOR_SELECTED_ROW = (0, 13)
+ COLOR_PRIVATE_NAME = (-1, 4)
+ COLOR_CONVERSATION_NAME = (2, 0)
+ COLOR_CONVERSATION_RESOURCE = (121, 0)
+ COLOR_GROUPCHAT_NAME = (10, 0)
+ COLOR_COLUMN_HEADER = (36, 4)
+ COLOR_COLUMN_HEADER_SEL = (4, 36)
+
+ # Strings for special messages (like join, quit, nick change, etc)
+ # Special messages
+ CHAR_JOIN = '--->'
+ CHAR_QUIT = '<---'
+ CHAR_KICK = '-!-'
+ CHAR_NEW_TEXT_SEPARATOR = ' ─'
+ CHAR_OK = '✔'
+ CHAR_ERROR = '✖'
+ CHAR_EMPTY = ' '
+ CHAR_ACK_RECEIVED = CHAR_OK
+ CHAR_NACK = CHAR_ERROR
+ CHAR_COLUMN_ASC = ' ▲'
+ CHAR_COLUMN_DESC = ' ▼'
+ CHAR_ROSTER_ERROR = CHAR_ERROR
+ CHAR_ROSTER_TUNE = '♪'
+ CHAR_ROSTER_ASKED = '?'
+ CHAR_ROSTER_ACTIVITY = 'A'
+ CHAR_ROSTER_MOOD = 'M'
+ CHAR_ROSTER_GAMING = 'G'
+ CHAR_ROSTER_FROM = '←'
+ CHAR_ROSTER_BOTH = '↔'
+ CHAR_ROSTER_TO = '→'
+ CHAR_ROSTER_NONE = '⇹'
+
+ COLOR_CHAR_ACK = (2, -1)
+ COLOR_CHAR_NACK = (1, -1)
+
+ COLOR_ROSTER_GAMING = (6, -1)
+ COLOR_ROSTER_MOOD = (2, -1)
+ COLOR_ROSTER_ACTIVITY = (3, -1)
+ COLOR_ROSTER_TUNE = (6, -1)
+ COLOR_ROSTER_ERROR = (1, -1)
+ COLOR_ROSTER_SUBSCRIPTION = (-1, -1)
+
+ COLOR_JOIN_CHAR = (4, -1)
+ COLOR_QUIT_CHAR = (1, -1)
+ COLOR_KICK_CHAR = (1, -1)
+
+ # Vertical tab list color
+ COLOR_VERTICAL_TAB_NUMBER = (34, -1)
+
+ # Info messages color (the part before the ">")
+ INFO_COLORS = {
+ 'info': (2, -1),
+ 'error': (1, -1, 'b'),
+ 'warning': (1, -1),
+ 'roster': (2, -1),
+ 'help': (10, -1),
+ 'headline': (11, -1, 'b'),
+ 'tune': (6, -1),
+ 'gaming': (6, -1),
+ 'mood': (5, -1),
+ 'activity': (3, -1),
+ 'default': (-1, -1),
+ }
+
+theme = CleanTheme()
diff --git a/doc/source/commands.rst b/doc/source/commands.rst
index 4a45d15d..d1763084 100644
--- a/doc/source/commands.rst
+++ b/doc/source/commands.rst
@@ -34,6 +34,12 @@ These commands work in *any* tab.
available commands. If it has a valid command as an argument, this command
will show the usage and the help for the given command.
+ /debug
+ **Usage:** ``/debug [filename]
+
+ Reset logging and enable debugging to ``[filename]``. If the filename
+ is empty, debug logging will be disabled.
+
/join
**Usage:** ``/join [room_name][@server][/nick] [password]``
@@ -56,7 +62,7 @@ These commands work in *any* tab.
- ``/join / password``
/destroy_room
- **Usage:** ``/destroy_room [room JID]``
+ **Usage:** ``/destroy_room [room JID [reason [alternative venue]]]``
Try to destroy the room given as a parameter, or the current room
is not parameter is given and the current tab is a chatroom.
@@ -93,6 +99,15 @@ These commands work in *any* tab.
Go to the matching tab. If the argument is a number, it goes to the tab with that number.
Otherwise, it goes to the next tab whose name contains the given string.
+ /wup
+
+ **Usage:** ``/wup <prefix>``
+
+ Go to the tab whose name starts with `prefix`. If multiple tabs start
+ with that prefix, no action is taken.
+
+ (Mnemonic: Window by Unique Prefix)
+
/status
**Usage:** ``/status <availability> [status message]``
@@ -209,6 +224,11 @@ These commands work in *any* tab.
Get the software version of the given JID (usually its XMPP
client and Operating System).
+ /ad-hoc
+ **Usage:** ``/ad-hoc <jid>``
+
+ List available ad-hoc commands on the given jid.
+
/invite
**Usage:** ``/invite <jid> <room> [reason]``
@@ -218,34 +238,12 @@ These commands work in *any* tab.
/invitations
Show the pending invitations.
- /impromptu
+ /impromptu
**Usage:** ``/impromptu <jid> [jid ..]``
Invite specified JIDs into a newly created room.
- .. versionadded:: 0.13
-
- /activity
- **Usage:** ``/activity [<general> [specific] [comment]]``
-
- Send your current activity to your contacts (use the completion to cycle
- through all the general and specific possible activities).
-
- Nothing means "stop broadcasting an activity".
-
- /mood
- **Usage:** ``/mood [<mood> [comment]]``
- Send your current mood to your contacts (use the completion to cycle
- through all the possible moods).
-
- Nothing means "stop broadcasting a mood".
-
- /gaming
- **Usage:** ``/gaming [<game name> [server address]]``
-
- Send your current gaming activity to your contacts.
-
- Nothing means "stop broadcasting a gaming activity".
+ .. versionadded:: 0.13
/last_activity
**Usage:** ``/activity <jid>``
@@ -316,6 +314,12 @@ These commands will work in any conversation tab (MultiUserChat, Private, or
/clear
Clear the current buffer.
+ /scrollback
+ /sb
+ **Usage:** ``/scrollback end home clear status goto <+|-linecount>|<linenum>|<timestamp>``
+
+ Allows to go to the given line or message in the window.
+
.. _muctab-commands:
MultiUserChat tab commands
@@ -325,11 +329,12 @@ MultiUserChat tab commands
:sorted:
/affiliation
- **Usage:** ``/affiliation <nick> <affiliation>``
+ **Usage:** ``/affiliation [<nick or jid> <affiliation>]``
- Sets the affiliation of the participant designated by **nick** to the
- given **affiliation** (can be one of owner, admin, member, outcast
- and none).
+ Sets the affiliation of the participant designated by **nick** or
+ **jid** to the given **affiliation** (can be one of owner, admin,
+ member, outcast and none). If not argument is provided, lists
+ room affiliations.
/role
**Usage:** ``/affiliation <nick> <role>``
@@ -344,8 +349,7 @@ MultiUserChat tab commands
are considered identical if they only differ by the presence of one
ore more **_** character at the beginning or the end. For example
_Foo and Foo___ are considered aliases of the nick Foo) will then
- always have the specified color, in all MultiUserChat tabs. This is
- true whatever the value of **deterministic_nick_colors** is.
+ always have the specified color, in all MultiUserChat tabs.
Use the completion to get a list of all the available color values.
Use the special color **unset** to remove the attributed color on
@@ -405,18 +409,34 @@ MultiUserChat tab commands
Disconnect you from a room. You can specify an optional
message.
+ This is similar to :term:`/leave`, but keeps the tab open and doesn’t
+ remove the bookmark, so restarting poezio or another client will reopen
+ this room.
+
+ /leave
+ **Usage:** ``/leave [message]``
+
+ Disconnect you from a room, on all of your clients. You can specify an
+ optional message.
+
+ This is similar to :term:`/part`, but closes the tab and removes its
+ bookmark, to make sure we don’t come back to this room the next time we
+ open poezio or another client.
+
+ This is similar to :term:`/close`, but also removes the bookmark to
+ make sure we don’t come back to this room the next time we open poezio
+ or another client.
+
/nick
**Usage:** ``/nick <nickname>``
Change your nickname in the current room.
/recolor
- **Usage:** ``/recolor [random]``
+ **Usage:** ``/recolor``
- Re-assign a color to all the participants in the current
- room, based on the last time they talked. Use this if the participants
- currently talking have too many identical colors. If a random argument is
- given, the participants will be shuffled before they are assigned a color.
+ Re-assign a color to all the participants in the current room,
+ if the theme has changed.
/cycle
**Usage:** ``/cycle [message]``
@@ -428,7 +448,7 @@ MultiUserChat tab commands
**Usage:** ``/info <nickname>``
Display some information about the user in the room:
- his/her role, affiliation, status, and status message.
+ their role, affiliation, status, and status message.
/version
**Usage:** ``/version <nickname or jid>``
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 0396e88f..74547057 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -46,16 +46,16 @@ master_doc = 'index'
# General information about the project.
project = 'Poezio'
-copyright = '%s, Mathieu Pasquet - Florent Le Coz - Emmanuel Gil Peyrot' % time.strftime('%Y')
+copyright = '%s, Mathieu Pasquet - Florent Le Coz - Emmanuel Gil Peyrot - Maxime Buquet' % time.strftime('%Y')
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
-version = '0.13'
+version = '0.14'
# The full version, including alpha/beta/rc tags.
-release = '0.13-dev'
+release = '0.14'
add_function_parentheses = True
@@ -195,7 +195,7 @@ latex_elements = {
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'PoezioDoc.tex', 'Poezio Documentation',
- 'Mathieu Pasquet - Florent Le Coz - Emmanuel Gil Peyrot', 'manual'),
+ 'Mathieu Pasquet - Florent Le Coz - Emmanuel Gil Peyrot - Maxime Buquet', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
@@ -225,11 +225,11 @@ latex_documents = [
# (source start file, name, description, authors, manual section).
man_pages = [
('configuration', 'poezio.cfg', 'Poezio Configuration File',
- ['Mathieu Pasquet', 'Florent Le Coz', 'Emmanuel Gil Peyrot'], 7),
+ ['Mathieu Pasquet', 'Florent Le Coz', 'Emmanuel Gil Peyrot', 'Maxime Buquet'], 7),
('keys', 'poezio.keys', 'Poezio Key Bindings',
- ['Mathieu Pasquet', 'Florent Le Coz', 'Emmanuel Gil Peyrot'], 7),
+ ['Mathieu Pasquet', 'Florent Le Coz', 'Emmanuel Gil Peyrot', 'Maxime Buquet'], 7),
('commands', 'poezio.commands', 'Poezio Commands',
- ['Mathieu Pasquet', 'Florent Le Coz', 'Emmanuel Gil Peyrot'], 7),
+ ['Mathieu Pasquet', 'Florent Le Coz', 'Emmanuel Gil Peyrot', 'Maxime Buquet'], 7),
]
# If true, show URL addresses after external links.
@@ -243,7 +243,7 @@ man_pages = [
# dir menu entry, description, category)
texinfo_documents = [
('index', 'PoezioDoc', 'Poezio Documentation',
- 'Mathieu Pasquet - Florent Le Coz - Emmanuel Gil Peyrot', 'PoezioDoc', 'Poezio Documentation',
+ 'Mathieu Pasquet - Florent Le Coz - Emmanuel Gil Peyrot - Maxime Buquet', 'PoezioDoc', 'Poezio Documentation',
'Miscellaneous'),
]
diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst
index 3a5f2ef9..c28f38fa 100644
--- a/doc/source/configuration.rst
+++ b/doc/source/configuration.rst
@@ -10,8 +10,8 @@ or use the :term:`/set` command to edit some of its values directly from poezio.
This file is also used to configure key bindings, but this is explained
in the :ref:`keys-page` documentation file.
-That file is read at each startup and the configuration is saved when poezio
-is closed.
+The configuration is read at each startup or when the `/reload` command is
+issued, and it is updated after every `/set` command.
This configuration file **requires** all global options to be in a section
named [Poezio]. Some other options can be in optional sections and will
@@ -81,15 +81,6 @@ and certificate validation.
you know what you are doing, see the :ref:`ciphers` dedicated section
for more details.
- default_muc_service
-
- **Default value:** ``[empty]``
-
- If specified, will be used instead of the MUC service provided by
- the user domain.
-
- .. versionadded:: 0.13
-
force_encryption
**Default value:** ``true``
@@ -142,6 +133,15 @@ Options related to account configuration, nickname…
This option can be combined with :term:`custom_host`.
You should not need this in a "normal" use case.
+ default_muc_service
+
+ **Default value:** ``[empty]``
+
+ If specified, will be used instead of the MUC service provided by
+ the user domain.
+
+ .. versionadded:: 0.13
+
default_nick
**Default value:** ``[empty]``
@@ -324,37 +324,12 @@ to understand what is :ref:`carbons <carbons-details>` or
as mobile networks). It can however increase bandwidth usage.
It also requires server support.
- enable_user_activity
-
- **Default value:** ``true``
-
- Set this to ``false`` if you don’t want to receive the activity of your contacts.
-
- enable_user_gaming
-
- **Default value:** ``true``
-
- Set this to ``false`` if you don’t want to receive the gaming activity of your contacts.
-
- enable_user_mood
-
- **Default value:** ``true``
-
- Set this to ``false`` if you don’t want to receive the mood of your contacts.
-
enable_user_nick
**Default value:** ``true``
Set to ``false`` if you don’t want your contacts to hint you their identity.
- enable_user_tune
-
- **Default value:** ``true``
-
- If this is set to ``false``, you will no longer be subscribed to tune events,
- and the :term:`display_tune_notifications` option will be ignored.
-
go_to_previous_tab_on_alt_number
**Default value:** ``false``
@@ -371,19 +346,18 @@ to understand what is :ref:`carbons <carbons-details>` or
sender intended it as such. See :ref:`Message Correction <correct-feature>` for
more information.
- bookmark_on_join
+ synchronise_open_rooms
- **Default value:** ``false``
-
- If ``true``, poezio will bookmark automatically every room that is joined with
- a manual ``/join`` command.
+ **Default value:** ``true``
- force_remote_bookmarks
+ If ``false``, poezio will not store the state of currently open rooms,
+ so that if you leave a room and restart poezio (or start another
+ client) it will reopen it.
- **Default value:** ``false``
+ If ``true``, ``/join`` will create a bookmark with ``autojoin=true``,
+ and ``/leave`` will remove said bookmark.
- Try to retrieve your remote bookmarks, even when your server doesn’t advertise
- support.
+ This was previously named ``bookmark_on_join``.
use_bookmark_method
@@ -407,34 +381,6 @@ to understand what is :ref:`carbons <carbons-details>` or
Use this option to force the use of local bookmarks if needed.
Anything but "false" will be counted as true.
- display_gaming_notifications
-
- **Default value:** ``false``
-
- If set to true, notifications about the games your are playing
- will be displayed in the info buffer as 'Gaming' messages.
-
- display_tune_notifications
-
- **Default value:** ``false``
-
- If set to true, notifications about the music your contacts listen to
- will be displayed in the info buffer as 'Tune' messages.
-
- display_mood_notifications
-
- **Default value:** ``false``
-
- If set to true, notifications about the mood of your contacts
- will be displayed in the info buffer as 'Mood' messages.
-
- display_activity_notifications
-
- **Default value:** ``false``
-
- If set to true, notifications about the current activity of your contacts
- will be displayed in the info buffer as 'Activity' messages.
-
enable_xhtml_im
**Default value:** ``true``
@@ -566,23 +512,14 @@ or the way messages are displayed.
bottom in the list, if set to ``asc``, they will be displayed from
bottom to top.
- deterministic_nick_colors
-
- **Default value:** ``true``
-
- Use a deterministic algorithm to choose the user colors in chatrooms if
- set to ``true``. Otherwise the colors will be picked randomly.
-
- The value of this option affects the behavior of :term:`/recolor`.
-
nick_color_aliases
**Default value:** ``true``
- Automatically search for color of nick aliases. For example, if nick is
- set to red, _nick, nick\_, _nick_, nick\__ etc. will have the same color.
- Aliases colors are checked first, so that it is still possible to have
- different colors for nick\_ and nick.
+ Automatically search for color of nick aliases. For example, if nick is
+ set to red, _nick, nick\_, _nick_, nick\__ etc. will have the same color.
+ Aliases colors are checked first, so that it is still possible to have
+ different colors for nick\_ and nick.
vertical_tab_list_size
@@ -656,7 +593,7 @@ or the way messages are displayed.
Some informational messages (error, a contact getting connected, etc)
are sometimes added to the information buffer. These settings can make
- that buffer grow temporarly so you can read these information when they
+ that buffer grow temporarily so you can read these information when they
appear.
A list of message types that should make the information buffer grow
@@ -680,6 +617,14 @@ or the way messages are displayed.
If set to true, the color of the nick will be used in chatroom
information messages, instead of the default color from the theme.
+ autocolor_tab_names
+
+ **Default value:** ``false``
+
+ If ``true``, uses deterministic coloration for tab names or tab
+ numbers in the activity bar, using Consistent Color Generation
+ (XEP-0392).
+
enable_vertical_tab_list
**Default value:** ``true``
@@ -793,6 +738,17 @@ or the way messages are displayed.
If you want to show the tab name in the bottom Tab bar, set this to ``true``.
+ unique_prefix_tab_names
+
+ **Default value:** ``false``
+
+ If this and :term:`show_tab_names` is set to true, only the shortest
+ unique prefix of each tab name is shown instead of the full name. This
+ can declutter the interface in an instance with many tabs shown in the
+ interface, while not having to use numbers (which may change completely due to reordering).
+
+ Takes precedence over `use_tab_nicks`.
+
show_tab_numbers
**Default value:** ``true``
@@ -892,14 +848,6 @@ Options related to logging.
.. glossary::
:sorted:
- load_log
-
- **Default value:** ``10``
-
- The number of line to preload in a chat buffer when it opens. The lines are
- loaded from the log files.
- ``0`` or a negative value here disable that option.
-
log_dir
**Default value:** ``[empty]``
@@ -921,6 +869,21 @@ Options related to logging.
Set to ``false`` if you don’t want to write any message to the disk.
+ mam_sync
+
+ **Default value:** ``true``
+
+ If ``true``, will try to fill local logs with missing MAM history
+ when opening a tab or joining a room.
+
+ mam_sync_limit
+
+ **Default value:** ``2000``
+
+ Maximum number of messages to fetch on a MAM sync. Will affect
+ performance when joining rooms with a huge backlog for the first time
+ or after a long period.
+
Plugins
~~~~~~~
@@ -1124,7 +1087,7 @@ found.
display_user_color_in_join_part
- **Default value:** ``false``
+ **Default value:** ``true``
If set to ``true``, the color of the nick will be used in chatroom
information messages, instead of the default color from the theme.
@@ -1177,16 +1140,6 @@ found.
Ignore private messages sent from this room.
- load_log
-
- **Default value:** ``10``
-
- The number of line to preload in a chat buffer when it opens. The lines are
- loaded from the log files.
- ``0`` or a negative value here disable that option.
-
- No value makes poezio fall back to the global value.
-
password
**Default value:** ``[empty]``
diff --git a/doc/source/dev/contributing.rst b/doc/source/dev/contributing.rst
index 8d386c87..4c2d8161 100644
--- a/doc/source/dev/contributing.rst
+++ b/doc/source/dev/contributing.rst
@@ -38,8 +38,8 @@ useless merges (and polluting the commit history) when none is needed.
.. code-block:: bash
git fetch origin
- git rebase origin/master
- git push origin master
+ git rebase origin/main
+ git push origin main
If your commit is related to an issue on our tracker_ (or fixes such an
issue), you can use ``Fix #BUGID`` or ``References #BUGID`` to help with the
@@ -64,4 +64,4 @@ account and submit it again.
If you have already submitted some code and plan to do more, you can ask us
direct commit access on the main repo.
-.. _tracker: https://dev.louiz.org/project/poezio/bugs
+.. _tracker: https://lab.louiz.org/poezio/poezio/-/issues
diff --git a/doc/source/dev/e2ee.rst b/doc/source/dev/e2ee.rst
new file mode 100644
index 00000000..23304512
--- /dev/null
+++ b/doc/source/dev/e2ee.rst
@@ -0,0 +1,52 @@
+End-to-end Encryption API documentation
+=======================================
+
+E2EEPlugin
+----------
+
+.. module:: poezio.plugin_e2ee
+
+
+.. autoclass:: E2EEPlugin
+ :members: decrypt, encrypt, encryption_name, encryption_short_name, eme_ns, replace_body_with_eme, stanza_encryption, tag_whitelist
+
+
+Please refer to :py:class:`~BasePlugin` for more information on how to
+write plugins.
+
+Example plugins
+---------------
+
+**Example 1:** Base64 plugin
+
+.. code-block:: python
+
+ from base64 import b64decode, b64encode
+ from poezio.plugin_e2ee import E2EEPlugin
+ from slixmpp import Message
+
+
+ class Plugin(E2EEPlugin):
+ """Base64 Plugin"""
+
+ encryption_name = 'base64'
+ encryption_short_name = 'b64'
+ eme_ns = 'urn:xmpps:base64:0'
+
+ # This encryption mechanism is using <body/> as a container
+ replace_body_with_eme = False
+
+ def decrypt(self, message: Message, _tab) -> None:
+ """
+ Decrypt base64
+ """
+ body = message['body']
+ message['body'] = b64decode(body.encode()).decode()
+
+ def encrypt(self, message: Message, _tab) -> None:
+ """
+ Encrypt to base64
+ """
+ # TODO: Stop using <body/> for this. Put the encoded payload in another element.
+ body = message['body']
+ message['body'] = b64encode(body.encode()).decode()
diff --git a/doc/source/dev/events.rst b/doc/source/dev/events.rst
index a2e6ad9d..6dd2e65e 100644
--- a/doc/source/dev/events.rst
+++ b/doc/source/dev/events.rst
@@ -121,7 +121,7 @@ The following events are poezio-only events, for Slixmpp events, check out
changing_nick
- **presence:** :py:class:`~~slixmpp.Presence` to be sent
- Triggered when the user changes his/her nickname on a MUC. The
+ Triggered when the user changes their nickname on a MUC. The
presence can thus be modified before being sent.
send_normal_presence
diff --git a/doc/source/dev/index.rst b/doc/source/dev/index.rst
index 21ea6253..630abfad 100644
--- a/doc/source/dev/index.rst
+++ b/doc/source/dev/index.rst
@@ -14,6 +14,7 @@ About plugins
:maxdepth: 2
plugin
+ e2ee
events
slix
xep
diff --git a/doc/source/dev/plugin.rst b/doc/source/dev/plugin.rst
index 7a63ed8f..4614c761 100644
--- a/doc/source/dev/plugin.rst
+++ b/doc/source/dev/plugin.rst
@@ -1,13 +1,32 @@
Plugin API documentation
========================
+External plugins
+----------------
+
+It is possible to create external plugins easily using `setuptools'
+entry_point
+<https://setuptools.readthedocs.io/en/latest/setuptools.html#dynamic-discovery-of-services-and-plugins>`_
+feature. You can register your plugin against the ``poezio_plugins`` entry
+group with the following snippet in your project ``setup.py``:
+
+.. code-block:: python
+
+ setup(
+ ..
+ packages=['yourmodule'],
+ entry_points={'poezio_plugins': 'yourplugin = yourmodule'},
+ ..
+ )
+
+The plugin will then be available as ``yourplugin`` at runtime.
+
BasePlugin
----------
.. module:: poezio.plugin
.. autoclass:: BasePlugin
- :members: init, cleanup, api, core
.. method:: init(self)
@@ -29,6 +48,16 @@ BasePlugin
The :py:class:`~PluginAPI` instance for this plugin.
+ .. attribute:: dependencies
+
+ Dependencies on other plugins, as a set of strings. A reference
+ to each dependency will be added in ``refs``.
+
+ .. attribute:: refs
+
+ This attribute is not to be edited by the user. It will be
+ populated when the plugin is initialized with references on each
+ plugin specified in the ``dependencies`` attribute.
Each plugin inheriting :py:class:`~BasePlugin` has an ``api`` member variable, which refers
to a :py:class:`~PluginAPI` object.
diff --git a/doc/source/dev/slix.rst b/doc/source/dev/slix.rst
index 3c06e349..50f9dd07 100644
--- a/doc/source/dev/slix.rst
+++ b/doc/source/dev/slix.rst
@@ -1,5 +1,5 @@
-SleekXMPP classes
-=================
+Slixmpp classes
+===============
.. module:: slixmpp
diff --git a/doc/source/install.rst b/doc/source/install.rst
index 3146958c..31dc332d 100644
--- a/doc/source/install.rst
+++ b/doc/source/install.rst
@@ -3,7 +3,7 @@
Installing poezio
=================
-.. warning:: Python 3.5 or above is **required**.
+.. warning:: Python 3.7 or above is **required**.
To install it on a distribution that doesn't provide it, see :ref:`pyenv <pyenv-install>`.
poezio in the GNU/Linux distributions
@@ -15,16 +15,18 @@ using one of these.
- **Archlinux**: poezio_ and poezio-git_ packages are in the AUR
(use your favourite AUR wrapper to install them)
-- **Gentoo**: It’s uncertain, but the bgo-overlay_ appears to contain poezio
- and slixmpp packages.
-- **Fedora**: The stable poezio package was out of date for a long time in
- Fedora, but now thanks to Casper, there is an `up-to-date package`_ in
- the repos since F19.
+- **Gentoo**: `net-im/poezio`_
+- **Fedora**: There is an `up-to-date package`_ in the repos since F19.
+- **CentOS**: Poezio is available in EPEL repositories since CentOS 8.
+- **Flatpak**: A stable package is provided on flathub_.
- **Debian**: A stable package is provided since buster_ thanks to debacle.
- **Nix** (and **NixOS**): The last stable version of poezio is availalble in
the unstable branch of `nixpkgs`. Use ``nix-env -f "<nixpkgs>" -iA poezio``
to install poezio for the current user.
- **OpenBSD**: a poezio port_ is available
+- **Guix**: Poezio can be obtained with Guix on any GNU/Linux distribution.
+ To install poezio in default user-profile: ``guix install poezio``.
+ To try poezio without installation: ``guix environment --pure --ad-hoc poezio``.
(If another distribution provides a poezio package, please tell us and we will
add it to the list)
@@ -54,14 +56,14 @@ support. Therefore, you might want to use the git version.
.. code-block:: bash
- git clone git://git.poez.io/poezio
+ git clone https://lab.louiz.org/poezio/poezio
cd poezio
"""""""
General
"""""""
-Poezio is a python3.5 (and above)-only application, so you will first need that.
+Poezio is a python3.7 (and above)-only application, so you will first need that.
Packages required for building poezio and deps:
@@ -70,6 +72,18 @@ Packages required for building poezio and deps:
- libidn and libidn-dev, only if you want to use cython_ (see below)
- python3-devel (or equivalent)
- python3-setuptools
+- python3-pip
+- python3-venv
+- libffi-dev (for pycares, needed by slixmpp)
+- pyasn1 and pyasn1-modules (needed by slixmpp)
+
+On Debian, you can install the dependencies as follows:
+
+.. code-block:: bash
+
+ apt install python3-dev make gcc python3-setuptools python3-pip python3-venv libffi-dev
+ pip3 install --user pyasn1 pyasn1-modules
+
Then you can run ``make`` to build it the poezio C extension module.
If you downloaded the standalone stable package, you are finished here and can skip
@@ -102,7 +116,7 @@ Poezio depends on slixmpp, a non-threaded fork of the SleekXMPP library.
.. code-block:: bash
- git clone git://git.poez.io/slixmpp
+ git clone https://lab.louiz.org/poezio/slixmpp
python3 setup.py install --user
@@ -115,7 +129,7 @@ The aiodns library is required in order to properly resolve XMPP domains (with S
.. code-block:: bash
- pip install --user aiodns
+ pip3 install --user aiodns
This will also install pycares, which aiodns uses.
@@ -230,8 +244,7 @@ that should be created beforehand:
If you don’t trust images distributed on the docker hub, you can rebuild the
image from the Dockerfile at the root of the git repository.
-.. _stable sources: https://dev.louiz.org/project/poezio/download
-.. _slixmpp: https://dev.louiz.org/projects/slixmpp
+.. _slixmpp: https://lab.louiz.org/poezio/slixmpp
.. _aiodns: https://github.com/saghul/aiodns
.. _poezio: https://aur.archlinux.org/packages/poezio/
.. _poezio-git: https://aur.archlinux.org/packages/poezio-git/
@@ -242,3 +255,5 @@ image from the Dockerfile at the root of the git repository.
.. _port: http://ports.su/net/poezio
.. _poezio/poezio: https://hub.docker.com/r/poezio/poezio/
.. _buster: https://packages.debian.org/buster/poezio
+.. _net-im/poezio: https://packages.gentoo.org/packages/net-im/poezio
+.. _flathub: https://flathub.org/apps/details/io.poez.Poezio
diff --git a/doc/source/keys.rst b/doc/source/keys.rst
index ae641c26..03ab2071 100644
--- a/doc/source/keys.rst
+++ b/doc/source/keys.rst
@@ -1,7 +1,7 @@
.. _keys-page:
-Keys
-====
+Keyboard Shortcuts
+==================
This file describes the default keys of poezio and explains how to
configure them.
@@ -374,6 +374,15 @@ Actions list
Similar to F4.
+**_go_to_room_name**: Jump to a tab by unique prefix.
+
+ Similar to :term:`/wup` and the default *Alt-j*. This action will take
+ input as long as there is at least one tab name starting with the input
+ given so far. If there is exactly one tab matching, the action completes
+ and the current tab is switched over to the tab matching the input. If
+ no tab matches, the action completes without any change. This means that
+ you can typically abort the action with Escape.
+
Status actions
~~~~~~~~~~~~~~
diff --git a/doc/source/plugins/index.rst b/doc/source/plugins/index.rst
index c1d8ded1..c1222c84 100644
--- a/doc/source/plugins/index.rst
+++ b/doc/source/plugins/index.rst
@@ -145,6 +145,14 @@ Plugin index
Sends the current song (and optionally the progress inside the song) to
the current (chat) tab.
+ OMEMO
+ **Not distributed with Poezio.** See https://lab.louiz.org/poezio/poezio-omemo.
+
+ `Documentation <https://lab.louiz.org/poezio/poezio-omemo>`_
+
+ Allows for end-to-end encrypted exchanges using the OMEMO
+ mechanism.
+
OTR
:ref:`Documentation <otr-plugin>`
@@ -203,6 +211,11 @@ Plugin index
Adds convenient aliases to /status (/away, etc).
+ Sticker
+ :ref:`Documentation <sticker-plugin>`
+
+ Opens a graphical sticker picker and sends the selected one.
+
Tell
:ref:`Documentation <tell-plugin>`
@@ -304,6 +317,11 @@ Plugin index
Add an ``/upload`` command to upload a file.
+ User Extras
+ :ref:`Documentation <userextras-plugin>`
+
+ Add /mood, /gaming, /activity
+
.. toctree::
:hidden:
@@ -329,6 +347,7 @@ Plugin index
simple_notify
spam
status
+ sticker
tell
time_marker
uptime
@@ -353,3 +372,4 @@ Plugin index
vcard
upload
contact
+ userextras
diff --git a/doc/source/plugins/sticker.rst b/doc/source/plugins/sticker.rst
new file mode 100644
index 00000000..815fb141
--- /dev/null
+++ b/doc/source/plugins/sticker.rst
@@ -0,0 +1,6 @@
+.. _sticker-plugin:
+
+Sticker
+=======
+
+.. automodule:: sticker
diff --git a/doc/source/plugins/user_extras.rst b/doc/source/plugins/user_extras.rst
new file mode 100644
index 00000000..8c63092e
--- /dev/null
+++ b/doc/source/plugins/user_extras.rst
@@ -0,0 +1,6 @@
+.. _userextras-plugin:
+
+User Extras
+===========
+
+.. automodule:: user_extras
diff --git a/doc/source/themes.rst b/doc/source/themes.rst
index f6da6af5..ba4be33b 100644
--- a/doc/source/themes.rst
+++ b/doc/source/themes.rst
@@ -19,7 +19,7 @@ the text impossible to read).
.. note:: The default theme should work properly in any case. If not, that’s a bug.
A theme file is a python file (with the .py extension) containing a
-class, inheriting the *theming.Theme* class defined into the *theming*
+class, inheriting the *poezio.theming.Theme* class defined into the *theming*
poezio module.
To check how many colors your current terminal/$TERM supports, do:
@@ -38,18 +38,18 @@ add:
.. code-block:: python
- import theming
+ from poezio.theming import Theme
- class FooTheme(theming.Theme):
- # Define here colors for that theme
+ class FooTheme(Theme):
+ # Define here colors for that theme
theme = FooTheme()
To define a *color pair* and assign it to the *COLOR_NAME* option, just do
.. code-block:: python
- class FooTheme(theming.Theme):
- COLOR_NAME = (fg_color, bg_color, opt_attr)
+ class FooTheme(Theme):
+ COLOR_NAME = (fg_color, bg_color, opt_attr)
You do not have to define all the :ref:`available-options`,
you can decide that your theme will only change some options, the other
diff --git a/doc/source/usage.rst b/doc/source/usage.rst
index 86936d03..6caf2728 100644
--- a/doc/source/usage.rst
+++ b/doc/source/usage.rst
@@ -15,20 +15,11 @@ Tab list
There are two ways of showing the open tabs:
-Horizontal list
-^^^^^^^^^^^^^^^
-
-This is the default method.
-
-On all tabs, you get a line showing the the list of all opened tabs. Each tab
-has a number, each time you open a new tab, it gets the next available number.
-
-.. figure:: ./images/tab_bar.png
- :alt: Example of 5 opened tabs
-
Vertical list
^^^^^^^^^^^^^
+This is the default method.
+
On all tabs, you get a pane on the left side of the screen that shows a list
of the opened tabs. As stated above, each tab has a number, and each time you
open a new tab, it gets the next available number.
@@ -36,10 +27,17 @@ open a new tab, it gets the next available number.
.. figure:: ./images/vert_tabs.png
:alt: Example of the vertical tab bar
+Horizontal list
+^^^^^^^^^^^^^^^
+
+On all tabs, you get a line showing the the list of all opened tabs. Each tab
+has a number, each time you open a new tab, it gets the next available number.
+
+.. figure:: ./images/tab_bar.png
+ :alt: Example of 5 opened tabs
-This mode is enabled by setting the
-:term:`enable_vertical_tab_list` option to ``true`` in the
-configuration file.
+This mode is enabled by setting the :term:`enable_vertical_tab_list`
+option to ``false`` in the configuration file.
Options for the tab list
^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/launch.sh b/launch.sh
index b049a7af..b9d59cb5 100755
--- a/launch.sh
+++ b/launch.sh
@@ -1,20 +1,20 @@
#!/bin/sh
-cd $(dirname "$0")
+proj_dir=$(dirname "$(readlink -f "$0")")
if [ -z "$POEZIO_VENV" ]
then
POEZIO_VENV="poezio-venv"
fi
-if [ -e .git ]
+if [ -e "$proj_dir/.git" ]
then
- args=$(git show --format='%h %ci' | head -n1)
+ args=$(git -C "$proj_dir" show --format='%h %ci' | head -n1)
else
- args="0.12-dev"
+ args="0.14-dev"
fi
-if [ -e "$POEZIO_VENV" ]
+if [ -e "$proj_dir/$POEZIO_VENV" ]
then
- PYTHON3="$POEZIO_VENV/bin/python3"
+ PYTHON3="$proj_dir/$POEZIO_VENV/bin/python3"
else
echo ""
echo "WARNING: Not using the up-to-date launch format"
@@ -24,6 +24,6 @@ else
PYTHON3=python3
fi
-$PYTHON3 -c 'import sys;(print("Python 3.5 or newer is required") and exit(1)) if sys.version_info < (3, 5) else exit(0)' || exit 1
-exec "$PYTHON3" -m poezio -v "$args" "$@"
+$PYTHON3 -c 'import sys;(print("Python 3.7 or newer is required") and exit(1)) if sys.version_info < (3, 7) else exit(0)' || exit 1
+PYTHONPATH="$proj_dir:$PYTHONPATH" exec "$PYTHON3" -m poezio --custom-version "$args" "$@"
diff --git a/plugins/admin.py b/plugins/admin.py
index 7bbc01d6..c2901844 100644
--- a/plugins/admin.py
+++ b/plugins/admin.py
@@ -122,10 +122,14 @@ class Plugin(BasePlugin):
completion=self.complete_nick)
def role(self, role):
- return lambda args: self.api.current_tab().command_role(args + ' ' + role)
+ async def inner(args):
+ await self.api.current_tab().command_role(args + ' ' + role)
+ return inner
def affiliation(self, affiliation):
- return lambda args: self.api.current_tab().command_affiliation(args + ' ' + affiliation)
+ async def inner(args):
+ await self.api.current_tab().command_affiliation(args + ' ' + affiliation)
+ return inner
def complete_nick(self, the_input):
tab = self.api.current_tab()
diff --git a/plugins/alias.py b/plugins/alias.py
index a10beb7c..459ce02c 100644
--- a/plugins/alias.py
+++ b/plugins/alias.py
@@ -128,7 +128,7 @@ class Plugin(BasePlugin):
if update:
self.api.information('Alias /%s updated' % alias, 'Info')
else:
- self.api.information('Alias /%s successfuly created' % alias,
+ self.api.information('Alias /%s successfully created' % alias,
'Info')
def command_unalias(self, alias):
@@ -139,7 +139,7 @@ class Plugin(BasePlugin):
del self.commands[alias]
self.api.del_command(alias)
self.config.remove(alias)
- self.api.information('Alias /%s successfuly deleted' % alias,
+ self.api.information('Alias /%s successfully deleted' % alias,
'Info')
def completion_unalias(self, the_input):
diff --git a/plugins/amsg.py b/plugins/amsg.py
index b8ac4e26..3b81085a 100644
--- a/plugins/amsg.py
+++ b/plugins/amsg.py
@@ -1,7 +1,7 @@
"""
This plugin broadcasts a message to all your joined rooms.
-.. note:: With great power comes great responsability.
+.. note:: With great power comes great responsibility.
Use with moderation.
Command
@@ -29,7 +29,7 @@ class Plugin(BasePlugin):
short='Broadcast a message',
help='Broadcast the message to all the joined rooms.')
- def command_amsg(self, args):
+ async def command_amsg(self, args):
for room in self.core.tabs:
if isinstance(room, MucTab) and room.joined:
- room.command_say(args)
+ await room.command_say(args)
diff --git a/plugins/b64.py b/plugins/b64.py
new file mode 100644
index 00000000..82300a0f
--- /dev/null
+++ b/plugins/b64.py
@@ -0,0 +1,70 @@
+#! /usr/bin/env python3
+# -*- coding: utf-8 -*-
+# vim:fenc=utf-8
+#
+# Copyright © 2019 Maxime “pep” Buquet <pep@bouah.net>
+#
+# Distributed under terms of the GPL-3.0+ license.
+
+"""
+Usage
+-----
+
+Base64 encryption plugin.
+
+This plugin also respects security guidelines listed in XEP-0419.
+
+.. glossary::
+ /b64
+ **Usage:** ``/b64``
+
+ This command enables encryption of outgoing messages for the current
+ tab.
+"""
+
+from base64 import b64decode, b64encode
+from typing import List, Optional
+from slixmpp import Message, JID
+
+from poezio.plugin_e2ee import E2EEPlugin
+from poezio.tabs import (
+ ChatTab,
+ MucTab,
+ PrivateTab,
+ DynamicConversationTab,
+ StaticConversationTab,
+)
+
+
+class Plugin(E2EEPlugin):
+ """Base64 Plugin"""
+
+ encryption_name = 'base64'
+ encryption_short_name = 'b64'
+ eme_ns = 'urn:xmpps:base64:0'
+
+ # This encryption mechanism is using <body/> as a container
+ replace_body_with_eme = False
+
+ # In what tab is it ok to use this plugin. Here we want all of them
+ supported_tab_types = (
+ MucTab,
+ PrivateTab,
+ DynamicConversationTab,
+ StaticConversationTab,
+ )
+
+ async def decrypt(self, message: Message, jid: Optional[JID], _tab: Optional[ChatTab]) -> None:
+ """
+ Decrypt base64
+ """
+ body = message['body']
+ message['body'] = b64decode(body.encode()).decode()
+
+ async def encrypt(self, message: Message, _jid: Optional[List[JID]], _tab: ChatTab) -> None:
+ """
+ Encrypt to base64
+ """
+ # TODO: Stop using <body/> for this. Put the encoded payload in another element.
+ body = message['body']
+ message['body'] = b64encode(body.encode()).decode()
diff --git a/plugins/bob.py b/plugins/bob.py
index be56ef4a..98c62901 100644
--- a/plugins/bob.py
+++ b/plugins/bob.py
@@ -37,7 +37,7 @@ class Plugin(BasePlugin):
default_config = {'bob': {'max_size': 2048, 'max_age': 86400}}
def init(self):
- for tab in tabs.ConversationTab, tabs.PrivateTab, tabs.MucTab:
+ for tab in tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.PrivateTab, tabs.MucTab:
self.api.add_tab_command(
tab,
'bob',
@@ -47,7 +47,7 @@ class Plugin(BasePlugin):
short='Send a short image',
completion=self.completion_bob)
- def command_bob(self, filename):
+ async def command_bob(self, filename):
path = Path(expanduser(filename))
try:
size = path.stat().st_size
@@ -67,7 +67,7 @@ class Plugin(BasePlugin):
with open(path.as_posix(), 'rb') as file:
data = file.read()
max_age = self.config.get('max_age')
- cid = self.core.xmpp.plugin['xep_0231'].set_bob(
+ cid = await self.core.xmpp.plugin['xep_0231'].set_bob(
data, mime_type, max_age=max_age)
self.api.run_command(
'/xhtml <img src="cid:%s" alt="%s"/>' % (cid, path.name))
diff --git a/plugins/code.py b/plugins/code.py
index fa04f758..8d9c57a3 100644
--- a/plugins/code.py
+++ b/plugins/code.py
@@ -41,8 +41,12 @@ class Plugin(BasePlugin):
help='Sends syntax-highlighted code in the current tab')
def command_code(self, args):
- language, code = args.split(None, 1)
+ split = args.split(None, 1)
+ if len(split) != 2:
+ self.api.information('Usage: /code <language> <code>', 'Error')
+ return None
+ language, code = split
lexer = get_lexer_by_name(language)
- room = self.api.current_tab()
+ tab = self.api.current_tab()
code = highlight(code, lexer, FORMATTER)
- room.command_xhtml('<pre>%s</pre>' % code.rstrip('\n'))
+ tab.command_xhtml('<pre><code class="language-%s">%s</code></pre>' % (language, code.rstrip('\n')))
diff --git a/plugins/contact.py b/plugins/contact.py
index ebe4dcc4..13dcc42f 100644
--- a/plugins/contact.py
+++ b/plugins/contact.py
@@ -13,6 +13,7 @@ Usage
"""
from poezio.plugin import BasePlugin
+from slixmpp.exceptions import IqError, IqTimeout
from slixmpp.jid import InvalidJID
CONTACT_TYPES = ['abuse', 'admin', 'feedback', 'sales', 'security', 'support']
@@ -25,38 +26,35 @@ class Plugin(BasePlugin):
help='Get the Contact Addresses of a JID')
def on_disco(self, iq):
- if iq['type'] == 'error':
- error_condition = iq['error']['condition']
- error_text = iq['error']['text']
- message = 'Error getting Contact Addresses from %s: %s: %s' % (iq['from'], error_condition, error_text)
- self.api.information(message, 'Error')
- return
info = iq['disco_info']
- title = 'Contact Info'
contacts = []
- for field in info['form']:
- var = field['var']
- if field['type'] == 'hidden' and var == 'FORM_TYPE':
- form_type = field['value'][0]
- if form_type != 'http://jabber.org/network/serverinfo':
- self.api.information('Not a server: “%s”: %s' % (iq['from'], form_type), 'Error')
- return
- continue
- if not var.endswith('-addresses'):
- continue
- var = var[:-10] # strip '-addresses'
- sep = '\n ' + len(var) * ' '
- field_value = field.get_value(convert=False)
- value = sep.join(field_value) if isinstance(field_value, list) else field_value
- contacts.append('%s: %s' % (var, value))
+ # iterate all data forms, in case there are multiple
+ for form in iq['disco_info']:
+ values = form.get_values()
+ if values['FORM_TYPE'][0] == 'http://jabber.org/network/serverinfo':
+ for var in values:
+ if not var.endswith('-addresses'):
+ continue
+ title = var[:-10] # strip '-addresses'
+ sep = '\n ' + len(title) * ' '
+ field_value = values[var]
+ if field_value:
+ value = sep.join(field_value) if isinstance(field_value, list) else field_value
+ contacts.append(f'{title}: {value}')
if contacts:
- self.api.information('\n'.join(contacts), title)
+ self.api.information('\n'.join(contacts), 'Contact Info')
else:
- self.api.information('No Contact Addresses for %s' % iq['from'], 'Error')
+ self.api.information(f'No Contact Addresses for {iq["from"]}', 'Error')
- def command_disco(self, jid):
+ async def command_disco(self, jid):
try:
- self.core.xmpp.plugin['xep_0030'].get_info(jid=jid, cached=False,
- callback=self.on_disco)
- except InvalidJID as e:
- self.api.information('Invalid JID “%s”: %s' % (jid, e), 'Error')
+ iq = await self.core.xmpp.plugin['xep_0030'].get_info(jid=jid, cached=False)
+ self.on_disco(iq)
+ except InvalidJID as exn:
+ self.api.information(f'Invalid JID “{jid}”: {exn}', 'Error')
+ except (IqError, IqTimeout,) as exn:
+ ifrom = exn.iq['from']
+ condition = exn.iq['error']['condition']
+ text = exn.iq['error']['text']
+ message = f'Error getting Contact Addresses from {ifrom}: {condition}: {text}'
+ self.api.information(message, 'Error')
diff --git a/plugins/day_change.py b/plugins/day_change.py
index 051b447b..5d3ab37c 100644
--- a/plugins/day_change.py
+++ b/plugins/day_change.py
@@ -4,11 +4,12 @@ date has changed.
"""
+import datetime
from gettext import gettext as _
+
+from poezio import timed_events, tabs
from poezio.plugin import BasePlugin
-import datetime
-from poezio import tabs
-from poezio import timed_events
+from poezio.ui.types import InfoMessage
class Plugin(BasePlugin):
@@ -30,7 +31,7 @@ class Plugin(BasePlugin):
for tab in self.core.tabs:
if isinstance(tab, tabs.ChatTab):
- tab.add_message(msg)
+ tab.add_message(InfoMessage(msg))
self.core.refresh_window()
self.schedule_event()
diff --git a/plugins/dice.py b/plugins/dice.py
index 376ed26a..3b540cbd 100644
--- a/plugins/dice.py
+++ b/plugins/dice.py
@@ -29,6 +29,7 @@ Configuration
"""
import random
+from typing import Optional
from poezio import tabs
from poezio.decorators import command_args_parser
@@ -40,17 +41,16 @@ DICE = '\u2680\u2681\u2682\u2683\u2684\u2685'
class DiceRoll:
__slots__ = [
'duration', 'total_duration', 'dice_number', 'msgtype', 'jid',
- 'last_msgid', 'increments'
+ 'msgid', 'increments'
]
- def __init__(self, total_duration, dice_number, is_muc, jid, msgid,
- increments):
+ def __init__(self, total_duration, dice_number, msgtype, jid, msgid, increments):
self.duration = 0
self.total_duration = total_duration
self.dice_number = dice_number
- self.msgtype = "groupchat" if is_muc else "chat"
+ self.msgtype = msgtype
self.jid = jid
- self.last_msgid = msgid
+ self.msgid = msgid
self.increments = increments
def reroll(self):
@@ -60,11 +60,14 @@ class DiceRoll:
return self.duration >= self.total_duration
+def roll_dice(num_dice: int) -> str:
+ return ''.join(random.choice(DICE) for _ in range(num_dice))
+
class Plugin(BasePlugin):
- default_config = {"dice": {"refresh": 0.5, "default_duration": 5}}
+ default_config = {"dice": {"refresh": 0.75, "default_duration": 7.5}}
def init(self):
- for tab_t in [tabs.MucTab, tabs.ConversationTab, tabs.PrivateTab]:
+ for tab_t in [tabs.MucTab, tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.PrivateTab]:
self.api.add_tab_command(
tab_t,
'roll',
@@ -90,13 +93,17 @@ class Plugin(BasePlugin):
self.core.command.help("roll")
return
- firstroll = ''.join(random.choice(DICE) for _ in range(num_dice))
- tab.command_say(firstroll)
- is_muctab = isinstance(tab, tabs.MucTab)
- msg_id = tab.last_sent_message["id"]
+ msgtype = 'groupchat' if isinstance(tab, tabs.MucTab) else 'chat'
+
+ message = self.core.xmpp.make_message(tab.jid)
+ message['type'] = msgtype
+ message['body'] = roll_dice(num_dice)
+ message.send()
+
increment = self.config.get('refresh')
- roll = DiceRoll(duration, num_dice, is_muctab, tab.name, msg_id,
- increment)
+ msgid = message['id']
+
+ roll = DiceRoll(duration, num_dice, msgtype, tab.jid, msgid, increment)
event = self.api.create_delayed_event(increment, self.delayed_event,
roll)
self.api.add_timed_event(event)
@@ -107,11 +114,9 @@ class Plugin(BasePlugin):
roll.reroll()
message = self.core.xmpp.make_message(roll.jid)
message["type"] = roll.msgtype
- message["body"] = ''.join(
- random.choice(DICE) for _ in range(roll.dice_number))
- message["replace"]["id"] = roll.last_msgid
+ message["body"] = roll_dice(roll.dice_number)
+ message["replace"]["id"] = roll.msgid
message.send()
- roll.last_msgid = message['id']
event = self.api.create_delayed_event(roll.increments,
self.delayed_event, roll)
self.api.add_timed_event(event)
diff --git a/plugins/disco.py b/plugins/disco.py
index ec0a04cd..d15235f6 100644
--- a/plugins/disco.py
+++ b/plugins/disco.py
@@ -16,7 +16,9 @@ Usage
"""
from poezio.plugin import BasePlugin
+from poezio.decorators import command_args_parser
from slixmpp.jid import InvalidJID
+from slixmpp.exceptions import IqError, IqTimeout
class Plugin(BasePlugin):
@@ -24,11 +26,11 @@ class Plugin(BasePlugin):
self.api.add_command(
'disco',
self.command_disco,
- usage='<JID>',
+ usage='<JID> [node] [info|items]',
short='Get the disco#info of a JID',
help='Get the disco#info of a JID')
- def on_disco(self, iq):
+ def on_info(self, iq):
if iq['type'] == 'error':
self.api.information(iq['error']['text'] or iq['error']['condition'], 'Error')
return
@@ -53,9 +55,52 @@ class Plugin(BasePlugin):
if server_info:
self.api.information('\n'.join(server_info), title)
- def command_disco(self, jid):
+ def on_items(self, iq):
+ if iq['type'] == 'error':
+ self.api.information(iq['error']['text'] or iq['error']['condition'], 'Error')
+ return
+
+ def describe(item):
+ text = item[0]
+ node = item[1]
+ name = item[2]
+ if node is not None:
+ text += ', node=' + node
+ if name is not None:
+ text += ', name=' + name
+ return text
+
+ items = iq['disco_items']
+ self.api.information('\n'.join(describe(item) for item in items['items']), 'Items')
+
+ @command_args_parser.quoted(1, 3)
+ async def command_disco(self, args):
+ if args is None:
+ self.core.command.help('disco')
+ return
+ if len(args) == 1:
+ jid, = args
+ node = None
+ type_ = 'info'
+ elif len(args) == 2:
+ jid, node = args
+ type_ = 'info'
+ else:
+ jid, node, type_ = args
try:
- self.core.xmpp.plugin['xep_0030'].get_info(
- jid=jid, cached=False, callback=self.on_disco)
+ if type_ == 'info':
+ iq = await self.core.xmpp.plugin['xep_0030'].get_info(
+ jid=jid, node=node, cached=False
+ )
+ self.on_info(iq)
+ elif type_ == 'items':
+ iq = await self.core.xmpp.plugin['xep_0030'].get_items(
+ jid=jid, node=node
+ )
+ self.on_items(iq)
except InvalidJID as e:
self.api.information('Invalid JID “%s”: %s' % (jid, e), 'Error')
+ except IqError as e:
+ self.api.information('Received iq error while querying “%s”: %s' % (jid, e), 'Error')
+ except IqTimeout:
+ self.api.information('Received no reply querying “%s”…' % jid, 'Error')
diff --git a/plugins/display_corrections.py b/plugins/display_corrections.py
index 22eb196d..cf8107ce 100644
--- a/plugins/display_corrections.py
+++ b/plugins/display_corrections.py
@@ -25,11 +25,13 @@ Usage
from poezio.plugin import BasePlugin
from poezio.common import shell_split
from poezio import tabs
+from poezio.ui.types import Message
+from poezio.theming import get_theme
class Plugin(BasePlugin):
def init(self):
- for tab_type in (tabs.MucTab, tabs.PrivateTab, tabs.ConversationTab):
+ for tab_type in (tabs.MucTab, tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab):
self.api.add_tab_command(
tab_type,
'display_corrections',
@@ -43,7 +45,9 @@ class Plugin(BasePlugin):
messages = self.api.get_conversation_messages()
if not messages:
return None
- for message in messages[::-1]:
+ for message in reversed(messages):
+ if not isinstance(message, Message):
+ continue
if message.old_message:
if nb == 1:
return message
@@ -52,6 +56,7 @@ class Plugin(BasePlugin):
return None
def command_display_corrections(self, args):
+ theme = get_theme()
args = shell_split(args)
if len(args) == 1:
try:
@@ -64,8 +69,9 @@ class Plugin(BasePlugin):
if message:
display = []
while message:
+ str_time = message.time.strftime(theme.SHORT_TIME_FORMAT)
display.append('%s %s%s%s %s' %
- (message.str_time, '* '
+ (str_time, '* '
if message.me else '', message.nickname, ''
if message.me else '>', message.txt))
message = message.old_message
diff --git a/plugins/embed.py b/plugins/embed.py
index 0cdc41d2..4a68f035 100644
--- a/plugins/embed.py
+++ b/plugins/embed.py
@@ -16,6 +16,7 @@ Usage
from poezio import tabs
from poezio.plugin import BasePlugin
from poezio.theming import get_theme
+from poezio.ui.types import Message
class Plugin(BasePlugin):
@@ -28,21 +29,22 @@ class Plugin(BasePlugin):
help='Embed an image url into the contact\'s client',
usage='<image_url>')
- def embed_image_url(self, args):
- tab = self.api.current_tab()
- message = self.core.xmpp.make_message(tab.name)
- message['body'] = args
- message['oob']['url'] = args
- if isinstance(tab, tabs.MucTab):
- message['type'] = 'groupchat'
- else:
+ def embed_image_url(self, url, tab=None):
+ tab = tab or self.api.current_tab()
+ message = self.core.xmpp.make_message(tab.jid)
+ message['body'] = url
+ message['oob']['url'] = url
+ message['type'] = 'groupchat'
+ if not isinstance(tab, tabs.MucTab):
message['type'] = 'chat'
tab.add_message(
- message['body'],
- nickname=tab.core.own_nick,
- nick_color=get_theme().COLOR_OWN_NICK,
- identifier=message['id'],
- jid=tab.core.xmpp.boundjid,
- typ=1,
+ Message(
+ message['body'],
+ nickname=tab.core.own_nick,
+ nick_color=get_theme().COLOR_OWN_NICK,
+ identifier=message['id'],
+ jid=tab.core.xmpp.boundjid,
+ ),
)
message.send()
+ self.core.refresh_window()
diff --git a/plugins/emoji_ascii.py b/plugins/emoji_ascii.py
new file mode 100644
index 00000000..4beec3b1
--- /dev/null
+++ b/plugins/emoji_ascii.py
@@ -0,0 +1,60 @@
+# poezio emoji_ascii plugin
+#
+# Will translate received Emoji to :emoji: for better display on text terminals,
+# and outgoing :emoji: into Emoji on the wire.
+#
+# Requires emojis.json.gz (MIT licensed) from:
+#
+# git clone https://github.com/vdurmont/emoji-java
+# gzip -9 < ./src/main/resources/emojis.json > poezio/plugins/emojis.json.gz
+
+# TODOs:
+# 1. it messes up your log files (doesn't log original message, logs mutilated :emoji: instead)
+# 2. Doesn't work on outgoing direct messages
+# 3. Doesn't detect pastes, corrupts jabber:x:foobar
+# 4. no auto-completion of emoji aliases
+# 5. coloring of converted Emojis to be able to differentiate them from incoming ASCII
+
+import gzip
+import json
+import os
+import re
+
+from poezio.plugin import BasePlugin
+from typing import Dict
+
+
+class Plugin(BasePlugin):
+ emoji_to_ascii: Dict[str, str] = {}
+ ascii_to_emoji: Dict[str, str] = {}
+ emoji_pattern = None
+ alias_pattern = None
+
+ def init(self):
+ emoji_map_file_name = os.path.abspath(os.path.dirname(__file__) + '/emojis.json.gz')
+ emoji_map_data = gzip.open(emoji_map_file_name, 'r').read().decode('utf-8')
+ emoji_map = json.loads(emoji_map_data)
+ for e in emoji_map:
+ self.emoji_to_ascii[e['emoji']] = ':%s:' % e['aliases'][0]
+ for alias in e['aliases']:
+ # work around :iq: and similar country code misdetection
+ flag = re.match('^[a-z][a-z]$', alias) and "flag" in e["tags"]
+ if not flag:
+ self.ascii_to_emoji[':%s:' % alias] = e['emoji']
+ self.emoji_pattern = re.compile('|'.join(self.emoji_to_ascii.keys()).replace('*', '\*'))
+ self.alias_pattern = re.compile('|'.join(self.ascii_to_emoji.keys()).replace('+', '\+'))
+
+ self.api.add_event_handler('muc_msg', self.emoji2alias)
+ self.api.add_event_handler('conversation_msg', self.emoji2alias)
+ self.api.add_event_handler('private_msg', self.emoji2alias)
+
+ self.api.add_event_handler('muc_say', self.alias2emoji)
+ self.api.add_event_handler('private_say', self.alias2emoji)
+ self.api.add_event_handler('conversation_say', self.alias2emoji)
+
+
+ def emoji2alias(self, msg, tab):
+ msg['body'] = self.emoji_pattern.sub(lambda m: self.emoji_to_ascii[m.group()], msg['body'])
+
+ def alias2emoji(self, msg, tab):
+ msg['body'] = self.alias_pattern.sub(lambda m: self.ascii_to_emoji[m.group()], msg['body'])
diff --git a/plugins/exec.py b/plugins/exec.py
index 0786c86f..68f24486 100644
--- a/plugins/exec.py
+++ b/plugins/exec.py
@@ -95,4 +95,4 @@ class Plugin(BasePlugin):
else:
self.api.run_command('/help exec')
return
- asyncio.ensure_future(self.async_exec(command, arg))
+ asyncio.create_task(self.async_exec(command, arg))
diff --git a/plugins/figlet.py b/plugins/figlet.py
index b8fcb813..4d4c7577 100644
--- a/plugins/figlet.py
+++ b/plugins/figlet.py
@@ -11,15 +11,35 @@ Say something in a Chat tab.
.. note:: Can create fun things when used with :ref:`The rainbow plugin <rainbow-plugin>`.
"""
-from poezio.plugin import BasePlugin
+
import subprocess
+from poezio.plugin import BasePlugin
+
+
+def is_figlet() -> bool:
+ """Ensure figlet exists"""
+ process = subprocess.Popen(
+ ['which', 'figlet'],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ return process.wait() == 0
class Plugin(BasePlugin):
def init(self):
+ if not is_figlet():
+ self.api.information(
+ 'Couldn\'t find the figlet program. '
+ 'Please install it and reload the plugin.',
+ 'Error',
+ )
+ return None
+
self.api.add_event_handler('muc_say', self.figletize)
self.api.add_event_handler('conversation_say', self.figletize)
self.api.add_event_handler('private_say', self.figletize)
+ return None
def figletize(self, msg, tab):
process = subprocess.Popen(
diff --git a/plugins/irc.py b/plugins/irc.py
index eeef128c..f3aa7b63 100644
--- a/plugins/irc.py
+++ b/plugins/irc.py
@@ -20,9 +20,9 @@ Global configuration
:sorted:
gateway
- **Default:** ``irc.poez.io``
+ **Default:** ``irc.jabberfr.org``
- The JID of the IRC gateway to use. If empty, irc.poez.io will be
+ The JID of the IRC gateway to use. If empty, irc.jabberfr.org will be
used. Please try to run your own, though, it’s painless to setup.
initial_connect
@@ -46,17 +46,6 @@ section name, and the following options:
.. glossary::
:sorted:
-
- login_command
- **Default:** ``[empty]``
-
- The command used to identify with the services (e.g. ``IDENTIFY mypassword``).
-
- login_nick
- **Default:** ``[empty]``
-
- The nickname to whom the auth command will be sent.
-
nickname
**Default:** ``[empty]``
@@ -77,14 +66,6 @@ Commands
.. glossary::
:sorted:
- /irc_login
- **Usage:** ``/irc_login [server1] [server2]…``
-
- Authenticate with the specified servers if they are correctly
- configured. If no servers are provided, the plugin will try
- them all. (You need to set :term:`login_nick` and
- :term:`login_command` as well)
-
/irc_join
**Usage:** ``/irc_join <room or server>``
@@ -109,9 +90,9 @@ Example configuration
.. code-block:: ini
[irc]
- gateway = irc.poez.io
+ gateway = irc.jabberfr.org
- [irc.freenode.net]
+ [irc.libera.chat]
nickname = mynick
login_nick = nickserv
login_command = identify mypassword
@@ -129,30 +110,30 @@ Example configuration
"""
+import asyncio
+
+from typing import Optional, Tuple, List, Any
+from slixmpp.jid import JID, InvalidJID
+
from poezio.plugin import BasePlugin
from poezio.decorators import command_args_parser
from poezio.core.structs import Completion
-from poezio import common
from poezio import tabs
class Plugin(BasePlugin):
- def init(self):
- if self.config.get('initial_connect', True):
- self.initial_connect()
-
- self.api.add_command(
- 'irc_login',
- self.command_irc_login,
- usage='[server] [server]…',
- help=('Connect to the specified servers if they '
- 'exist in the configuration and the login '
- 'options are set. If not is given, the '
- 'plugin will try all the sections in the '
- 'configuration.'),
- short='Login to irc servers with nickserv',
- completion=self.completion_irc_login)
-
+ default_config = {
+ 'irc': {
+ "initial_connect": True,
+ "gateway": "irc.jabberfr.org",
+ }
+ }
+
+ def init(self) -> None:
+ if self.config.getbool('initial_connect'):
+ asyncio.create_task(
+ self.initial_connect()
+ )
self.api.add_command(
'irc_join',
self.command_irc_join,
@@ -179,22 +160,38 @@ class Plugin(BasePlugin):
'example.com "hi there"`'),
short='Open a private conversation with an IRC user')
- def join(self, gateway, server):
+ async def join(self, gateway: str, server: JID) -> None:
"Join irc rooms on a server"
- nick = self.config.get_by_tabname(
+ nick: str = self.config.get_by_tabname(
'nickname', server, default='') or self.core.own_nick
- rooms = self.config.get_by_tabname(
+ rooms: List[str] = self.config.get_by_tabname(
'rooms', server, default='').split(':')
+ joins = []
for room in rooms:
room = '{}%{}@{}/{}'.format(room, server, gateway, nick)
- self.core.command.join(room)
+ joins.append(self.core.command.join(room))
- def initial_connect(self):
- gateway = self.config.get('gateway', 'irc.poez.io')
- sections = self.config.sections()
+ await asyncio.gather(*joins)
- for section in (s for s in sections if s != 'irc'):
+ async def initial_connect(self) -> None:
+ gateway: str = self.config.getstr('gateway')
+ sections: List[str] = self.config.sections()
+ sections_jid = []
+ for sect in sections:
+ if sect == 'irc':
+ continue
+ try:
+ sect_jid = JID(sect)
+ if sect_jid != sect_jid.server:
+ self.api.information(f'Invalid server: {sect}', 'Warning')
+ continue
+ except InvalidJID:
+ self.api.information(f'Invalid server: {sect}', 'Warning')
+ continue
+ sections_jid.append(sect_jid)
+
+ for section in sections_jid:
room_suffix = '%{}@{}'.format(section, gateway)
already_opened = False
@@ -203,125 +200,40 @@ class Plugin(BasePlugin):
already_opened = True
break
- login_command = self.config.get_by_tabname(
- 'login_command', section, default='')
- login_nick = self.config.get_by_tabname(
- 'login_nick', section, default='')
- nick = self.config.get_by_tabname(
- 'nickname', section, default='') or self.core.own_nick
- if login_command and login_nick:
-
- def login(gw, sect, log_nick, log_cmd, room_suff):
- dest = '{}%{}'.format(log_nick, room_suff)
- self.core.xmpp.send_message(
- mto=dest, mbody=log_cmd, mtype='chat')
- delayed = self.api.create_delayed_event(
- 5, self.join, gw, sect)
- self.api.add_timed_event(delayed)
-
- if not already_opened:
- self.core.command.join(room_suffix + '/' + nick)
- delayed = self.api.create_delayed_event(
- 5, login, gateway, section, login_nick, login_command,
- room_suffix[1:])
- self.api.add_timed_event(delayed)
- else:
- login(gateway, section, login_nick, login_command,
- room_suffix[1:])
- elif not already_opened:
- self.join(gateway, section)
-
- @command_args_parser.quoted(0, -1)
- def command_irc_login(self, args):
- """
- /irc_login [server] [server]…
- """
- gateway = self.config.get('gateway', 'irc.poez.io')
- if args:
- not_present = []
- sections = self.config.sections()
- for section in args:
- if section not in sections:
- not_present.append(section)
- continue
- login_command = self.config.get_by_tabname(
- 'login_command', section, default='')
- login_nick = self.config.get_by_tabname(
- 'login_nick', section, default='')
- if not login_command and not login_nick:
- not_present.append(section)
- continue
-
- room_suffix = '%{}@{}'.format(section, gateway)
- dest = '{}%{}'.format(login_nick, room_suffix[1:])
- self.core.xmpp.send_message(
- mto=dest, mbody=login_command, mtype='chat')
- if len(not_present) == 1:
- self.api.information(
- 'Section %s does not exist or is not configured' %
- not_present[0], 'Warning')
- elif len(not_present) > 1:
- self.api.information(
- 'Sections %s do not exist or are not configured' %
- ', '.join(not_present), 'Warning')
- else:
- sections = self.config.sections()
-
- for section in (s for s in sections if s != 'irc'):
- login_command = self.config.get_by_tabname(
- 'login_command', section, default='')
- login_nick = self.config.get_by_tabname(
- 'login_nick', section, default='')
- if not login_nick and not login_command:
- continue
-
- room_suffix = '%{}@{}'.format(section, gateway)
- dest = '{}%{}'.format(login_nick, room_suffix[1:])
- self.core.xmpp.send_message(
- mto=dest, mbody=login_command, mtype='chat')
-
- def completion_irc_login(self, the_input):
- """
- completion for /irc_login
- """
- args = the_input.text.split()
- if '' in args:
- args.remove('')
- pos = the_input.get_argument_position()
- sections = self.config.sections()
- if 'irc' in sections:
- sections.remove('irc')
- for section in args:
- try:
- sections.remove(section)
- except:
- pass
- return Completion(the_input.new_completion, sections, pos)
+ if not already_opened:
+ await self.join(gateway, section)
@command_args_parser.quoted(1, 1)
- def command_irc_join(self, args):
+ async def command_irc_join(self, args: Optional[List[str]]) -> None:
"""
/irc_join <room or server>
"""
if not args:
- return self.core.command.help('irc_join')
- sections = self.config.sections()
+ self.core.command.help('irc_join')
+ return
+ sections: List[str] = self.config.sections()
if 'irc' in sections:
sections.remove('irc')
- if args[0] in sections and self.config.get_by_tabname(
- 'rooms', args[0]):
- self.join_server_rooms(args[0])
+ if args[0] in sections:
+ try:
+ section_jid = JID(args[0])
+ except InvalidJID:
+ self.api.information(f'Invalid address: {args[0]}', 'Error')
+ return
+ #self.config.get_by_tabname('rooms', section_jid)
+ await self.join_server_rooms(section_jid)
else:
- self.join_room(args[0])
+ await self.join_room(args[0])
@command_args_parser.quoted(1, 1)
- def command_irc_query(self, args):
+ def command_irc_query(self, args: Optional[List[str]]) -> None:
"""
Open a private conversation with the given nickname, on the current IRC
server.
"""
if args is None:
- return self.core.command.help('irc_query')
+ self.core.command.help('irc_query')
+ return
current_tab_info = self.get_current_tab_irc_info()
if not current_tab_info:
return
@@ -336,14 +248,14 @@ class Plugin(BasePlugin):
else:
self.core.command.message('{}'.format(jid))
- def join_server_rooms(self, section):
+ async def join_server_rooms(self, section: JID) -> None:
"""
Join all the rooms configured for a section
(section = irc server)
"""
- gateway = self.config.get('gateway', 'irc.poez.io')
- rooms = self.config.get_by_tabname('rooms', section).split(':')
- nick = self.config.get_by_tabname('nickname', section)
+ gateway: str = self.config.getstr('gateway')
+ rooms: List[str] = self.config.get_by_tabname('rooms', section).split(':')
+ nick: str = self.config.get_by_tabname('nickname', section)
if nick:
nick = '/' + nick
else:
@@ -351,9 +263,9 @@ class Plugin(BasePlugin):
suffix = '%{}@{}{}'.format(section, gateway, nick)
for room in rooms:
- self.core.command.join(room + suffix)
+ await self.core.command.join(room + suffix)
- def join_room(self, name):
+ async def join_room(self, name: str) -> None:
"""
Join a room with only its name and the current tab
"""
@@ -361,22 +273,26 @@ class Plugin(BasePlugin):
if not current_tab_info:
return
server, gateway = current_tab_info
+ try:
+ server_jid = JID(server)
+ except InvalidJID:
+ return
room = '{}%{}@{}'.format(name, server, gateway)
- if self.config.get_by_tabname('nickname', server):
- room += '/' + self.config.get_by_tabname('nickname', server)
+ if self.config.get_by_tabname('nickname', server_jid.bare):
+ room += '/' + self.config.get_by_tabname('nickname', server_jid.bare)
- self.core.command.join(room)
+ await self.core.command.join(room)
- def get_current_tab_irc_info(self):
+ def get_current_tab_irc_info(self) -> Optional[Tuple[str, str]]:
"""
Return a tuple with the irc server and the gateway hostnames of the
current tab. If the current tab is not an IRC channel or private
conversation, a warning is displayed and None is returned
"""
- gateway = self.config.get('gateway', 'irc.poez.io')
+ gateway: str = self.config.getstr('gateway')
current = self.api.current_tab()
- current_jid = common.safeJID(current.name)
+ current_jid = current.jid
if not current_jid.server == gateway:
self.api.information(
'The current tab does not appear to be an IRC one', 'Warning')
@@ -397,11 +313,11 @@ class Plugin(BasePlugin):
return None
return server, gateway
- def completion_irc_join(self, the_input):
+ def completion_irc_join(self, the_input: Any) -> Completion:
"""
completion for /irc_join
"""
- sections = self.config.sections()
+ sections: List[str] = self.config.sections()
if 'irc' in sections:
sections.remove('irc')
return Completion(the_input.new_completion, sections, 1)
diff --git a/plugins/lastlog.py b/plugins/lastlog.py
new file mode 100644
index 00000000..1c48fa06
--- /dev/null
+++ b/plugins/lastlog.py
@@ -0,0 +1,61 @@
+#! /usr/bin/env python3
+# -*- coding: utf-8 -*-
+# vim:fenc=utf-8
+#
+# Copyright © 2018 Maxime “pep” Buquet
+# Copyright © 2019 Madhur Garg
+#
+# Distributed under terms of the GPL-3.0+ license. See the COPYING file.
+
+"""
+ Search provided string in the buffer and return all results on the screen
+"""
+
+import re
+from typing import Optional
+from datetime import datetime
+
+from poezio.plugin import BasePlugin
+from poezio import tabs
+from poezio.text_buffer import TextBuffer
+from poezio.ui.types import Message as PMessage, InfoMessage
+
+
+def add_line(
+ text_buffer: TextBuffer,
+ text: str,
+ datetime: Optional[datetime] = None,
+ ) -> None:
+ """Adds a textual entry in the TextBuffer"""
+ text_buffer.add_message(InfoMessage(text, time=datetime))
+
+
+class Plugin(BasePlugin):
+ """Lastlog Plugin"""
+
+ def init(self):
+ for tab in tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.PrivateTab, tabs.MucTab:
+ self.api.add_tab_command(
+ tab,
+ 'lastlog',
+ self.command_lastlog,
+ usage='<keyword>',
+ help='Search <keyword> in the buffer and returns results'
+ 'on the screen')
+
+ def command_lastlog(self, input_):
+ """Define lastlog command"""
+
+ text_buffer = self.api.current_tab()._text_buffer
+ search_re = re.compile(input_, re.I)
+
+ res = []
+ add_line(text_buffer, "Lastlog:")
+ for message in text_buffer.messages:
+ if isinstance(message, PMessage) and \
+ search_re.search(message.txt) is not None:
+ res.append(message)
+ add_line(text_buffer, "%s> %s" % (message.nickname, message.txt), message.time)
+ add_line(text_buffer, "End of Lastlog")
+ self.api.current_tab().text_win.pos = 0
+ self.api.current_tab().core.refresh_window()
diff --git a/plugins/link.py b/plugins/link.py
index 352d403d..699215ea 100644
--- a/plugins/link.py
+++ b/plugins/link.py
@@ -76,7 +76,7 @@ Options
Set the default browser started by the plugin
.. _Unix FIFO: https://en.wikipedia.org/wiki/Named_pipe
-.. _daemon.py: http://dev.louiz.org/projects/poezio/repository/revisions/master/raw/poezio/daemon.py
+.. _daemon.py: https://lab.louiz.org/poezio/poezio/raw/main/poezio/daemon.py
"""
import platform
@@ -87,8 +87,17 @@ from poezio.xhtml import clean_text
from poezio import common
from poezio import tabs
-url_pattern = re.compile(r'\b(?:http[s]?://(?:\S+))|(?:magnet:\?(?:\S+))\b',
- re.I | re.U)
+url_pattern = re.compile(
+ r'\b'
+ '(?:http[s]?://(?:\S+))|'
+ '(?:magnet:\?(?:\S+))|'
+ '(?:aesgcm://(?:\S+))|'
+ '(?:gopher://(?:\S+))|'
+ '(?:gemini://(?:\S+))'
+ '\b',
+ re.I | re.U
+)
+
app_mapping = {
'Linux': 'xdg-open',
'Darwin': 'open',
@@ -97,7 +106,7 @@ app_mapping = {
class Plugin(BasePlugin):
def init(self):
- for _class in (tabs.MucTab, tabs.PrivateTab, tabs.ConversationTab):
+ for _class in (tabs.MucTab, tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab):
self.api.add_tab_command(
_class,
'link',
diff --git a/plugins/marquee.py b/plugins/marquee.py
index bad06301..66ec8b70 100644
--- a/plugins/marquee.py
+++ b/plugins/marquee.py
@@ -34,6 +34,7 @@ Configuration
"""
+import asyncio
from poezio.plugin import BasePlugin
from poezio import tabs
from poezio import xhtml
@@ -41,7 +42,7 @@ from poezio.decorators import command_args_parser
def move(text, step, spacing):
- new_text = text + (" " * spacing)
+ new_text = text + ("\u00A0" * spacing)
return new_text[-(step % len(new_text)):] + new_text[:-(
step % len(new_text))]
@@ -56,19 +57,21 @@ class Plugin(BasePlugin):
}
def init(self):
- for tab_t in [tabs.MucTab, tabs.ConversationTab, tabs.PrivateTab]:
+ for tab_t in [tabs.MucTab, tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.PrivateTab]:
self.add_tab_command(
tab_t, 'marquee', self.command_marquee,
'Replicate the <marquee/> behavior in a message')
@command_args_parser.raw
- def command_marquee(self, args):
+ async def command_marquee(self, args):
+ if not args:
+ return None
tab = self.api.current_tab()
args = xhtml.clean_text(xhtml.convert_simple_to_full_colors(args))
- tab.command_say(args)
+ await tab.command_say(args)
is_muctab = isinstance(tab, tabs.MucTab)
msg_id = tab.last_sent_message["id"]
- jid = tab.name
+ jid = tab.jid
event = self.api.create_delayed_event(
self.config.get("refresh"), self.delayed_event, jid, args, msg_id,
@@ -85,6 +88,6 @@ class Plugin(BasePlugin):
message.send()
event = self.api.create_delayed_event(
self.config.get("refresh"), self.delayed_event, jid, body,
- message["id"], step + 1, duration + self.config.get("refresh"),
+ msg_id, step + 1, duration + self.config.get("refresh"),
is_muctab)
self.api.add_timed_event(event)
diff --git a/plugins/mirror.py b/plugins/mirror.py
index 116d16b1..55c429a3 100644
--- a/plugins/mirror.py
+++ b/plugins/mirror.py
@@ -16,7 +16,7 @@ from poezio import tabs
class Plugin(BasePlugin):
def init(self):
- for tab_type in (tabs.MucTab, tabs.PrivateTab, tabs.ConversationTab):
+ for tab_type in (tabs.MucTab, tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab):
self.api.add_tab_command(
tab_type,
'mirror',
diff --git a/plugins/mpd_client.py b/plugins/mpd_client.py
index a8893999..f1eea902 100644
--- a/plugins/mpd_client.py
+++ b/plugins/mpd_client.py
@@ -57,7 +57,7 @@ import mpd
class Plugin(BasePlugin):
def init(self):
- for _class in (tabs.ConversationTab, tabs.MucTab, tabs.PrivateTab):
+ for _class in (tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.MucTab, tabs.PrivateTab):
self.api.add_tab_command(
_class,
'mpd',
diff --git a/plugins/otr.py b/plugins/otr.py
index 9c80f390..6c15f3d2 100644
--- a/plugins/otr.py
+++ b/plugins/otr.py
@@ -184,7 +184,6 @@ and :term:`log` configuration parameters are tab-specific.
from gettext import gettext as _
import logging
-log = logging.getLogger(__name__)
import os
import html
import curses
@@ -194,10 +193,11 @@ import potr
from potr.context import NotEncryptedError, UnencryptedMessage, ErrorReceived, NotOTRMessage,\
STATE_ENCRYPTED, STATE_PLAINTEXT, STATE_FINISHED, Context, Account, crypt
+from slixmpp import JID, InvalidJID
+
from poezio import common
from poezio import xdg
from poezio import xhtml
-from poezio.common import safeJID
from poezio.config import config
from poezio.plugin import BasePlugin
from poezio.roster import roster
@@ -205,6 +205,9 @@ from poezio.tabs import StaticConversationTab, PrivateTab
from poezio.theming import get_theme, dump_tuple
from poezio.decorators import command_args_parser
from poezio.core.structs import Completion
+from poezio.ui.types import InfoMessage, Message
+
+log = logging.getLogger(__name__)
POLICY_FLAGS = {
'ALLOW_V1': False,
@@ -306,7 +309,7 @@ MESSAGE_INVALID = _('%(info)sThe message from %(jid_c)s%(jid)s%(info)s'
OTR_ERROR = _('%(info)sReceived the following error from '
'%(jid_c)s%(jid)s%(info)s:\x19o %(err)s')
-POTR_ERROR = _('%(info)sAn unspecified error in the OTR plugin occured:\n'
+POTR_ERROR = _('%(info)sAn unspecified error in the OTR plugin occurred:\n'
'%(exc)s')
TRUST_ADDED = _('%(info)sYou added %(jid_c)s%(bare_jid)s%(info)s with key '
@@ -325,7 +328,7 @@ def hl(tab):
if tab.state != 'current':
tab.state = 'private'
- conv_jid = safeJID(tab.name)
+ conv_jid = tab.jid
if 'private' in config.get('beep_on', 'highlight private').split():
if not config.get_by_tabname(
'disable_beep', conv_jid.bare, default=False):
@@ -344,7 +347,7 @@ class PoezioContext(Context):
self.xmpp = xmpp
self.core = core
self.flags = {}
- self.trustName = safeJID(peer).bare
+ self.trustName = JID(peer).bare
self.in_smp = False
self.smp_own = False
self.log = 0
@@ -374,7 +377,7 @@ class PoezioContext(Context):
'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
'normal': '\x19%s}' % dump_tuple(get_theme().COLOR_NORMAL_TEXT),
'jid': self.peer,
- 'bare_jid': safeJID(self.peer).bare
+ 'bare_jid': JID(self.peer).bare
}
tab = self.core.tabs.by_name(self.peer)
@@ -385,25 +388,28 @@ class PoezioContext(Context):
log.debug('OTR conversation with %s refreshed', self.peer)
if self.getCurrentTrust():
msg = OTR_REFRESH_TRUSTED % format_dict
- tab.add_message(msg, typ=self.log)
+ tab.add_message(InfoMessage(msg))
else:
msg = OTR_REFRESH_UNTRUSTED % format_dict
- tab.add_message(msg, typ=self.log)
+ tab.add_message(InfoMessage(msg))
hl(tab)
elif newstate == STATE_FINISHED or newstate == STATE_PLAINTEXT:
log.debug('OTR conversation with %s finished', self.peer)
if tab:
- tab.add_message(OTR_END % format_dict, typ=self.log)
+ tab.add_message(InfoMessage(OTR_END % format_dict))
hl(tab)
elif newstate == STATE_ENCRYPTED and tab:
if self.getCurrentTrust():
- tab.add_message(OTR_START_TRUSTED % format_dict, typ=self.log)
+ tab.add_message(InfoMessage(OTR_START_TRUSTED % format_dict))
else:
format_dict['our_fpr'] = self.user.getPrivkey()
format_dict['remote_fpr'] = self.getCurrentKey()
- tab.add_message(OTR_TUTORIAL % format_dict, typ=0)
tab.add_message(
- OTR_START_UNTRUSTED % format_dict, typ=self.log)
+ InfoMessage(OTR_TUTORIAL % format_dict),
+ )
+ tab.add_message(
+ InfoMessage(OTR_START_UNTRUSTED % format_dict),
+ )
hl(tab)
log.debug('Set encryption state of %s to %s', self.peer,
@@ -455,8 +461,9 @@ class PoezioAccount(Account):
if acc != self.name or proto != 'xmpp':
continue
- jid = safeJID(ctx).bare
- if not jid:
+ try:
+ jid = JID(ctx).bare
+ except InvalidJID:
continue
self.setTrust(jid, fpr, trust)
except:
@@ -589,7 +596,7 @@ class Plugin(BasePlugin):
"""
Retrieve or create an OTR context
"""
- jid = safeJID(jid)
+ jid = JID(jid)
if jid.full not in self.contexts:
flags = POLICY_FLAGS.copy()
require = self.config.get_by_tabname(
@@ -607,6 +614,8 @@ class Plugin(BasePlugin):
"""
Message received
"""
+ if msg['from'].bare == self.core.xmpp.boundjid.bare:
+ return
format_dict = {
'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
@@ -639,7 +648,7 @@ class Plugin(BasePlugin):
# Received an OTR error
proto_error = err.args[0].error # pylint: disable=no-member
format_dict['err'] = proto_error.decode('utf-8', errors='replace')
- tab.add_message(OTR_ERROR % format_dict, typ=0)
+ tab.add_message(InfoMessage(OTR_ERROR % format_dict))
del msg['body']
del msg['html']
hl(tab)
@@ -649,7 +658,7 @@ class Plugin(BasePlugin):
# Encrypted message received, but unreadable as we do not have
# an OTR session in place.
text = MESSAGE_UNREADABLE % format_dict
- tab.add_message(text, jid=msg['from'], typ=0)
+ tab.add_message(InfoMessage(text))
hl(tab)
del msg['body']
del msg['html']
@@ -658,7 +667,7 @@ class Plugin(BasePlugin):
except crypt.InvalidParameterError:
# Malformed OTR payload and stuff
text = MESSAGE_INVALID % format_dict
- tab.add_message(text, jid=msg['from'], typ=0)
+ tab.add_message(InfoMessage(text))
hl(tab)
del msg['body']
del msg['html']
@@ -669,7 +678,7 @@ class Plugin(BasePlugin):
import traceback
exc = traceback.format_exc()
format_dict['exc'] = exc
- tab.add_message(POTR_ERROR % format_dict, typ=0)
+ tab.add_message(InfoMessage(POTR_ERROR % format_dict))
log.error('Unspecified error in the OTR plugin', exc_info=True)
return
# No error, proceed with the message
@@ -688,10 +697,10 @@ class Plugin(BasePlugin):
abort = get_tlv(tlvs, potr.proto.SMPABORTTLV)
if abort:
ctx.reset_smp()
- tab.add_message(SMP_ABORTED_PEER % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_ABORTED_PEER % format_dict))
elif ctx.in_smp and not ctx.smpIsValid():
ctx.reset_smp()
- tab.add_message(SMP_ABORTED % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_ABORTED % format_dict))
elif smp1 or smp1q:
# Received an SMP request (with a question or not)
if smp1q:
@@ -709,22 +718,22 @@ class Plugin(BasePlugin):
# we did not initiate it
ctx.smp_own = False
format_dict['q'] = question
- tab.add_message(SMP_REQUESTED % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_REQUESTED % format_dict))
elif smp2:
# SMP reply received
if not ctx.in_smp:
ctx.reset_smp()
else:
- tab.add_message(SMP_PROGRESS % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_PROGRESS % format_dict))
elif smp3 or smp4:
# Type 4 (SMP message 3) or 5 (SMP message 4) TLVs received
# in both cases it is the final message of the SMP exchange
if ctx.smpIsSuccess():
- tab.add_message(SMP_SUCCESS % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_SUCCESS % format_dict))
if not ctx.getCurrentTrust():
- tab.add_message(SMP_RECIPROCATE % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_RECIPROCATE % format_dict))
else:
- tab.add_message(SMP_FAIL % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_FAIL % format_dict))
ctx.reset_smp()
hl(tab)
self.core.refresh_window()
@@ -736,7 +745,13 @@ class Plugin(BasePlugin):
"""
format_dict['msg'] = err.args[0].decode('utf-8')
text = MESSAGE_UNENCRYPTED % format_dict
- tab.add_message(text, jid=msg['from'], typ=ctx.log)
+ tab.add_message(
+ Message(
+ text,
+ nickname=tab.nick,
+ jid=msg['from'],
+ ),
+ )
del msg['body']
del msg['html']
hl(tab)
@@ -780,12 +795,14 @@ class Plugin(BasePlugin):
if decode_newlines:
body = body.replace('<br/>', '\n').replace('<br>', '\n')
tab.add_message(
- body,
- nickname=tab.nick,
- jid=msg['from'],
- forced_user=user,
- typ=ctx.log,
- nick_color=nick_color)
+ Message(
+ body,
+ nickname=tab.nick,
+ jid=msg['from'],
+ user=user,
+ nick_color=nick_color
+ ),
+ )
hl(tab)
self.core.refresh_window()
del msg['body']
@@ -795,9 +812,11 @@ class Plugin(BasePlugin):
Find an OTR session from a bare JID.
"""
for ctx in self.contexts:
- if safeJID(
- ctx
- ).bare == bare_jid and self.contexts[ctx].state == STATE_ENCRYPTED:
+ try:
+ jid = JID(ctx).bare
+ except InvalidJID:
+ continue
+ if jid == bare_jid and self.contexts[ctx].state == STATE_ENCRYPTED:
return self.contexts[ctx]
return None
@@ -806,7 +825,7 @@ class Plugin(BasePlugin):
On message sent
"""
name = tab.name
- jid = safeJID(tab.name)
+ jid = tab.jid
format_dict = {
'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
@@ -826,19 +845,21 @@ class Plugin(BasePlugin):
tab.send_chat_state('inactive', always_send=True)
tab.add_message(
- msg['body'],
- nickname=self.core.own_nick or tab.own_nick,
- nick_color=get_theme().COLOR_OWN_NICK,
- identifier=msg['id'],
- jid=self.core.xmpp.boundjid,
- typ=ctx.log)
+ Message(
+ msg['body'],
+ nickname=self.core.own_nick or tab.own_nick,
+ nick_color=get_theme().COLOR_OWN_NICK,
+ identifier=msg['id'],
+ jid=self.core.xmpp.boundjid,
+ ),
+ )
# remove everything from the message so that it doesn’t get sent
del msg['body']
del msg['replace']
del msg['html']
elif is_relevant(tab) and ctx and ctx.getPolicy('REQUIRE_ENCRYPTION'):
warning_msg = MESSAGE_NOT_SENT % format_dict
- tab.add_message(warning_msg, typ=0)
+ tab.add_message(InfoMessage(warning_msg))
del msg['body']
del msg['replace']
del msg['html']
@@ -846,7 +867,7 @@ class Plugin(BasePlugin):
elif not is_relevant(tab) and ctx and (
ctx.state == STATE_ENCRYPTED
or ctx.getPolicy('REQUIRE_ENCRYPTION')):
- contact = roster[tab.name]
+ contact = roster[tab.jid.bare]
res = []
if contact:
res = [resource.jid for resource in contact.resources]
@@ -856,7 +877,7 @@ class Plugin(BasePlugin):
('\n - /message %s' % jid) for jid in res)
format_dict['help'] = help_msg
warning_msg = INCOMPATIBLE_TAB % format_dict
- tab.add_message(warning_msg, typ=0)
+ tab.add_message(InfoMessage(warning_msg))
del msg['body']
del msg['replace']
del msg['html']
@@ -866,7 +887,11 @@ class Plugin(BasePlugin):
Returns the text to display in the infobar (the OTR status)
"""
context = self.get_context(jid)
- if safeJID(jid).bare == jid and context.state != STATE_ENCRYPTED:
+ try:
+ bare_jid = JID(jid).bare
+ except InvalidJID:
+ bare_jid = ''
+ if bare_jid == jid and context.state != STATE_ENCRYPTED:
ctx = self.find_encrypted_context_with_matching(jid)
if ctx:
context = ctx
@@ -884,13 +909,13 @@ class Plugin(BasePlugin):
return self.core.command.help('otr')
action = args.pop(0)
tab = self.api.current_tab()
- name = tab.name
+ name = tab.jid.full
format_dict = {
'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
'normal': '\x19%s}' % dump_tuple(get_theme().COLOR_NORMAL_TEXT),
- 'jid': name,
- 'bare_jid': safeJID(name).bare
+ 'jid': tab.jid.full,
+ 'bare_jid': tab.jid.bare,
}
if action == 'end': # close the session
@@ -900,22 +925,22 @@ class Plugin(BasePlugin):
self.otr_start(tab, name, format_dict)
elif action == 'ourfpr':
format_dict['fpr'] = self.account.getPrivkey()
- tab.add_message(OTR_OWN_FPR % format_dict, typ=0)
+ tab.add_message(InfoMessage(OTR_OWN_FPR % format_dict))
elif action == 'fpr':
if name in self.contexts:
ctx = self.contexts[name]
if ctx.getCurrentKey() is not None:
format_dict['fpr'] = ctx.getCurrentKey()
- tab.add_message(OTR_REMOTE_FPR % format_dict, typ=0)
+ tab.add_message(InfoMessage(OTR_REMOTE_FPR % format_dict))
else:
- tab.add_message(OTR_NO_FPR % format_dict, typ=0)
+ tab.add_message(InfoMessage(OTR_NO_FPR % format_dict))
elif action == 'drop':
# drop the privkey (and obviously, end the current conversations before that)
for context in self.contexts.values():
if context.state not in (STATE_FINISHED, STATE_PLAINTEXT):
context.disconnect()
self.account.drop_privkey()
- tab.add_message(KEY_DROPPED % format_dict, typ=0)
+ tab.add_message(InfoMessage(KEY_DROPPED % format_dict))
elif action == 'trust':
ctx = self.get_context(name)
key = ctx.getCurrentKey()
@@ -927,7 +952,7 @@ class Plugin(BasePlugin):
format_dict['key'] = key
ctx.setTrust(fpr, 'verified')
self.account.saveTrusts()
- tab.add_message(TRUST_ADDED % format_dict, typ=0)
+ tab.add_message(InfoMessage(TRUST_ADDED % format_dict))
elif action == 'untrust':
ctx = self.get_context(name)
key = ctx.getCurrentKey()
@@ -939,7 +964,7 @@ class Plugin(BasePlugin):
format_dict['key'] = key
ctx.setTrust(fpr, '')
self.account.saveTrusts()
- tab.add_message(TRUST_REMOVED % format_dict, typ=0)
+ tab.add_message(InfoMessage(TRUST_REMOVED % format_dict))
self.core.refresh_window()
def otr_start(self, tab, name, format_dict):
@@ -954,7 +979,7 @@ class Plugin(BasePlugin):
if otr.state != STATE_ENCRYPTED:
format_dict['secs'] = secs
text = OTR_NOT_ENABLED % format_dict
- tab.add_message(text, typ=0)
+ tab.add_message(InfoMessage(text))
self.core.refresh_window()
if secs > 0:
@@ -962,7 +987,7 @@ class Plugin(BasePlugin):
self.api.add_timed_event(event)
body = self.get_context(name).sendMessage(0, b'?OTRv?').decode()
self.core.xmpp.send_message(mto=name, mtype='chat', mbody=body)
- tab.add_message(OTR_REQUEST % format_dict, typ=0)
+ tab.add_message(InfoMessage(OTR_REQUEST % format_dict))
@staticmethod
def completion_otr(the_input):
@@ -991,12 +1016,12 @@ class Plugin(BasePlugin):
question = secret = None
tab = self.api.current_tab()
- name = tab.name
+ name = tab.jid.full
format_dict = {
'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
- 'jid': name,
- 'bare_jid': safeJID(name).bare
+ 'jid': tab.jid.full,
+ 'bare_jid': tab.jid.bare,
}
ctx = self.get_context(name)
@@ -1012,13 +1037,13 @@ class Plugin(BasePlugin):
ctx.smpInit(secret, question)
else:
ctx.smpInit(secret)
- tab.add_message(SMP_INITIATED % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_INITIATED % format_dict))
elif action == 'answer':
ctx.smpGotSecret(secret)
elif action == 'abort':
if ctx.in_smp:
ctx.smpAbort()
- tab.add_message(SMP_ABORTED % format_dict, typ=0)
+ tab.add_message(InfoMessage(SMP_ABORTED % format_dict))
self.core.refresh_window()
@staticmethod
diff --git a/plugins/ping.py b/plugins/ping.py
index 4868ccf9..cc987bf0 100644
--- a/plugins/ping.py
+++ b/plugins/ping.py
@@ -21,11 +21,13 @@ Command
In a private or a direct conversation, you can do ``/ping`` to ping
the current interlocutor.
"""
+import asyncio
+from slixmpp import InvalidJID, JID
+from slixmpp.exceptions import IqTimeout
from poezio.decorators import command_args_parser
from poezio.plugin import BasePlugin
from poezio.roster import roster
-from poezio.common import safeJID
from poezio.contact import Contact, Resource
from poezio.core.structs import Completion
from poezio import tabs
@@ -57,7 +59,7 @@ class Plugin(BasePlugin):
help='Send an XMPP ping to jid (see XEP-0199).',
short='Send a ping.',
completion=self.completion_ping)
- for _class in (tabs.PrivateTab, tabs.ConversationTab):
+ for _class in (tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab):
self.api.add_tab_command(
_class,
'ping',
@@ -69,13 +71,19 @@ class Plugin(BasePlugin):
completion=self.completion_ping)
@command_args_parser.raw
- def command_ping(self, arg):
+ async def command_ping(self, arg):
if not arg:
return self.core.command.help('ping')
- jid = safeJID(arg)
+ try:
+ jid = JID(arg)
+ except InvalidJID:
+ return self.api.information('Invalid JID: %s' % arg, 'Error')
start = time.time()
- def callback(iq):
+ try:
+ iq = await self.core.xmpp.plugin['xep_0199'].send_ping(
+ jid=jid, timeout=10
+ )
delay = time.time() - start
error = False
reply = ''
@@ -98,13 +106,11 @@ class Plugin(BasePlugin):
message = '%s responded to ping after %ss%s' % (
jid, round(delay, 4), reply)
self.api.information(message, 'Info')
-
- def timeout(iq):
+ except IqTimeout:
self.api.information(
- '%s did not respond to ping after 10s: timeout' % jid, 'Info')
-
- self.core.xmpp.plugin['xep_0199'].send_ping(
- jid=jid, callback=callback, timeout=10, timeout_callback=timeout)
+ '%s did not respond to ping after 10s: timeout' % jid,
+ 'Info'
+ )
def completion_muc_ping(self, the_input):
users = [user.nick for user in self.api.current_tab().users]
@@ -114,9 +120,12 @@ class Plugin(BasePlugin):
@command_args_parser.raw
def command_private_ping(self, arg):
- if arg:
- return self.command_ping(arg)
- self.command_ping(self.api.current_tab().name)
+ jid = arg
+ if not arg:
+ jid = self.api.current_tab().jid
+ asyncio.create_task(
+ self.command_ping(jid)
+ )
@command_args_parser.raw
def command_muc_ping(self, arg):
@@ -124,24 +133,32 @@ class Plugin(BasePlugin):
return
user = self.api.current_tab().get_user_by_name(arg)
if user:
- jid = safeJID(self.api.current_tab().name)
+ jid = self.api.current_tab().jid
jid.resource = user.nick
else:
- jid = safeJID(arg)
- self.command_ping(jid.full)
+ try:
+ jid = JID(arg)
+ except InvalidJID:
+ return self.api.information('Invalid JID: %s' % arg, 'Error')
+ asyncio.create_task(
+ self.command_ping(jid.full)
+ )
@command_args_parser.raw
def command_roster_ping(self, arg):
if arg:
- self.command_ping(arg)
+ jid = arg
else:
current = self.api.current_tab().selected_row
if isinstance(current, Resource):
- self.command_ping(current.jid)
+ jid = current.jid
elif isinstance(current, Contact):
res = current.get_highest_priority_resource()
if res is not None:
- self.command_ping(res.jid)
+ jid =res.jid
+ asyncio.create_task(
+ self.command_ping(jid)
+ )
def resources(self):
l = []
diff --git a/plugins/qr.py b/plugins/qr.py
new file mode 100755
index 00000000..735c3002
--- /dev/null
+++ b/plugins/qr.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+
+import io
+import logging
+import qrcode
+
+from typing import Dict, Callable
+
+from slixmpp import JID, InvalidJID
+
+from poezio import windows
+from poezio.tabs import Tab
+from poezio.core.structs import Command
+from poezio.decorators import command_args_parser
+from poezio.plugin import BasePlugin
+from poezio.theming import get_theme, to_curses_attr
+from poezio.windows.base_wins import Win
+
+log = logging.getLogger(__name__)
+
+class QrWindow(Win):
+ __slots__ = ('qr', 'invert', 'inverted')
+
+ str_invert = " Invert "
+ str_close = " Close "
+
+ def __init__(self, qr: str) -> None:
+ self.qr = qr
+ self.invert = True
+ self.inverted = True
+
+ def refresh(self) -> None:
+ self._win.erase()
+ # draw QR code
+ code = qrcode.QRCode()
+ code.add_data(self.qr)
+ out = io.StringIO()
+ code.print_ascii(out, invert=self.inverted)
+ self.addstr(" " + self.qr + "\n")
+ self.addstr(out.getvalue(), to_curses_attr((15, 0)))
+ self.addstr(" ")
+
+ col = to_curses_attr(get_theme().COLOR_TAB_NORMAL)
+
+ if self.invert:
+ self.addstr(self.str_invert, col)
+ else:
+ self.addstr(self.str_invert)
+
+ self.addstr(" ")
+
+ if self.invert:
+ self.addstr(self.str_close)
+ else:
+ self.addstr(self.str_close, col)
+
+ self._refresh()
+
+ def toggle_choice(self) -> None:
+ self.invert = not self.invert
+
+ def engage(self) -> bool:
+ if self.invert:
+ self.inverted = not self.inverted
+ return False
+ else:
+ return True
+
+class QrTab(Tab):
+ plugin_commands = {} # type: Dict[str, Command]
+ plugin_keys = {} # type: Dict[str, Callable]
+
+ def __init__(self, core, qr):
+ Tab.__init__(self, core)
+ self.state = 'highlight'
+ self.text = qr
+ self._name = qr
+ self.topic_win = windows.Topic()
+ self.topic_win.set_message(qr)
+ self.qr_win = QrWindow(qr)
+ self.help_win = windows.HelpText(
+ "Choose with arrow keys and press enter")
+ self.key_func['^I'] = self.toggle_choice
+ self.key_func[' '] = self.toggle_choice
+ self.key_func['KEY_LEFT'] = self.toggle_choice
+ self.key_func['KEY_RIGHT'] = self.toggle_choice
+ self.key_func['^M'] = self.engage
+ self.resize()
+ self.update_commands()
+ self.update_keys()
+
+ def resize(self):
+ self.need_resize = False
+ self.topic_win.resize(1, self.width, 0, 0)
+ self.qr_win.resize(self.height-3, self.width, 1, 0)
+ self.help_win.resize(1, self.width, self.height-1, 0)
+
+ def refresh(self):
+ if self.need_resize:
+ self.resize()
+ log.debug(' TAB Refresh: %s', self.__class__.__name__)
+ self.refresh_tab_win()
+ self.info_win.refresh()
+ self.topic_win.refresh()
+ self.qr_win.refresh()
+ self.help_win.refresh()
+
+ def on_input(self, key, raw):
+ if not raw and key in self.key_func:
+ return self.key_func[key]()
+
+ def toggle_choice(self):
+ log.debug(' TAB toggle_choice: %s', self.__class__.__name__)
+ self.qr_win.toggle_choice()
+ self.refresh()
+ self.core.doupdate()
+
+ def engage(self):
+ log.debug(' TAB engage: %s', self.__class__.__name__)
+ if self.qr_win.engage():
+ self.core.close_tab(self)
+ else:
+ self.refresh()
+ self.core.doupdate()
+
+class Plugin(BasePlugin):
+ def init(self):
+ self.api.add_command(
+ 'qr',
+ self.command_qr,
+ usage='<message>',
+ short='Display a QR code',
+ help='Display a QR code of <message> in a new tab')
+ self.api.add_command(
+ 'invitation',
+ self.command_invite,
+ usage='[<server>]',
+ short='Invite a user',
+ help='Generate a XEP-0401 invitation on your server or on <server> and display a QR code')
+
+ def command_qr(self, msg):
+ t = QrTab(self.core, msg)
+ self.core.add_tab(t, True)
+ self.core.doupdate()
+
+ def on_next(self, iq, adhoc_session):
+ status = iq['command']['status']
+ xform = iq.xml.find(
+ '{http://jabber.org/protocol/commands}command/{jabber:x:data}x')
+ if xform is not None:
+ form = self.core.xmpp.plugin['xep_0004'].build_form(xform)
+ else:
+ form = None
+ uri = None
+ if status == 'completed' and form:
+ for field in form:
+ log.debug(' field: %s -> %s', field['var'], field['value'])
+ if field['var'] == 'landing-url' and field['value']:
+ uri = field.get_value(convert=False)
+ if field['var'] == 'uri' and field['value'] and uri is None:
+ uri = field.get_value(convert=False)
+ if uri:
+ t = QrTab(self.core, uri)
+ self.core.add_tab(t, True)
+ self.core.doupdate()
+ else:
+ self.core.handler.next_adhoc_step(iq, adhoc_session)
+
+
+ @command_args_parser.quoted(0, 1, defaults=[])
+ def command_invite(self, args):
+ server = self.core.xmpp.boundjid.domain
+ if len(args) > 0:
+ try:
+ server = JID(args[0])
+ except InvalidJID:
+ self.api.information(f'Invalid JID: {args[0]}', 'Error')
+ return
+ session = {
+ 'next' : self.on_next,
+ 'error': self.core.handler.adhoc_error
+ }
+ self.core.xmpp.plugin['xep_0050'].start_command(server, 'urn:xmpp:invite#invite', session)
+
diff --git a/plugins/quote.py b/plugins/quote.py
index b412cd9a..d7bc1e2a 100644
--- a/plugins/quote.py
+++ b/plugins/quote.py
@@ -45,8 +45,10 @@ Options
"""
from poezio.core.structs import Completion
+from poezio.ui.types import Message
from poezio.plugin import BasePlugin
from poezio.xhtml import clean_text
+from poezio.theming import get_theme
from poezio import common
from poezio import tabs
@@ -56,7 +58,7 @@ log = logging.getLogger(__name__)
class Plugin(BasePlugin):
def init(self):
- for _class in (tabs.MucTab, tabs.ConversationTab, tabs.PrivateTab):
+ for _class in (tabs.MucTab, tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.PrivateTab):
self.api.add_tab_command(
_class,
'quote',
@@ -74,13 +76,14 @@ class Plugin(BasePlugin):
return self.api.run_command('/help quote')
message = self.find_message(message)
if message:
+ str_time = message.time.strftime(get_theme().SHORT_TIME_FORMAT)
before = self.config.get('before_quote', '') % {
'nick': message.nickname or '',
- 'time': message.str_time
+ 'time': str_time,
}
after = self.config.get('after_quote', '') % {
'nick': message.nickname or '',
- 'time': message.str_time
+ 'time': str_time,
}
self.core.insert_input_text(
'%(before)s%(quote)s%(after)s' % {
@@ -96,7 +99,7 @@ class Plugin(BasePlugin):
if not messages:
return None
for message in messages[::-1]:
- if clean_text(message.txt) == txt:
+ if isinstance(message, Message) and clean_text(message.txt) == txt:
return message
return None
@@ -114,5 +117,8 @@ class Plugin(BasePlugin):
messages = list(filter(message_match, messages))
elif len(args) > 1:
return False
- return Completion(the_input.auto_completion,
- [clean_text(msg.txt) for msg in messages[::-1]], '')
+ return Completion(
+ the_input.auto_completion,
+ [clean_text(msg.txt) for msg in messages[::-1] if isinstance(msg, Message)],
+ ''
+ )
diff --git a/plugins/rainbow.py b/plugins/rainbow.py
index 4ab0b9ac..e5987089 100644
--- a/plugins/rainbow.py
+++ b/plugins/rainbow.py
@@ -14,7 +14,7 @@ Usage
.. note:: Can create fun things when used with :ref:`The figlet plugin <figlet-plugin>`.
-.. _#3273: https://dev.louiz.org/issues/3273
+.. _#3273: https://lab.louiz.org/poezio/poezio/-/issues/3273
"""
from poezio.plugin import BasePlugin
from poezio import xhtml
diff --git a/plugins/remove_get_trackers.py b/plugins/remove_get_trackers.py
new file mode 100644
index 00000000..db1e87f3
--- /dev/null
+++ b/plugins/remove_get_trackers.py
@@ -0,0 +1,24 @@
+"""
+Remove GET trackers from URLs in sent messages.
+"""
+from poezio.plugin import BasePlugin
+import re
+
+class Plugin(BasePlugin):
+ def init(self):
+ self.api.information('This plugin is deprecated and will be replaced by \'untrackme\'.', 'Warning')
+
+ self.api.add_event_handler('muc_say', self.remove_get_trackers)
+ self.api.add_event_handler('conversation_say', self.remove_get_trackers)
+ self.api.add_event_handler('private_say', self.remove_get_trackers)
+
+ def remove_get_trackers(self, msg, tab):
+ # fbclid: used globally (Facebook)
+ # utm_*: used globally https://en.wikipedia.org/wiki/UTM_parameters
+ # ncid: DoubleClick (Google)
+ # ref_src, ref_url: twitter
+ # Others exist but are excluded because they are not common.
+ # See https://en.wikipedia.org/wiki/UTM_parameters
+ msg['body'] = re.sub('(https?://[^ ]+)&?(fbclid|dclid|ncid|utm_source|utm_medium|utm_campaign|utm_term|utm_content|ref_src|ref_url)=[^ &#]*',
+ r'\1',
+ msg['body'])
diff --git a/plugins/reorder.py b/plugins/reorder.py
index 7308196d..158b89bb 100644
--- a/plugins/reorder.py
+++ b/plugins/reorder.py
@@ -59,6 +59,8 @@ And finally, the ``[tab name]`` must be:
- For a type ``static``, the full JID of the contact
"""
+from slixmpp import InvalidJID, JID
+
from poezio import tabs
from poezio.decorators import command_args_parser
from poezio.plugin import BasePlugin
@@ -90,7 +92,11 @@ def parse_config(tab_config):
if pos in result or pos <= 0:
return None
- typ, name = tab_config.get(option, default=':').split(':', maxsplit=1)
+ spec = tab_config.get(option, default=':').split(':', maxsplit=1)
+ # Gap tabs are recreated automatically if there's a gap in indices.
+ if spec == 'empty':
+ return None
+ typ, name = spec
if typ not in TEXT_TO_TAB:
return None
result[pos] = (TEXT_TO_TAB[typ], name)
@@ -111,12 +117,15 @@ def parse_runtime_tablist(tablist):
for tab in tablist[1:]:
i += 1
result = check_tab(tab)
- if result:
- props.append((i, '%s:%s' % (result, tab.name)))
+ # Don't serialize gap tabs as they're recreated automatically
+ if result != 'empty' and isinstance(tab, tuple(TEXT_TO_TAB.values())):
+ props.append((i, '%s:%s' % (result, tab.jid.full)))
return props
class Plugin(BasePlugin):
+ """reorder plugin"""
+
def init(self):
self.api.add_command(
'reorder',
@@ -129,20 +138,24 @@ class Plugin(BasePlugin):
help='Save the current tab layout')
@command_args_parser.ignored
- def command_save_order(self):
+ def command_save_order(self) -> None:
+ """
+ /save_order
+ """
conf = parse_runtime_tablist(self.core.tabs)
for key, value in conf:
self.config.set(key, value)
self.api.information('Tab order saved', 'Info')
@command_args_parser.ignored
- def command_reorder(self):
+ def command_reorder(self) -> None:
"""
/reorder
"""
tabs_spec = parse_config(self.config)
if not tabs_spec:
- return self.api.information('Invalid reorder config', 'Error')
+ self.api.information('Invalid reorder config', 'Error')
+ return None
old_tabs = self.core.tabs.get_tabs()
roster = old_tabs.pop(0)
@@ -154,22 +167,37 @@ class Plugin(BasePlugin):
for pos in sorted(tabs_spec):
if create_gaps and pos > last + 1:
new_tabs += [
- tabs.GapTab(self.core) for i in range(pos - last - 1)
+ tabs.GapTab() for i in range(pos - last - 1)
]
- cls, name = tabs_spec[pos]
- tab = self.core.tabs.by_name_and_class(name, cls=cls)
- if tab and tab in old_tabs:
- new_tabs.append(tab)
- old_tabs.remove(tab)
- else:
- self.api.information('Tab %s not found' % name, 'Warning')
+ cls, jid = tabs_spec[pos]
+ try:
+ jid = JID(jid)
+ tab = self.core.tabs.by_name_and_class(str(jid), cls=cls)
+ if tab and tab in old_tabs:
+ new_tabs.append(tab)
+ old_tabs.remove(tab)
+ else:
+ # TODO: Add support for MucTab. Requires nickname.
+ if cls in (tabs.DynamicConversationTab, tabs.StaticConversationTab):
+ self.api.information('Tab %s not found. Creating it' % jid, 'Warning')
+ new_tab = cls(self.core, jid)
+ new_tabs.append(new_tab)
+ else:
+ new_tabs.append(tabs.GapTab())
+ except:
+ self.api.information('Failed to create tab \'%s\'.' % jid, 'Error')
if create_gaps:
- new_tabs.append(tabs.GapTab(self.core))
- last = pos
+ new_tabs.append(tabs.GapTab())
+ finally:
+ last = pos
for tab in old_tabs:
if tab:
new_tabs.append(tab)
+ # TODO: Ensure we don't break poezio and call this with whatever
+ # tablist we have. The roster tab at least needs to be in there.
self.core.tabs.replace_tabs(new_tabs)
self.core.refresh_window()
+
+ return None
diff --git a/plugins/replace.py b/plugins/replace.py
index 3202721c..02059a18 100644
--- a/plugins/replace.py
+++ b/plugins/replace.py
@@ -23,7 +23,7 @@ Add your own pattern
--------------------
You can easily edit this plugin to add your own patterns. For example if
-don’t want to search for an insult everytime you’re angry, you can create a
+don’t want to search for an insult every time you’re angry, you can create a
curse pattern this way:
- In the init(self) method of the Plugin class, add something like
@@ -91,7 +91,7 @@ def replace_time(message, tab):
def replace_date(message, tab):
- return datetime.datetime.now().strftime("%x")
+ return datetime.datetime.now().strftime("%Y-%m-%d")
def replace_datetime(message, tab):
@@ -102,11 +102,11 @@ def replace_random_user(message, tab):
if isinstance(tab, tabs.MucTab):
return random.choice(tab.users).nick
elif isinstance(tab, tabs.PrivateTab):
- return random.choice([JID(tab.name).resource, tab.own_nick])
+ return random.choice([tab.jid.resource, tab.own_nick])
else:
# that doesn’t make any sense. By why use this pattern in a
# ConversationTab anyway?
- return str(tab.name)
+ return tab.jid.full
def replace_dice(message, tab):
diff --git a/plugins/screen_detach.py b/plugins/screen_detach.py
index 0a2514c4..1f908513 100644
--- a/plugins/screen_detach.py
+++ b/plugins/screen_detach.py
@@ -43,10 +43,10 @@ DEFAULT_CONFIG = {
# overload if this is not how your stuff
# is configured
try:
- LOGIN = os.getlogin()
+ LOGIN = os.getlogin() or ''
LOGIN_TMUX = os.getuid()
except Exception:
- LOGIN = os.getenv('USER')
+ LOGIN = os.getenv('USER') or ''
LOGIN_TMUX = os.getuid()
SCREEN_DIR = '/var/run/screens/S-%s' % LOGIN
diff --git a/plugins/send_delayed.py b/plugins/send_delayed.py
index 846fccd1..92ed97c1 100644
--- a/plugins/send_delayed.py
+++ b/plugins/send_delayed.py
@@ -18,6 +18,7 @@ This plugin adds a command to the chat tabs.
"""
+import asyncio
from poezio.plugin import BasePlugin
from poezio.core.structs import Completion
from poezio.decorators import command_args_parser
@@ -28,7 +29,7 @@ from poezio import timed_events
class Plugin(BasePlugin):
def init(self):
- for _class in (tabs.PrivateTab, tabs.ConversationTab, tabs.MucTab):
+ for _class in (tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab, tabs.MucTab):
self.api.add_tab_command(
_class,
'send_delayed',
@@ -74,6 +75,6 @@ class Plugin(BasePlugin):
tab = args[0]
# anything could happen to the tab during the interval
try:
- tab.command_say(args[1])
+ asyncio.ensure_future(tab.command_say(args[1]))
except:
pass
diff --git a/plugins/server_part.py b/plugins/server_part.py
index 7a71d94b..cae2248e 100644
--- a/plugins/server_part.py
+++ b/plugins/server_part.py
@@ -16,10 +16,10 @@ Command
"""
+from slixmpp import JID, InvalidJID
from poezio.plugin import BasePlugin
from poezio.tabs import MucTab
from poezio.decorators import command_args_parser
-from poezio.common import safeJID
from poezio.core.structs import Completion
@@ -39,16 +39,18 @@ class Plugin(BasePlugin):
if not args and not isinstance(current_tab, MucTab):
return self.core.command_help('server_part')
elif not args:
- jid = safeJID(current_tab.name).bare
+ jid = current_tab.jid.bare
message = None
elif len(args) == 1:
- jid = safeJID(args[0]).domain
- if not jid:
+ try:
+ jid = JID(args[0]).domain
+ except InvalidJID:
return self.core.command_help('server_part')
message = None
else:
- jid = safeJID(args[0]).domain
- if not jid:
+ try:
+ jid = JID(args[0]).domain
+ except InvalidJID:
return self.core.command_help('server_part')
message = args[1]
@@ -60,6 +62,6 @@ class Plugin(BasePlugin):
serv_list = set()
for tab in self.core.get_tabs(MucTab):
if tab.joined:
- serv = safeJID(tab.name).server
+ serv = tab.jid.server
serv_list.add(serv)
return Completion(the_input.new_completion, sorted(serv_list), 1, ' ')
diff --git a/plugins/simple_notify.py b/plugins/simple_notify.py
index cfb65e9b..29418f40 100644
--- a/plugins/simple_notify.py
+++ b/plugins/simple_notify.py
@@ -114,10 +114,11 @@ class Plugin(BasePlugin):
def on_conversation_msg(self, message, tab):
fro = message['from'].bare
- self.do_notify(message, fro)
+ if fro.bare != self.core.xmpp.boundjid.bare:
+ self.do_notify(message, fro)
def on_muc_msg(self, message, tab):
- # Dont notify if message is from yourself
+ # Don't notify if message is from yourself
if message['from'].resource == tab.own_nick:
return
diff --git a/plugins/sticker.py b/plugins/sticker.py
new file mode 100644
index 00000000..c9deacc0
--- /dev/null
+++ b/plugins/sticker.py
@@ -0,0 +1,97 @@
+'''
+This plugin lets the user select and send a sticker from a pack of stickers.
+
+The protocol used here is based on XEP-0363 and XEP-0066, while a future
+version may use XEP-0449 instead.
+
+Command
+-------
+
+.. glossary::
+ /sticker
+ **Usage:** ``/sticker <pack>``
+
+ Opens a picker tool, and send the sticker which has been selected.
+
+Configuration options
+---------------------
+
+.. glossary::
+ sticker_picker
+ **Default:** ``poezio-sticker-picker``
+
+ The command to invoke as a sticker picker. A sample one is provided in
+ tools/sticker-picker.
+
+ stickers_dir
+ **Default:** ``XDG_DATA_HOME/poezio/stickers``
+
+ The directory under which the sticker packs can be found.
+'''
+
+import asyncio
+import concurrent.futures
+from poezio import xdg
+from poezio.plugin import BasePlugin
+from poezio.config import config
+from poezio.decorators import command_args_parser
+from poezio.core.structs import Completion
+from pathlib import Path
+from asyncio.subprocess import PIPE, DEVNULL
+
+class Plugin(BasePlugin):
+ dependencies = {'upload'}
+
+ def init(self):
+ # The command to use as a picker helper.
+ self.picker_command = config.getstr('sticker_picker') or 'poezio-sticker-picker'
+
+ # Select and create the stickers directory.
+ directory = config.getstr('stickers_dir')
+ if directory:
+ self.directory = Path(directory).expanduser()
+ else:
+ self.directory = xdg.DATA_HOME / 'stickers'
+ self.directory.mkdir(parents=True, exist_ok=True)
+
+ self.upload = self.refs['upload']
+ self.api.add_command('sticker', self.command_sticker,
+ usage='<sticker pack>',
+ short='Send a sticker',
+ help='Send a sticker, with a helper GUI sticker picker',
+ completion=self.completion_sticker)
+
+ def command_sticker(self, pack):
+ '''
+ Sends a sticker
+ '''
+ if not pack:
+ self.api.information('Missing sticker pack argument.', 'Error')
+ return
+ async def run_command(tab, path: Path):
+ try:
+ process = await asyncio.create_subprocess_exec(
+ self.picker_command, path, stdout=PIPE, stderr=PIPE)
+ sticker, stderr = await process.communicate()
+ except FileNotFoundError as err:
+ self.api.information('Failed to launch the sticker picker: %s' % err, 'Error')
+ return
+ else:
+ if process.returncode != 0:
+ self.api.information('Sticker picker failed: %s' % stderr.decode(), 'Error')
+ return
+ if sticker:
+ filename = sticker.decode().rstrip()
+ self.api.information('Sending sticker %s' % filename, 'Info')
+ await self.upload.send_upload(path / filename, tab)
+ tab = self.api.current_tab()
+ path = self.directory / pack
+ asyncio.create_task(run_command(tab, path))
+
+ def completion_sticker(self, the_input):
+ '''
+ Completion for /sticker
+ '''
+ txt = the_input.get_text()[9:]
+ directories = [directory.name for directory in self.directory.glob(txt + '*')]
+ return Completion(the_input.auto_completion, directories, quotify=False)
diff --git a/plugins/stoi.py b/plugins/stoi.py
index 04d84881..78c4ed70 100644
--- a/plugins/stoi.py
+++ b/plugins/stoi.py
@@ -28,7 +28,7 @@ char_we_dont_want = string.punctuation + ' ’„“”…«»'
class Plugin(BasePlugin):
def init(self):
- for tab_type in (tabs.MucTab, tabs.PrivateTab, tabs.ConversationTab):
+ for tab_type in (tabs.MucTab, tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab):
self.api.add_tab_command(
tab_type,
'stoi',
diff --git a/plugins/tell.py b/plugins/tell.py
index 43a91d8b..cd72a9e5 100644
--- a/plugins/tell.py
+++ b/plugins/tell.py
@@ -25,6 +25,7 @@ This plugin defines two new commands for chatroom tabs:
List all queued messages for the current chatroom.
"""
+import asyncio
from poezio.plugin import BasePlugin
from poezio.core.structs import Completion
from poezio.decorators import command_args_parser
@@ -66,7 +67,7 @@ class Plugin(BasePlugin):
if nick not in self.tabs[tab]:
return
for i in self.tabs[tab][nick]:
- tab.command_say("%s: %s" % (nick, i))
+ asyncio.ensure_future(tab.command_say("%s: %s" % (nick, i)))
del self.tabs[tab][nick]
@command_args_parser.ignored
@@ -75,7 +76,7 @@ class Plugin(BasePlugin):
if not self.tabs.get(tab):
self.api.information('No message queued.', 'Info')
return
- build = ['Messages queued for %s:' % tab.name]
+ build = ['Messages queued for %s:' % tab.jid.bare]
for nick, messages in self.tabs[tab].items():
build.append(' for %s:' % nick)
for message in messages:
diff --git a/plugins/time_marker.py b/plugins/time_marker.py
index bd6af1c4..6ce511a0 100644
--- a/plugins/time_marker.py
+++ b/plugins/time_marker.py
@@ -31,12 +31,13 @@ Messages like “2 hours, 25 minutes passed…” are automatically displayed in
from poezio.plugin import BasePlugin
from datetime import datetime, timedelta
+from poezio.ui.types import InfoMessage
class Plugin(BasePlugin):
def init(self):
self.api.add_event_handler("muc_msg", self.on_muc_msg)
- # Dict of MucTab.name: last_message date, so we don’t have to
+ # Dict of MucTab.jid.bare: last_message date, so we don’t have to
# retrieve the messages of the given muc to look for the last
# message’s date each time. Also, now that I think about it, the
# date of the message is not event kept in the Message object, so…
@@ -66,10 +67,11 @@ class Plugin(BasePlugin):
res += "%s seconds, " % seconds
return res[:-2]
- last_message_date = self.last_messages.get(tab.name)
- self.last_messages[tab.name] = datetime.now()
+ last_message_date = self.last_messages.get(tab.jid.bare)
+ self.last_messages[tab.jid.bare] = datetime.now()
if last_message_date:
delta = datetime.now() - last_message_date
if delta >= timedelta(0, self.config.get('delay', 900)):
tab.add_message(
- "%s passed…" % (format_timedelta(delta), ), str_time='')
+ InfoMessage("%s passed…" % (format_timedelta(delta), ))
+ )
diff --git a/plugins/untrackme.py b/plugins/untrackme.py
new file mode 100644
index 00000000..ceddc5c5
--- /dev/null
+++ b/plugins/untrackme.py
@@ -0,0 +1,140 @@
+"""
+ UntrackMe wannabe plugin
+"""
+
+from typing import Callable, Dict, List, Tuple, Union
+
+import re
+import logging
+from slixmpp import Message
+from poezio import tabs
+from poezio.plugin import BasePlugin
+from urllib.parse import quote as urlquote
+
+
+log = logging.getLogger(__name__)
+
+ChatTabs = Union[
+ tabs.MucTab,
+ tabs.DynamicConversationTab,
+ tabs.StaticConversationTab,
+ tabs.PrivateTab,
+]
+
+RE_URL: re.Pattern = re.compile('https?://(?P<host>[^/]+)(?P<rest>[^ ]*)')
+
+SERVICES: Dict[str, Tuple[str, bool]] = { # host: (service, proxy)
+ 'm.youtube.com': ('invidious', False),
+ 'www.youtube.com': ('invidious', False),
+ 'youtube.com': ('invidious', False),
+ 'youtu.be': ('invidious', False),
+ 'youtube-nocookie.com': ('invidious', False),
+ 'mobile.twitter.com': ('nitter', False),
+ 'www.twitter.com': ('nitter', False),
+ 'twitter.com': ('nitter', False),
+ 'pic.twitter.com': ('nitter_img', True),
+ 'pbs.twimg.com': ('nitter_img', True),
+ 'instagram.com': ('bibliogram', False),
+ 'www.instagram.com': ('bibliogram', False),
+ 'm.instagram.com': ('bibliogram', False),
+}
+
+def proxy(service: str) -> Callable[[str], str]:
+ """Some services require the original url"""
+ def inner(origin: str) -> str:
+ return service + urlquote(origin)
+ return inner
+
+
+class Plugin(BasePlugin):
+ """UntrackMe"""
+
+ default_config: Dict[str, Dict[str, Union[str, bool]]] = {
+ 'default': {
+ 'cleanup': True,
+ 'redirect': True,
+ 'display_corrections': False,
+ },
+ 'services': {
+ 'invidious': 'https://invidious.snopyta.org',
+ 'nitter': 'https://nitter.net',
+ 'bibliogram': 'https://bibliogram.art',
+ },
+ }
+
+ def init(self):
+ nitter_img = self.config.get('nitter', section='services') + '/pic/'
+ self.config.set('nitter_img', nitter_img, section='services')
+
+ self.api.add_event_handler('muc_say', self.handle_msg)
+ self.api.add_event_handler('conversation_say', self.handle_msg)
+ self.api.add_event_handler('private_say', self.handle_msg)
+
+ self.api.add_event_handler('muc_msg', self.handle_msg)
+ self.api.add_event_handler('conversation_msg', self.handle_msg)
+ self.api.add_event_handler('private_msg', self.handle_msg)
+
+ def map_services(self, match: re.Match) -> str:
+ """
+ If it matches a host that we know about, change the domain for the
+ alternative service. Some hosts needs to be proxied instead (such
+ as twitter pictures), so they're url encoded and appended to the
+ proxy service.
+ """
+
+ host = match.group('host')
+
+ dest = SERVICES.get(host)
+ if dest is None:
+ return match.group(0)
+
+ destname, proxy = dest
+ replaced = self.config.get(destname, section='services')
+ result = replaced + match.group('rest')
+
+ if proxy:
+ url = urlquote(match.group(0))
+ result = replaced + url
+
+ # TODO: count parenthesis?
+ # Removes comma at the end of a link.
+ if result[-3] == '%2C':
+ result = result[:-3] + ','
+
+ return result
+
+ def handle_msg(self, msg: Message, tab: ChatTabs) -> None:
+ orig = msg['body']
+
+ if self.config.get('cleanup', section='default'):
+ msg['body'] = self.cleanup_url(msg['body'])
+ if self.config.get('redirect', section='default'):
+ msg['body'] = self.redirect_url(msg['body'])
+
+ if self.config.get('display_corrections', section='default') and \
+ msg['body'] != orig:
+ log.debug(
+ 'UntrackMe in tab \'%s\':\nOriginal: %s\nModified: %s',
+ tab.name, orig, msg['body'],
+ )
+
+ self.api.information(
+ 'UntrackMe in tab \'{}\':\nOriginal: {}\nModified: {}'.format(
+ tab.name, orig, msg['body']
+ ),
+ 'Info',
+ )
+
+ def cleanup_url(self, txt: str) -> str:
+ # fbclid: used globally (Facebook)
+ # utm_*: used globally https://en.wikipedia.org/wiki/UTM_parameters
+ # ncid: DoubleClick (Google)
+ # ref_src, ref_url: twitter
+ # Others exist but are excluded because they are not common.
+ # See https://en.wikipedia.org/wiki/UTM_parameters
+ return re.sub('(https?://[^ ]+)&?(fbclid|dclid|ncid|utm_source|utm_medium|utm_campaign|utm_term|utm_content|ref_src|ref_url)=[^ &#]*',
+ r'\1',
+ txt)
+
+ def redirect_url(self, txt: str) -> str:
+ return RE_URL.sub(self.map_services, txt)
diff --git a/plugins/upload.py b/plugins/upload.py
index 7e25070e..6926c075 100644
--- a/plugins/upload.py
+++ b/plugins/upload.py
@@ -16,12 +16,15 @@ This plugin adds a command to the chat tabs.
"""
+
+from typing import Optional
+
import asyncio
import traceback
from os.path import expanduser
from glob import glob
-from slixmpp.plugins.xep_0363.http_upload import UploadServiceNotFound
+from slixmpp.plugins.xep_0363.http_upload import FileTooBig, HTTPError, UploadServiceNotFound
from poezio.plugin import BasePlugin
from poezio.core.structs import Completion
@@ -30,9 +33,19 @@ from poezio import tabs
class Plugin(BasePlugin):
+ dependencies = {'embed'}
+
def init(self):
+ self.embed = self.refs['embed']
+
if not self.core.xmpp['xep_0363']:
raise Exception('slixmpp XEP-0363 plugin failed to load')
+ if not self.core.xmpp['xep_0454']:
+ self.api.information(
+ 'slixmpp XEP-0454 plugin failed to load. '
+ 'Will not be able to encrypt uploaded files.',
+ 'Warning',
+ )
for _class in (tabs.PrivateTab, tabs.StaticConversationTab, tabs.DynamicConversationTab, tabs.MucTab):
self.api.add_tab_command(
_class,
@@ -43,18 +56,29 @@ class Plugin(BasePlugin):
short='Upload a file',
completion=self.completion_filename)
- async def async_upload(self, filename):
+ async def upload(self, filename, encrypted=False) -> Optional[str]:
try:
- url = await self.core.xmpp['xep_0363'].upload_file(filename)
+ upload_file = self.core.xmpp['xep_0363'].upload_file
+ if encrypted:
+ upload_file = self.core.xmpp['xep_0454'].upload_file
+ url = await upload_file(filename)
except UploadServiceNotFound:
self.api.information('HTTP Upload service not found.', 'Error')
- return
+ return None
+ except (FileTooBig, HTTPError) as exn:
+ self.api.information(str(exn), 'Error')
+ return None
except Exception:
exception = traceback.format_exc()
self.api.information('Failed to upload file: %s' % exception,
'Error')
- return
- self.core.insert_input_text(url)
+ return None
+ return url
+
+ async def send_upload(self, filename, tab, encrypted=False):
+ url = await self.upload(filename, encrypted)
+ if url is not None:
+ self.embed.embed_image_url(url, tab)
@command_args_parser.quoted(1)
def command_upload(self, args):
@@ -63,7 +87,9 @@ class Plugin(BasePlugin):
return
filename, = args
filename = expanduser(filename)
- asyncio.ensure_future(self.async_upload(filename))
+ tab = self.api.current_tab()
+ encrypted = self.core.xmpp['xep_0454'] and tab.e2e_encryption is not None
+ asyncio.create_task(self.send_upload(filename, tab, encrypted))
@staticmethod
def completion_filename(the_input):
diff --git a/plugins/uptime.py b/plugins/uptime.py
index d5a07b7b..a55af970 100644
--- a/plugins/uptime.py
+++ b/plugins/uptime.py
@@ -12,8 +12,10 @@ Command
Retrieve the uptime of the server of ``jid``.
"""
from poezio.plugin import BasePlugin
-from poezio.common import parse_secs_to_str, safeJID
+from poezio.common import parse_secs_to_str
from slixmpp.xmlstream import ET
+from slixmpp import JID, InvalidJID
+from slixmpp.exceptions import IqError, IqTimeout
class Plugin(BasePlugin):
@@ -25,19 +27,23 @@ class Plugin(BasePlugin):
help='Ask for the uptime of a server or component (see XEP-0012).',
short='Get the uptime')
- def command_uptime(self, arg):
- def callback(iq):
- for query in iq.xml.getiterator('{jabber:iq:last}query'):
+ async def command_uptime(self, arg):
+ try:
+ jid = JID(arg)
+ except InvalidJID:
+ return
+ iq = self.core.xmpp.make_iq_get(ito=jid.server)
+ iq.append(ET.Element('{jabber:iq:last}query'))
+ try:
+ iq = await iq.send()
+ result = iq.xml.find('{jabber:iq:last}query')
+ if result is not None:
self.api.information(
'Server %s online since %s' %
(iq['from'], parse_secs_to_str(
- int(query.attrib['seconds']))), 'Info')
+ int(result.attrib['seconds']))), 'Info')
return
- self.api.information('Could not retrieve uptime', 'Error')
+ except (IqError, IqTimeout):
+ pass
+ self.api.information('Could not retrieve uptime', 'Error')
- jid = safeJID(arg)
- if not jid.server:
- return
- iq = self.core.xmpp.make_iq_get(ito=jid.server)
- iq.append(ET.Element('{jabber:iq:last}query'))
- iq.send(callback=callback)
diff --git a/plugins/user_extras.py b/plugins/user_extras.py
new file mode 100644
index 00000000..96559111
--- /dev/null
+++ b/plugins/user_extras.py
@@ -0,0 +1,634 @@
+"""
+This plugin enables rich presence events, such as mood, activity, gaming or tune.
+
+.. versionadded:: 0.14
+ This plugin was previously provided in the poezio core features.
+
+Command
+-------
+.. glossary::
+
+ /activity
+ **Usage:** ``/activity [<general> [specific] [comment]]``
+
+ Send your current activity to your contacts (use the completion to cycle
+ through all the general and specific possible activities).
+
+ Nothing means "stop broadcasting an activity".
+
+ /mood
+ **Usage:** ``/mood [<mood> [comment]]``
+ Send your current mood to your contacts (use the completion to cycle
+ through all the possible moods).
+
+ Nothing means "stop broadcasting a mood".
+
+ /gaming
+ **Usage:** ``/gaming [<game name> [server address]]``
+
+ Send your current gaming activity to your contacts.
+
+ Nothing means "stop broadcasting a gaming activity".
+
+
+Configuration
+-------------
+
+.. glossary::
+
+ display_gaming_notifications
+
+ **Default value:** ``true``
+
+ If set to true, notifications about the games your contacts are playing
+ will be displayed in the info buffer as 'Gaming' messages.
+
+ display_tune_notifications
+
+ **Default value:** ``true``
+
+ If set to true, notifications about the music your contacts listen to
+ will be displayed in the info buffer as 'Tune' messages.
+
+ display_mood_notifications
+
+ **Default value:** ``true``
+
+ If set to true, notifications about the mood of your contacts
+ will be displayed in the info buffer as 'Mood' messages.
+
+ display_activity_notifications
+
+ **Default value:** ``true``
+
+ If set to true, notifications about the current activity of your contacts
+ will be displayed in the info buffer as 'Activity' messages.
+
+ enable_user_activity
+
+ **Default value:** ``true``
+
+ Set this to ``false`` if you don’t want to receive the activity of your contacts.
+
+ enable_user_gaming
+
+ **Default value:** ``true``
+
+ Set this to ``false`` if you don’t want to receive the gaming activity of your contacts.
+
+ enable_user_mood
+
+ **Default value:** ``true``
+
+ Set this to ``false`` if you don’t want to receive the mood of your contacts.
+
+ enable_user_tune
+
+ **Default value:** ``true``
+
+ If this is set to ``false``, you will no longer be subscribed to tune events,
+ and the :term:`display_tune_notifications` option will be ignored.
+
+
+"""
+import asyncio
+from functools import reduce
+from typing import Dict
+
+from slixmpp import InvalidJID, JID, Message
+from poezio.decorators import command_args_parser
+from poezio.plugin import BasePlugin
+from poezio.roster import roster
+from poezio.contact import Contact, Resource
+from poezio.core.structs import Completion
+from poezio import common
+from poezio import tabs
+
+
+class Plugin(BasePlugin):
+
+ default_config = {
+ 'user_extras': {
+ 'display_gaming_notifications': True,
+ 'display_mood_notifications': True,
+ 'display_activity_notifications': True,
+ 'display_tune_notifications': True,
+ 'enable_user_activity': True,
+ 'enable_user_gaming': True,
+ 'enable_user_mood': True,
+ 'enable_user_tune': True,
+ }
+ }
+
+ def init(self):
+ for plugin in {'xep_0196', 'xep_0108', 'xep_0107', 'xep_0118'}:
+ self.core.xmpp.register_plugin(plugin)
+ self.api.add_command(
+ 'activity',
+ self.command_activity,
+ usage='[<general> [specific] [text]]',
+ help='Send your current activity to your contacts '
+ '(use the completion). Nothing means '
+ '"stop broadcasting an activity".',
+ short='Send your activity.',
+ completion=self.comp_activity
+ )
+ self.api.add_command(
+ 'mood',
+ self.command_mood,
+ usage='[<mood> [text]]',
+ help='Send your current mood to your contacts '
+ '(use the completion). Nothing means '
+ '"stop broadcasting a mood".',
+ short='Send your mood.',
+ completion=self.comp_mood,
+ )
+ self.api.add_command(
+ 'gaming',
+ self.command_gaming,
+ usage='[<game name> [server address]]',
+ help='Send your current gaming activity to '
+ 'your contacts. Nothing means "stop '
+ 'broadcasting a gaming activity".',
+ short='Send your gaming activity.',
+ completion=None
+ )
+ handlers = [
+ ('user_mood_publish', self.on_mood_event),
+ ('user_tune_publish', self.on_tune_event),
+ ('user_gaming_publish', self.on_gaming_event),
+ ('user_activity_publish', self.on_activity_event),
+ ]
+ for name, handler in handlers:
+ self.core.xmpp.add_event_handler(name, handler)
+
+ def cleanup(self):
+ handlers = [
+ ('user_mood_publish', self.on_mood_event),
+ ('user_tune_publish', self.on_tune_event),
+ ('user_gaming_publish', self.on_gaming_event),
+ ('user_activity_publish', self.on_activity_event),
+ ]
+ for name, handler in handlers:
+ self.core.xmpp.del_event_handler(name, handler)
+ asyncio.create_task(self._stop())
+
+ async def _stop(self):
+ await asyncio.gather(
+ self.core.xmpp.plugin['xep_0108'].stop(),
+ self.core.xmpp.plugin['xep_0107'].stop(),
+ self.core.xmpp.plugin['xep_0196'].stop(),
+ )
+
+
+ @command_args_parser.quoted(0, 2)
+ async def command_mood(self, args):
+ """
+ /mood [<mood> [text]]
+ """
+ if not args:
+ return await self.core.xmpp.plugin['xep_0107'].stop()
+ mood = args[0]
+ if mood not in MOODS:
+ return self.core.information(
+ '%s is not a correct value for a mood.' % mood, 'Error')
+ if len(args) == 2:
+ text = args[1]
+ else:
+ text = None
+ await self.core.xmpp.plugin['xep_0107'].publish_mood(
+ mood, text
+ )
+
+ @command_args_parser.quoted(0, 3)
+ async def command_activity(self, args):
+ """
+ /activity [<general> [specific] [text]]
+ """
+ length = len(args)
+ if not length:
+ return await self.core.xmpp.plugin['xep_0108'].stop()
+
+ general = args[0]
+ if general not in ACTIVITIES:
+ return self.api.information(
+ '%s is not a correct value for an activity' % general, 'Error')
+ specific = None
+ text = None
+ if length == 2:
+ if args[1] in ACTIVITIES[general]:
+ specific = args[1]
+ else:
+ text = args[1]
+ elif length == 3:
+ specific = args[1]
+ text = args[2]
+ if specific and specific not in ACTIVITIES[general]:
+ return self.core.information(
+ '%s is not a correct value '
+ 'for an activity' % specific, 'Error')
+ await self.core.xmpp.plugin['xep_0108'].publish_activity(
+ general, specific, text
+ )
+
+ @command_args_parser.quoted(0, 2)
+ async def command_gaming(self, args):
+ """
+ /gaming [<game name> [server address]]
+ """
+ if not args:
+ return await self.core.xmpp.plugin['xep_0196'].stop()
+
+ name = args[0]
+ if len(args) > 1:
+ address = args[1]
+ else:
+ address = None
+ return await self.core.xmpp.plugin['xep_0196'].publish_gaming(
+ name=name, server_address=address
+ )
+
+ def comp_activity(self, the_input):
+ """Completion for /activity"""
+ n = the_input.get_argument_position(quoted=True)
+ args = common.shell_split(the_input.text)
+ if n == 1:
+ return Completion(
+ the_input.new_completion,
+ sorted(ACTIVITIES.keys()),
+ n,
+ quotify=True)
+ elif n == 2:
+ if args[1] in ACTIVITIES:
+ l = list(ACTIVITIES[args[1]])
+ l.remove('category')
+ l.sort()
+ return Completion(the_input.new_completion, l, n, quotify=True)
+
+ def comp_mood(self, the_input):
+ """Completion for /mood"""
+ n = the_input.get_argument_position(quoted=True)
+ if n == 1:
+ return Completion(
+ the_input.new_completion,
+ sorted(MOODS.keys()),
+ 1,
+ quotify=True)
+
+ def on_gaming_event(self, message: Message):
+ """
+ Called when a pep notification for user gaming
+ is received
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ item = message['pubsub_event']['items']['item']
+ old_gaming = contact.rich_presence['gaming']
+ xml_node = item.xml.find('{urn:xmpp:gaming:0}game')
+ # list(xml_node) checks whether there are children or not.
+ if xml_node is not None and list(xml_node):
+ item = item['gaming']
+ # only name and server_address are used for now
+ contact.rich_presence['gaming'] = {
+ 'character_name': item['character_name'],
+ 'character_profile': item['character_profile'],
+ 'name': item['name'],
+ 'level': item['level'],
+ 'uri': item['uri'],
+ 'server_name': item['server_name'],
+ 'server_address': item['server_address'],
+ }
+ else:
+ contact.rich_presence['gaming'] = {}
+
+ if old_gaming != contact.rich_presence['gaming'] and self.config.get(
+ 'display_gaming_notifications'):
+ if contact.rich_presence['gaming']:
+ self.core.information(
+ '%s is playing %s' % (contact.bare_jid,
+ common.format_gaming_string(
+ contact.rich_presence['gaming'])), 'Gaming')
+ else:
+ self.core.information(contact.bare_jid + ' stopped playing.',
+ 'Gaming')
+
+ def on_mood_event(self, message: Message):
+ """
+ Called when a pep notification for a user mood
+ is received.
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ item = message['pubsub_event']['items']['item']
+ old_mood = contact.rich_presence.get('mood')
+ plugin = item.get_plugin('mood', check=True)
+ if plugin:
+ mood = item['mood']['value']
+ else:
+ mood = ''
+ if mood:
+ mood = MOODS.get(mood, mood)
+ text = item['mood']['text']
+ if text:
+ mood = '%s (%s)' % (mood, text)
+ contact.rich_presence['mood'] = mood
+ else:
+ contact.rich_presence['mood'] = ''
+
+ if old_mood != contact.rich_presence['mood'] and self.config.get(
+ 'display_mood_notifications'):
+ if contact.rich_presence['mood']:
+ self.core.information(
+ 'Mood from ' + contact.bare_jid + ': ' + contact.rich_presence['mood'],
+ 'Mood')
+ else:
+ self.core.information(
+ contact.bare_jid + ' stopped having their mood.', 'Mood')
+
+ def on_activity_event(self, message: Message):
+ """
+ Called when a pep notification for a user activity
+ is received.
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ item = message['pubsub_event']['items']['item']
+ old_activity = contact.rich_presence['activity']
+ xml_node = item.xml.find('{http://jabber.org/protocol/activity}activity')
+ # list(xml_node) checks whether there are children or not.
+ if xml_node is not None and list(xml_node):
+ try:
+ activity = item['activity']['value']
+ except ValueError:
+ return
+ if activity[0]:
+ general = ACTIVITIES.get(activity[0])
+ if general is None:
+ return
+ s = general['category']
+ if activity[1]:
+ s = s + '/' + general.get(activity[1], 'other')
+ text = item['activity']['text']
+ if text:
+ s = '%s (%s)' % (s, text)
+ contact.rich_presence['activity'] = s
+ else:
+ contact.rich_presence['activity'] = ''
+ else:
+ contact.rich_presence['activity'] = ''
+
+ if old_activity != contact.rich_presence['activity'] and self.config.get(
+ 'display_activity_notifications'):
+ if contact.rich_presence['activity']:
+ self.core.information(
+ 'Activity from ' + contact.bare_jid + ': ' +
+ contact.rich_presence['activity'], 'Activity')
+ else:
+ self.core.information(
+ contact.bare_jid + ' stopped doing their activity.',
+ 'Activity')
+
+ def on_tune_event(self, message: Message):
+ """
+ Called when a pep notification for a user tune
+ is received
+ """
+ contact = roster[message['from'].bare]
+ if not contact:
+ return
+ roster.modified()
+ item = message['pubsub_event']['items']['item']
+ old_tune = contact.rich_presence['tune']
+ xml_node = item.xml.find('{http://jabber.org/protocol/tune}tune')
+ # list(xml_node) checks whether there are children or not.
+ if xml_node is not None and list(xml_node):
+ item = item['tune']
+ contact.rich_presence['tune'] = {
+ 'artist': item['artist'],
+ 'length': item['length'],
+ 'rating': item['rating'],
+ 'source': item['source'],
+ 'title': item['title'],
+ 'track': item['track'],
+ 'uri': item['uri']
+ }
+ else:
+ contact.rich_presence['tune'] = {}
+
+ if old_tune != contact.rich_presence['tune'] and self.config.get(
+ 'display_tune_notifications'):
+ if contact.rich_presence['tune']:
+ self.core.information(
+ 'Tune from ' + message['from'].bare + ': ' +
+ common.format_tune_string(contact.rich_presence['tune']), 'Tune')
+ else:
+ self.core.information(
+ contact.bare_jid + ' stopped listening to music.', 'Tune')
+
+
+# Collection of mappings for PEP moods/activities
+# extracted directly from the XEP
+
+MOODS: Dict[str, str] = {
+ 'afraid': 'Afraid',
+ 'amazed': 'Amazed',
+ 'angry': 'Angry',
+ 'amorous': 'Amorous',
+ 'annoyed': 'Annoyed',
+ 'anxious': 'Anxious',
+ 'aroused': 'Aroused',
+ 'ashamed': 'Ashamed',
+ 'bored': 'Bored',
+ 'brave': 'Brave',
+ 'calm': 'Calm',
+ 'cautious': 'Cautious',
+ 'cold': 'Cold',
+ 'confident': 'Confident',
+ 'confused': 'Confused',
+ 'contemplative': 'Contemplative',
+ 'contented': 'Contented',
+ 'cranky': 'Cranky',
+ 'crazy': 'Crazy',
+ 'creative': 'Creative',
+ 'curious': 'Curious',
+ 'dejected': 'Dejected',
+ 'depressed': 'Depressed',
+ 'disappointed': 'Disappointed',
+ 'disgusted': 'Disgusted',
+ 'dismayed': 'Dismayed',
+ 'distracted': 'Distracted',
+ 'embarrassed': 'Embarrassed',
+ 'envious': 'Envious',
+ 'excited': 'Excited',
+ 'flirtatious': 'Flirtatious',
+ 'frustrated': 'Frustrated',
+ 'grumpy': 'Grumpy',
+ 'guilty': 'Guilty',
+ 'happy': 'Happy',
+ 'hopeful': 'Hopeful',
+ 'hot': 'Hot',
+ 'humbled': 'Humbled',
+ 'humiliated': 'Humiliated',
+ 'hungry': 'Hungry',
+ 'hurt': 'Hurt',
+ 'impressed': 'Impressed',
+ 'in_awe': 'In awe',
+ 'in_love': 'In love',
+ 'indignant': 'Indignant',
+ 'interested': 'Interested',
+ 'intoxicated': 'Intoxicated',
+ 'invincible': 'Invincible',
+ 'jealous': 'Jealous',
+ 'lonely': 'Lonely',
+ 'lucky': 'Lucky',
+ 'mean': 'Mean',
+ 'moody': 'Moody',
+ 'nervous': 'Nervous',
+ 'neutral': 'Neutral',
+ 'offended': 'Offended',
+ 'outraged': 'Outraged',
+ 'playful': 'Playful',
+ 'proud': 'Proud',
+ 'relaxed': 'Relaxed',
+ 'relieved': 'Relieved',
+ 'remorseful': 'Remorseful',
+ 'restless': 'Restless',
+ 'sad': 'Sad',
+ 'sarcastic': 'Sarcastic',
+ 'serious': 'Serious',
+ 'shocked': 'Shocked',
+ 'shy': 'Shy',
+ 'sick': 'Sick',
+ 'sleepy': 'Sleepy',
+ 'spontaneous': 'Spontaneous',
+ 'stressed': 'Stressed',
+ 'strong': 'Strong',
+ 'surprised': 'Surprised',
+ 'thankful': 'Thankful',
+ 'thirsty': 'Thirsty',
+ 'tired': 'Tired',
+ 'undefined': 'Undefined',
+ 'weak': 'Weak',
+ 'worried': 'Worried'
+}
+
+ACTIVITIES: Dict[str, Dict[str, str]] = {
+ 'doing_chores': {
+ 'category': 'Doing_chores',
+ 'buying_groceries': 'Buying groceries',
+ 'cleaning': 'Cleaning',
+ 'cooking': 'Cooking',
+ 'doing_maintenance': 'Doing maintenance',
+ 'doing_the_dishes': 'Doing the dishes',
+ 'doing_the_laundry': 'Doing the laundry',
+ 'gardening': 'Gardening',
+ 'running_an_errand': 'Running an errand',
+ 'walking_the_dog': 'Walking the dog',
+ 'other': 'Other',
+ },
+ 'drinking': {
+ 'category': 'Drinking',
+ 'having_a_beer': 'Having a beer',
+ 'having_coffee': 'Having coffee',
+ 'having_tea': 'Having tea',
+ 'other': 'Other',
+ },
+ 'eating': {
+ 'category': 'Eating',
+ 'having_breakfast': 'Having breakfast',
+ 'having_a_snack': 'Having a snack',
+ 'having_dinner': 'Having dinner',
+ 'having_lunch': 'Having lunch',
+ 'other': 'Other',
+ },
+ 'exercising': {
+ 'category': 'Exercising',
+ 'cycling': 'Cycling',
+ 'dancing': 'Dancing',
+ 'hiking': 'Hiking',
+ 'jogging': 'Jogging',
+ 'playing_sports': 'Playing sports',
+ 'running': 'Running',
+ 'skiing': 'Skiing',
+ 'swimming': 'Swimming',
+ 'working_out': 'Working out',
+ 'other': 'Other',
+ },
+ 'grooming': {
+ 'category': 'Grooming',
+ 'at_the_spa': 'At the spa',
+ 'brushing_teeth': 'Brushing teeth',
+ 'getting_a_haircut': 'Getting a haircut',
+ 'shaving': 'Shaving',
+ 'taking_a_bath': 'Taking a bath',
+ 'taking_a_shower': 'Taking a shower',
+ 'other': 'Other',
+ },
+ 'having_appointment': {
+ 'category': 'Having appointment',
+ 'other': 'Other',
+ },
+ 'inactive': {
+ 'category': 'Inactive',
+ 'day_off': 'Day_off',
+ 'hanging_out': 'Hanging out',
+ 'hiding': 'Hiding',
+ 'on_vacation': 'On vacation',
+ 'praying': 'Praying',
+ 'scheduled_holiday': 'Scheduled holiday',
+ 'sleeping': 'Sleeping',
+ 'thinking': 'Thinking',
+ 'other': 'Other',
+ },
+ 'relaxing': {
+ 'category': 'Relaxing',
+ 'fishing': 'Fishing',
+ 'gaming': 'Gaming',
+ 'going_out': 'Going out',
+ 'partying': 'Partying',
+ 'reading': 'Reading',
+ 'rehearsing': 'Rehearsing',
+ 'shopping': 'Shopping',
+ 'smoking': 'Smoking',
+ 'socializing': 'Socializing',
+ 'sunbathing': 'Sunbathing',
+ 'watching_a_movie': 'Watching a movie',
+ 'watching_tv': 'Watching tv',
+ 'other': 'Other',
+ },
+ 'talking': {
+ 'category': 'Talking',
+ 'in_real_life': 'In real life',
+ 'on_the_phone': 'On the phone',
+ 'on_video_phone': 'On video phone',
+ 'other': 'Other',
+ },
+ 'traveling': {
+ 'category': 'Traveling',
+ 'commuting': 'Commuting',
+ 'driving': 'Driving',
+ 'in_a_car': 'In a car',
+ 'on_a_bus': 'On a bus',
+ 'on_a_plane': 'On a plane',
+ 'on_a_train': 'On a train',
+ 'on_a_trip': 'On a trip',
+ 'walking': 'Walking',
+ 'cycling': 'Cycling',
+ 'other': 'Other',
+ },
+ 'undefined': {
+ 'category': 'Undefined',
+ 'other': 'Other',
+ },
+ 'working': {
+ 'category': 'Working',
+ 'coding': 'Coding',
+ 'in_a_meeting': 'In a meeting',
+ 'writing': 'Writing',
+ 'studying': 'Studying',
+ 'other': 'Other',
+ }
+}
diff --git a/plugins/vcard.py b/plugins/vcard.py
index 643dd569..b0c8e396 100644
--- a/plugins/vcard.py
+++ b/plugins/vcard.py
@@ -25,15 +25,16 @@ Command
vcard from the current interlocutor, and in the contact list to do it
on the currently selected contact.
"""
+import asyncio
from poezio.decorators import command_args_parser
from poezio.plugin import BasePlugin
from poezio.roster import roster
-from poezio.common import safeJID
from poezio.contact import Contact, Resource
from poezio.core.structs import Completion
from poezio import tabs
from slixmpp.jid import JID, InvalidJID
+from slixmpp.exceptions import IqTimeout
class Plugin(BasePlugin):
@@ -61,7 +62,7 @@ class Plugin(BasePlugin):
help='Send an XMPP vcard request to jid (see XEP-0054).',
short='Send a vcard request.',
completion=self.completion_vcard)
- for _class in (tabs.PrivateTab, tabs.ConversationTab):
+ for _class in (tabs.PrivateTab, tabs.DynamicConversationTab, tabs.StaticConversationTab):
self.api.add_tab_command(
_class,
'vcard',
@@ -240,19 +241,18 @@ class Plugin(BasePlugin):
on_cancel = lambda form: self.core.close_tab()
self.core.open_new_form(form, on_cancel, on_validate)
- def _get_vcard(self, jid):
+ async def _get_vcard(self, jid):
'''Send an iq to ask the vCard.'''
-
- def timeout_cb(iq):
+ try:
+ vcard = await self.core.xmpp.plugin['xep_0054'].get_vcard(
+ jid=jid,
+ timeout=30,
+ )
+ self._handle_vcard(vcard)
+ except IqTimeout:
self.api.information('Timeout while retrieving vCard for %s' % jid,
'Error')
- return
- self.core.xmpp.plugin['xep_0054'].get_vcard(
- jid=jid,
- timeout=30,
- callback=self._handle_vcard,
- timeout_callback=timeout_cb)
@command_args_parser.raw
def command_vcard(self, arg):
@@ -266,14 +266,16 @@ class Plugin(BasePlugin):
self.api.information('Invalid JID: %s' % arg, 'Error')
return
- self._get_vcard(jid)
+ asyncio.create_task(
+ self._get_vcard(jid)
+ )
@command_args_parser.raw
def command_private_vcard(self, arg):
if arg:
self.command_vcard(arg)
return
- self.command_vcard(self.api.current_tab().name)
+ self.command_vcard(self.api.current_tab().jid.full)
@command_args_parser.raw
def command_muc_vcard(self, arg):
@@ -282,11 +284,15 @@ class Plugin(BasePlugin):
return
user = self.api.current_tab().get_user_by_name(arg)
if user:
- # No need to use safeJID here, we already know the JID is valid.
- jid = JID(self.api.current_tab().name + '/' + user.nick)
+ jid = self.api.current_tab().jid.bare + '/' + user.nick
else:
- jid = safeJID(arg)
- self._get_vcard(jid)
+ try:
+ jid = JID(arg)
+ except InvalidJID:
+ return self.api.information('Invalid JID: %s' % arg, 'Error')
+ asyncio.create_task(
+ self._get_vcard(jid)
+ )
@command_args_parser.raw
def command_roster_vcard(self, arg):
@@ -295,9 +301,13 @@ class Plugin(BasePlugin):
return
current = self.api.current_tab().selected_row
if isinstance(current, Resource):
- self._get_vcard(JID(current.jid).bare)
+ asyncio.create_task(
+ self._get_vcard(JID(current.jid).bare)
+ )
elif isinstance(current, Contact):
- self._get_vcard(current.bare_jid)
+ asyncio.create_task(
+ self._get_vcard(current.bare_jid)
+ )
def completion_vcard(self, the_input):
contacts = [contact.bare_jid for contact in roster.get_contacts()]
diff --git a/poezio/args.py b/poezio/args.py
index d0005d82..3907fc88 100644
--- a/poezio/args.py
+++ b/poezio/args.py
@@ -1,10 +1,16 @@
"""
Module related to the argument parsing
-
-There is a fallback to the deprecated optparse if argparse is not found
"""
+import pkg_resources
+import stat
+import sys
+from argparse import ArgumentParser, SUPPRESS, Namespace
from pathlib import Path
-from argparse import ArgumentParser, SUPPRESS
+from shutil import copy2
+from typing import Tuple
+
+from poezio.version import __version__
+from poezio import xdg
def parse_args(CONFIG_PATH: Path):
@@ -33,11 +39,48 @@ def parse_args(CONFIG_PATH: Path):
help="The config file you want to use",
metavar="CONFIG_FILE")
parser.add_argument(
- "-v",
- "--version",
- dest="version",
+ '-v',
+ '--version',
+ action='version',
+ version='Poezio v%s' % __version__,
+ )
+ parser.add_argument(
+ "--custom-version",
+ dest="custom_version",
help=SUPPRESS,
metavar="VERSION",
- default="0.13-dev")
- options = parser.parse_args()
- return options
+ default=__version__
+ )
+ return parser.parse_args()
+
+
+def run_cmdline_args() -> Tuple[Namespace, bool]:
+ "Parse the command line arguments"
+ options = parse_args(xdg.CONFIG_HOME)
+ firstrun = False
+
+ # Copy a default file if none exists
+ if not options.filename.is_file():
+ try:
+ options.filename.parent.mkdir(parents=True, exist_ok=True)
+ except OSError as e:
+ sys.stderr.write(
+ 'Poezio was unable to create the config directory: %s\n' % e)
+ sys.exit(1)
+ default = Path(__file__).parent / '..' / 'data' / 'default_config.cfg'
+ other = Path(
+ pkg_resources.resource_filename('poezio', 'default_config.cfg'))
+ if default.is_file():
+ copy2(str(default), str(options.filename))
+ elif other.is_file():
+ copy2(str(other), str(options.filename))
+
+ # Inside the nixstore and possibly other distributions, the reference
+ # file is readonly, so is the copy.
+ # Make it writable by the user who just created it.
+ if options.filename.exists():
+ options.filename.chmod(options.filename.stat().st_mode
+ | stat.S_IWUSR)
+ firstrun = True
+
+ return (options, firstrun)
diff --git a/poezio/asyncio.py b/poezio/asyncio_fix.py
index d333ffa6..d333ffa6 100644
--- a/poezio/asyncio.py
+++ b/poezio/asyncio_fix.py
diff --git a/poezio/bookmarks.py b/poezio/bookmarks.py
index 0406de94..64d7a437 100644
--- a/poezio/bookmarks.py
+++ b/poezio/bookmarks.py
@@ -30,11 +30,20 @@ Adding a remote bookmark:
import functools
import logging
-from typing import Optional, List, Union
-
-from slixmpp import JID
+from typing import (
+ Callable,
+ List,
+ Optional,
+ Union,
+)
+
+from slixmpp import (
+ InvalidJID,
+ JID,
+)
+from slixmpp.exceptions import IqError, IqTimeout
from slixmpp.plugins.xep_0048 import Bookmarks, Conference, URL
-from poezio.common import safeJID
+from poezio.connection import Connection
from poezio.config import config
log = logging.getLogger(__name__)
@@ -42,20 +51,43 @@ log = logging.getLogger(__name__)
class Bookmark:
def __init__(self,
- jid: JID,
+ jid: Union[JID, str],
name: Optional[str] = None,
autojoin=False,
nick: Optional[str] = None,
password: Optional[str] = None,
method='local') -> None:
- self.jid = jid
- self.name = name or jid
+ try:
+ if isinstance(jid, JID):
+ self._jid = jid
+ else:
+ self._jid = JID(jid)
+ except InvalidJID:
+ log.debug('Invalid JID %r provided for bookmark', jid)
+ raise
+ self.name = name or str(self.jid)
self.autojoin = autojoin
self.nick = nick
self.password = password
self._method = method
@property
+ def jid(self) -> JID:
+ """Jid getter"""
+ return self._jid
+
+ @jid.setter
+ def jid(self, jid: JID) -> None:
+ try:
+ if isinstance(jid, JID):
+ self._jid = jid
+ else:
+ self._jid = JID(jid)
+ except InvalidJID:
+ log.debug('Invalid JID %r provided for bookmark', jid)
+ raise
+
+ @property
def method(self) -> str:
return self._method
@@ -86,7 +118,7 @@ class Bookmark:
def local(self) -> str:
"""Generate a str for local storage"""
- local = self.jid
+ local = str(self.jid)
if self.nick:
local += '/%s' % self.nick
local += ':'
@@ -130,8 +162,8 @@ class Bookmark:
class BookmarkList:
def __init__(self):
- self.bookmarks = [] # type: List[Bookmark]
- preferred = config.get('use_bookmarks_method').lower()
+ self.bookmarks: List[Bookmark] = []
+ preferred = config.getstr('use_bookmarks_method').lower()
if preferred not in ('pep', 'privatexml'):
preferred = 'privatexml'
self.preferred = preferred
@@ -149,7 +181,7 @@ class BookmarkList:
return self.bookmarks[key]
return None
- def __in__(self, key) -> bool:
+ def __contains__(self, key) -> bool:
if isinstance(key, (str, JID)):
for bookmark in self.bookmarks:
if bookmark.jid == key:
@@ -191,17 +223,17 @@ class BookmarkList:
self.preferred = value
config.set_and_save('use_bookmarks_method', value)
- def save_remote(self, xmpp, callback):
+ async def save_remote(self, xmpp: Connection):
"""Save the remote bookmarks."""
if not any(self.available_storage.values()):
return
method = 'xep_0049' if self.preferred == 'privatexml' else 'xep_0223'
if method:
- xmpp.plugin['xep_0048'].set_bookmarks(
+ return await xmpp.plugin['xep_0048'].set_bookmarks(
stanza_storage(self.bookmarks),
method=method,
- callback=callback)
+ )
def save_local(self):
"""Save the local bookmarks."""
@@ -209,86 +241,65 @@ class BookmarkList:
if bookmark.method == 'local')
config.set_and_save('rooms', local)
- def save(self, xmpp, core=None, callback=None):
+ async def save(self, xmpp: Connection, core=None):
"""Save all the bookmarks."""
self.save_local()
-
- def _cb(iq):
- if callback:
- callback(iq)
- if iq["type"] == "error" and core:
- core.information('Could not save remote bookmarks.', 'Error')
- elif core:
- core.information('Bookmarks saved', 'Info')
-
- if config.get('use_remote_bookmarks'):
- self.save_remote(xmpp, _cb)
-
- def get_pep(self, xmpp, callback):
+ if config.getbool('use_remote_bookmarks'):
+ try:
+ result = await self.save_remote(xmpp)
+ if core is not None:
+ core.information('Bookmarks saved', 'Info')
+ return result
+ except (IqError, IqTimeout):
+ if core is not None:
+ core.information(
+ 'Could not save remote bookmarks.',
+ 'Error'
+ )
+ raise
+
+ async def get_pep(self, xmpp: Connection):
"""Add the remotely stored bookmarks via pep to the list."""
+ iq = await xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0223')
+ for conf in iq['pubsub']['items']['item']['bookmarks'][
+ 'conferences']:
+ if isinstance(conf, URL):
+ continue
+ bookm = Bookmark.parse(conf)
+ self.append(bookm)
+ return iq
- def _cb(iq):
- if iq['type'] == 'result':
- for conf in iq['pubsub']['items']['item']['bookmarks'][
- 'conferences']:
- if isinstance(conf, URL):
- continue
- b = Bookmark.parse(conf)
- self.append(b)
- if callback:
- callback(iq)
-
- xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0223', callback=_cb)
-
- def get_privatexml(self, xmpp, callback):
+ async def get_privatexml(self, xmpp: Connection):
"""
Fetch the remote bookmarks stored via privatexml.
"""
- def _cb(iq):
- if iq['type'] == 'result':
- for conf in iq['private']['bookmarks']['conferences']:
- b = Bookmark.parse(conf)
- self.append(b)
- if callback:
- callback(iq)
+ iq = await xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0049')
+ for conf in iq['private']['bookmarks']['conferences']:
+ bookm = Bookmark.parse(conf)
+ self.append(bookm)
+ return iq
- xmpp.plugin['xep_0048'].get_bookmarks(method='xep_0049', callback=_cb)
-
- def get_remote(self, xmpp, information, callback):
+ async def get_remote(self, xmpp: Connection, information: Callable):
"""Add the remotely stored bookmarks to the list."""
- force = config.get('force_remote_bookmarks')
- if xmpp.anon or not (any(self.available_storage.values()) or force):
+ if xmpp.anon or not any(self.available_storage.values()):
information('No remote bookmark storage available', 'Warning')
return
-
- if force and not any(self.available_storage.values()):
- old_callback = callback
- method = 'pep' if self.preferred == 'pep' else 'privatexml'
-
- def new_callback(result):
- if result['type'] != 'error':
- self.available_storage[method] = True
- old_callback(result)
- else:
- information('No remote bookmark storage available',
- 'Warning')
-
- callback = new_callback
-
if self.preferred == 'pep':
- self.get_pep(xmpp, callback=callback)
+ return await self.get_pep(xmpp)
else:
- self.get_privatexml(xmpp, callback=callback)
+ return await self.get_privatexml(xmpp)
def get_local(self):
"""Add the locally stored bookmarks to the list."""
- rooms = config.get('rooms')
+ rooms = config.getlist('rooms')
if not rooms:
return
- rooms = rooms.split(':')
for room in rooms:
- jid = safeJID(room)
+ try:
+ jid = JID(room)
+ except InvalidJID:
+ continue
if jid.bare == '':
continue
if jid.resource != '':
@@ -307,7 +318,7 @@ class BookmarkList:
self.append(b)
-def stanza_storage(bookmarks: BookmarkList) -> Bookmarks:
+def stanza_storage(bookmarks: Union[BookmarkList, List[Bookmark]]) -> Bookmarks:
"""Generate a <storage/> stanza with the conference elements."""
storage = Bookmarks()
for b in (b for b in bookmarks if b.method == 'remote'):
diff --git a/poezio/colors.py b/poezio/colors.py
index 6bbbb12e..62566c77 100644
--- a/poezio/colors.py
+++ b/poezio/colors.py
@@ -1,7 +1,8 @@
-from typing import Tuple, Dict, List
+from typing import Tuple, Dict, List, Union
import curses
import hashlib
-import math
+
+from . import hsluv
Palette = Dict[float, int]
@@ -13,6 +14,9 @@ K_B = 1 - K_R - K_G
def ncurses_color_to_rgb(color: int) -> Tuple[float, float, float]:
if color <= 15:
+ r: Union[int, float]
+ g: Union[int, float]
+ b: Union[int, float]
try:
(r, g, b) = curses.color_content(color)
except: # fallback in faulty terminals (e.g. xterm)
@@ -33,23 +37,18 @@ def ncurses_color_to_rgb(color: int) -> Tuple[float, float, float]:
return r / 5, g / 5, b / 5
-def rgb_to_ycbcr(r: float, g: float, b: float) -> Tuple[float, float, float]:
- y = K_R * r + K_G * g + K_B * b
- cr = (r - y) / (1 - K_R) / 2
- cb = (b - y) / (1 - K_B) / 2
- return y, cb, cr
-
-
def generate_ccg_palette(curses_palette: List[int],
reference_y: float) -> Palette:
- cbcr_palette = {} # type: Dict[float, Tuple[float, int]]
+ cbcr_palette: Dict[float, Tuple[float, int]] = {}
for curses_color in curses_palette:
r, g, b = ncurses_color_to_rgb(curses_color)
# drop grayscale
if r == g == b:
continue
- y, cb, cr = rgb_to_ycbcr(r, g, b)
- key = round(cbcr_to_angle(cb, cr), 2)
+ h, _, y = hsluv.rgb_to_hsluv((r, g, b))
+ # this is to keep the code compatible with earlier versions of XEP-0392
+ y = y / 100
+ key = round(h)
try:
existing_y, *_ = cbcr_palette[key]
except KeyError:
@@ -68,35 +67,15 @@ def text_to_angle(text: str) -> float:
hf = hashlib.sha1()
hf.update(text.encode("utf-8"))
hue = int.from_bytes(hf.digest()[:2], "little")
- return hue / 65535 * math.pi * 2
-
-
-def angle_to_cbcr_edge(angle: float) -> Tuple[float, float]:
- cr = math.sin(angle)
- cb = math.cos(angle)
- if abs(cr) > abs(cb):
- factor = 0.5 / abs(cr)
- else:
- factor = 0.5 / abs(cb)
- return cb * factor, cr * factor
-
-
-def cbcr_to_angle(cb: float, cr: float) -> float:
- magn = math.sqrt(cb**2 + cr**2)
- if magn > 0:
- cr /= magn
- cb /= magn
- return math.atan2(cr, cb) % (2 * math.pi)
+ return hue / 65535 * 360
def ccg_palette_lookup(palette: Palette, angle: float) -> int:
# try quick lookup first
try:
- color = palette[round(angle, 2)]
+ return palette[round(angle)]
except KeyError:
pass
- else:
- return color
best_metric = float("inf")
best = None
@@ -106,6 +85,9 @@ def ccg_palette_lookup(palette: Palette, angle: float) -> int:
best_metric = metric
best = color
+ if best is None:
+ raise ValueError("No color in palette")
+
return best
diff --git a/poezio/common.py b/poezio/common.py
index 3a865054..6b7d2bfe 100644
--- a/poezio/common.py
+++ b/poezio/common.py
@@ -3,12 +3,16 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Various useful functions.
"""
-from datetime import datetime, timedelta
+from datetime import (
+ datetime,
+ timedelta,
+ timezone,
+)
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
@@ -16,10 +20,14 @@ import os
import subprocess
import time
import string
+import logging
+import itertools
-from slixmpp import JID, InvalidJID, Message
+from slixmpp import Message
from poezio.poezio_shlex import shlex
+log = logging.getLogger(__name__)
+
def _get_output_of_command(command: str) -> Optional[List[str]]:
"""
@@ -36,7 +44,7 @@ def _get_output_of_command(command: str) -> Optional[List[str]]:
return None
-def _is_in_path(command: str, return_abs_path=False) -> Union[bool, str]:
+def _is_in_path(command: str, return_abs_path: bool = False) -> Union[bool, str]:
"""
Check if *command* is in the $PATH or not.
@@ -103,10 +111,12 @@ def get_os_info() -> str:
stdout=subprocess.PIPE,
close_fds=True)
process.wait()
- output = process.stdout.readline().decode('utf-8').strip()
- # some distros put n/a in places, so remove those
- output = output.replace('n/a', '').replace('N/A', '')
- return output
+ if process.stdout is not None:
+ out = process.stdout.readline().decode('utf-8').strip()
+ # some distros put n/a in places, so remove those
+ out = out.replace('n/a', '').replace('N/A', '')
+ return out
+ return ''
# lsb_release executable not available, so parse files
for distro_name in DISTRO_INFO:
@@ -240,7 +250,7 @@ def find_delayed_tag(message: Message) -> Tuple[bool, Optional[datetime]]:
find_delay = message.xml.find
delay_tag = find_delay('{urn:xmpp:delay}delay')
- date = None # type: Optional[datetime]
+ date: Optional[datetime] = None
if delay_tag is not None:
delayed = True
date = _datetime_tuple(delay_tag.attrib['stamp'])
@@ -279,7 +289,7 @@ def shell_split(st: str) -> List[str]:
return ret
-def find_argument(pos: int, text: str, quoted=True) -> int:
+def find_argument(pos: int, text: str, quoted: bool = True) -> int:
"""
Split an input into a list of arguments, return the number of the
argument selected by pos.
@@ -334,7 +344,7 @@ def _find_argument_unquoted(pos: int, text: str) -> int:
return argnum + 1
-def parse_str_to_secs(duration='') -> int:
+def parse_str_to_secs(duration: str = '') -> int:
"""
Parse a string of with a number of d, h, m, s.
@@ -362,7 +372,7 @@ def parse_str_to_secs(duration='') -> int:
return result
-def parse_secs_to_str(duration=0) -> str:
+def parse_secs_to_str(duration: int = 0) -> str:
"""
Do the reverse operation of :py:func:`parse_str_to_secs`.
@@ -394,7 +404,7 @@ def parse_secs_to_str(duration=0) -> str:
def format_tune_string(infos: Dict[str, str]) -> str:
"""
- Contruct a string from a dict created from an "User tune" event.
+ Construct a string from a dict created from an "User tune" event.
:param dict infos: Tune information
:return: The formatted string
@@ -449,14 +459,103 @@ def format_gaming_string(infos: Dict[str, str]) -> str:
return name
-def safeJID(*args, **kwargs) -> JID:
+def unique_prefix_of(a: str, b: str) -> str:
"""
- Construct a :py:class:`slixmpp.JID` object from a string.
+ Return the unique prefix of `a` with `b`.
+
+ Corner cases:
- Used to avoid tracebacks during is stringprep fails
- (fall back to a JID with an empty string).
+ * If `a` and `b` share no prefix, the first letter of `a` is returned.
+ * If `a` and `b` are equal, `a` is returned.
+ * If `a` is a prefix of `b`, `a` is returned.
+ * If `b` is a prefix of `a`, `b` plus the first letter of `a` after the
+ common prefix is returned.
"""
- try:
- return JID(*args, **kwargs)
- except InvalidJID:
- return JID('')
+ for i, (ca, cb) in enumerate(itertools.zip_longest(a, b)):
+ if ca != cb:
+ return a[:i+1]
+ # both are equal, return a
+ return a
+
+
+def to_utc(time_: datetime) -> datetime:
+ """Convert a datetime-aware time zone into raw UTC"""
+ if time_.tzinfo is not None: # Convert to UTC
+ time_ = time_.astimezone(tz=timezone.utc)
+ else: # Assume local tz, convert to UTC
+ tzone = datetime.now().astimezone().tzinfo
+ time_ = time_.replace(tzinfo=tzone).astimezone(tz=timezone.utc)
+ # Return an offset-naive datetime
+ return time_.replace(tzinfo=None)
+
+
+# http://xmpp.org/extensions/xep-0045.html#errorstatus
+ERROR_AND_STATUS_CODES = {
+ '401': 'A password is required',
+ '403': 'Permission denied',
+ '404': 'The room doesn’t exist',
+ '405': 'Your are not allowed to create a new room',
+ '406': 'A reserved nick must be used',
+ '407': 'You are not in the member list',
+ '409': 'This nickname is already in use or has been reserved',
+ '503': 'The maximum number of users has been reached',
+}
+
+
+# http://xmpp.org/extensions/xep-0086.html
+DEPRECATED_ERRORS = {
+ '302': 'Redirect',
+ '400': 'Bad request',
+ '401': 'Not authorized',
+ '402': 'Payment required',
+ '403': 'Forbidden',
+ '404': 'Not found',
+ '405': 'Not allowed',
+ '406': 'Not acceptable',
+ '407': 'Registration required',
+ '408': 'Request timeout',
+ '409': 'Conflict',
+ '500': 'Internal server error',
+ '501': 'Feature not implemented',
+ '502': 'Remote server error',
+ '503': 'Service unavailable',
+ '504': 'Remote server timeout',
+ '510': 'Disconnected',
+}
+
+
+def get_error_message(stanza: Message, deprecated: bool = False) -> str:
+ """
+ Takes a stanza of the form <message type='error'><error/></message>
+ and return a well formed string containing error information
+ """
+ sender = stanza['from']
+ msg = stanza['error']['type']
+ condition = stanza['error']['condition']
+ code = stanza['error']['code']
+ body = stanza['error']['text']
+ if not body:
+ if deprecated:
+ if code in DEPRECATED_ERRORS:
+ body = DEPRECATED_ERRORS[code]
+ else:
+ body = condition or 'Unknown error'
+ else:
+ if code in ERROR_AND_STATUS_CODES:
+ body = ERROR_AND_STATUS_CODES[code]
+ else:
+ body = condition or 'Unknown error'
+ if code:
+ message = '%(from)s: %(code)s - %(msg)s: %(body)s' % {
+ 'from': sender,
+ 'msg': msg,
+ 'body': body,
+ 'code': code
+ }
+ else:
+ message = '%(from)s: %(msg)s: %(body)s' % {
+ 'from': sender,
+ 'msg': msg,
+ 'body': body
+ }
+ return message
diff --git a/poezio/config.py b/poezio/config.py
index d5a81c0e..4eb43cad 100644
--- a/poezio/config.py
+++ b/poezio/config.py
@@ -10,35 +10,37 @@ TODO: get http://bugs.python.org/issue1410680 fixed, one day, in order
to remove our ugly custom I/O methods.
"""
+import logging
import logging.config
import os
-import stat
import sys
-import pkg_resources
from configparser import RawConfigParser, NoOptionError, NoSectionError
from pathlib import Path
-from shutil import copy2
-from typing import Callable, Dict, List, Optional, Union, Tuple
+from typing import Dict, List, Optional, Union, Tuple, cast, Any
-from poezio.args import parse_args
from poezio import xdg
+from slixmpp import JID, InvalidJID
+
+log = logging.getLogger(__name__) # type: logging.Logger
ConfigValue = Union[str, int, float, bool]
-DEFSECTION = "Poezio"
+ConfigDict = Dict[str, Dict[str, ConfigValue]]
+
+USE_DEFAULT_SECTION = '__DEFAULT SECTION PLACEHOLDER__'
-DEFAULT_CONFIG = {
+DEFAULT_CONFIG: ConfigDict = {
'Poezio': {
'ack_message_receipts': True,
'add_space_after_completion': True,
'after_completion': ',',
'alternative_nickname': '',
'auto_reconnect': True,
+ 'autocolor_tab_names': False,
'autorejoin_delay': '5',
'autorejoin': False,
'beep_on': 'highlight private invite disconnect',
- 'bookmark_on_join': False,
'ca_cert_path': '',
'certificate': '',
'certfile': '',
@@ -50,7 +52,6 @@ DEFAULT_CONFIG = {
'custom_port': '',
'default_nick': '',
'default_muc_service': '',
- 'deterministic_nick_colors': True,
'device_id': '',
'nick_color_aliases': True,
'display_activity_notifications': False,
@@ -74,7 +75,6 @@ DEFAULT_CONFIG = {
'extract_inline_images': True,
'filter_info_messages': '',
'force_encryption': True,
- 'force_remote_bookmarks': False,
'go_to_previous_tab_on_alt_number': False,
'group_corrections': True,
'hide_exit_join': -1,
@@ -90,9 +90,10 @@ DEFAULT_CONFIG = {
'keyfile': '',
'lang': 'en',
'lazy_resize': True,
- 'load_log': 10,
'log_dir': '',
'log_errors': True,
+ 'mam_sync': True,
+ 'mam_sync_limit': 2000,
'max_lines_in_memory': 2048,
'max_messages_in_memory': 2048,
'max_nick_length': 25,
@@ -134,9 +135,11 @@ DEFAULT_CONFIG = {
'show_useless_separator': True,
'status': '',
'status_message': '',
+ 'synchronise_open_rooms': True,
'theme': 'default',
'themes_dir': '',
'tmp_image_dir': '',
+ 'unique_prefix_tab_names': False,
'use_bookmarks_method': '',
'use_log': True,
'use_remote_bookmarks': True,
@@ -158,21 +161,33 @@ DEFAULT_CONFIG = {
}
-class Config(RawConfigParser):
+class PoezioConfigParser(RawConfigParser):
+ def optionxform(self, value) -> str:
+ return str(value)
+
+
+class Config:
"""
load/save the config to a file
"""
- def __init__(self, file_name: Path, default=None) -> None:
- RawConfigParser.__init__(self, None)
+ configparser: PoezioConfigParser
+ file_name: Path
+ default: ConfigDict
+ default_section: str = 'Poezio'
+
+ def __init__(self, file_name: Path, default: Optional[ConfigDict] = None) -> None:
+ self.configparser = PoezioConfigParser()
# make the options case sensitive
- self.optionxform = lambda param: str(param)
self.file_name = file_name
self.read_file()
- self.default = default
+ self.default = default or {}
+
+ def optionxform(self, value):
+ return str(value)
def read_file(self):
- RawConfigParser.read(self, str(self.file_name), encoding='utf-8')
+ self.configparser.read(str(self.file_name), encoding='utf-8')
# Check config integrity and fix it if it’s wrong
# only when the object is the main config
if self.__class__ is Config:
@@ -183,38 +198,62 @@ class Config(RawConfigParser):
def get(self,
option: str,
default: Optional[ConfigValue] = None,
- section=DEFSECTION) -> ConfigValue:
+ section: str = USE_DEFAULT_SECTION) -> Any:
"""
get a value from the config but return
a default value if it is not found
The type of default defines the type
returned
"""
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
if default is None:
- if self.default:
- default = self.default.get(section, {}).get(option)
- else:
- default = ''
+ default = self.default.get(section, {}).get(option, '')
+ res: Optional[ConfigValue]
try:
if isinstance(default, bool):
- res = self.getboolean(option, section)
+ res = self.configparser.getboolean(section, option)
elif isinstance(default, int):
- res = self.getint(option, section)
+ res = self.configparser.getint(section, option)
elif isinstance(default, float):
- res = self.getfloat(option, section)
+ res = self.configparser.getfloat(section, option)
else:
- res = self.getstr(option, section)
+ res = self.configparser.get(section, option)
except (NoOptionError, NoSectionError, ValueError, AttributeError):
- return default if default is not None else ''
+ return default
if res is None:
return default
return res
+ def _get_default(self, option, section):
+ if self.default:
+ return self.default.get(section, {}).get(option)
+ else:
+ return ''
+
+ def sections(self, *args, **kwargs) -> List[str]:
+ return self.configparser.sections(*args, **kwargs)
+
+ def options(self, *args, **kwargs):
+ return self.configparser.options(*args, **kwargs)
+
+ def has_option(self, *args, **kwargs) -> bool:
+ return self.configparser.has_option(*args, **kwargs)
+
+ def has_section(self, *args, **kwargs) -> bool:
+ return self.configparser.has_section(*args, **kwargs)
+
+ def add_section(self, *args, **kwargs):
+ return self.configparser.add_section(*args, **kwargs)
+
+ def remove_section(self, *args, **kwargs):
+ return self.configparser.remove_section(*args, **kwargs)
+
def get_by_tabname(self,
option,
- tabname,
+ tabname: JID,
fallback=True,
fallback_server=True,
default=''):
@@ -225,11 +264,11 @@ class Config(RawConfigParser):
True. And we return `default` as a fallback as a last resort.
"""
if self.default and (not default) and fallback:
- default = self.default.get(DEFSECTION, {}).get(option, '')
+ default = self.default.get(self.default_section, {}).get(option, '')
if tabname in self.sections():
if option in self.options(tabname):
# We go the tab-specific option
- return self.get(option, default, tabname)
+ return self.get(option, default, tabname.full)
if fallback_server:
return self.get_by_servname(tabname, option, default, fallback)
if fallback:
@@ -241,7 +280,10 @@ class Config(RawConfigParser):
"""
Try to get the value of an option for a server
"""
- server = safeJID(jid).server
+ try:
+ server = JID(jid).server
+ except InvalidJID:
+ server = ''
if server:
server = '@' + server
if server in self.sections() and option in self.options(server):
@@ -250,11 +292,13 @@ class Config(RawConfigParser):
return self.get(option, default)
return default
- def __get(self, option, section=DEFSECTION, **kwargs):
+ def __get(self, option, section=USE_DEFAULT_SECTION, **kwargs):
"""
facility for RawConfigParser.get
"""
- return RawConfigParser.get(self, section, option, **kwargs)
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
+ return self.configparser.get(section, option, **kwargs)
def _get(self, section, conv, option, **kwargs):
"""
@@ -262,29 +306,54 @@ class Config(RawConfigParser):
"""
return conv(self.__get(option, section, **kwargs))
- def getstr(self, option, section=DEFSECTION):
+ def getstr(self, option, section=USE_DEFAULT_SECTION) -> str:
"""
get a value and returns it as a string
"""
- return self.__get(option, section)
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
+ try:
+ return self.configparser.get(section, option)
+ except (NoOptionError, NoSectionError, ValueError, AttributeError):
+ return cast(str, self._get_default(option, section))
- def getint(self, option, section=DEFSECTION):
+ def getint(self, option, section=USE_DEFAULT_SECTION) -> int:
"""
get a value and returns it as an int
"""
- return RawConfigParser.getint(self, section, option)
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
+ try:
+ return self.configparser.getint(section, option)
+ except (NoOptionError, NoSectionError, ValueError, AttributeError):
+ return cast(int, self._get_default(option, section))
- def getfloat(self, option, section=DEFSECTION):
+ def getfloat(self, option, section=USE_DEFAULT_SECTION) -> float:
"""
get a value and returns it as a float
"""
- return RawConfigParser.getfloat(self, section, option)
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
+ try:
+ return self.configparser.getfloat(section, option)
+ except (NoOptionError, NoSectionError, ValueError, AttributeError):
+ return cast(float, self._get_default(option, section))
- def getboolean(self, option, section=DEFSECTION):
+ def getbool(self, option, section=USE_DEFAULT_SECTION) -> bool:
"""
get a value and returns it as a boolean
"""
- return RawConfigParser.getboolean(self, section, option)
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
+ try:
+ return self.configparser.getboolean(section, option)
+ except (NoOptionError, NoSectionError, ValueError, AttributeError):
+ return cast(bool, self._get_default(option, section))
+
+ def getlist(self, option, section=USE_DEFAULT_SECTION) -> List[str]:
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
+ return self.getstr(option, section).split(':')
def write_in_file(self, section: str, option: str,
value: ConfigValue) -> bool:
@@ -306,7 +375,7 @@ class Config(RawConfigParser):
begin, end = sections[section]
pos = find_line(result_lines, begin, end, option)
- if pos is -1:
+ if pos == -1:
result_lines.insert(end, '%s = %s' % (option, value))
else:
result_lines[pos] = '%s = %s' % (option, value)
@@ -332,7 +401,7 @@ class Config(RawConfigParser):
begin, end = sections[section]
pos = find_line(result_lines, begin, end, option)
- if pos is -1:
+ if pos == -1:
log.error(
'Tried to remove a non-existing option %s'
' from section %s', option, section)
@@ -380,8 +449,7 @@ class Config(RawConfigParser):
if file_ok(self.file_name):
try:
with self.file_name.open('r', encoding='utf-8') as df:
- lines_before = [line.strip()
- for line in df] # type: List[str]
+ lines_before: List[str] = [line.strip() for line in df]
except OSError:
log.error(
'Unable to read the config file %s',
@@ -391,7 +459,7 @@ class Config(RawConfigParser):
else:
lines_before = []
- sections = {} # type: Dict[str, List[int]]
+ sections: Dict[str, List[int]] = {}
duplicate_section = False
current_section = ''
current_line = 0
@@ -418,7 +486,7 @@ class Config(RawConfigParser):
return (sections, lines_before)
def set_and_save(self, option: str, value: ConfigValue,
- section=DEFSECTION) -> Tuple[str, str]:
+ section=USE_DEFAULT_SECTION) -> Tuple[str, str]:
"""
set the value in the configuration then save it
to the file
@@ -426,10 +494,12 @@ class Config(RawConfigParser):
# Special case for a 'toggle' value. We take the current value
# and set the opposite. Warning if the no current value exists
# or it is not a bool.
- if value == "toggle":
- current = self.get(option, "", section)
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
+ if isinstance(value, str) and value == "toggle":
+ current = self.getbool(option, section)
if isinstance(current, bool):
- value = str(not current)
+ value = str(not current).lower()
else:
if current.lower() == "false":
value = "true"
@@ -440,51 +510,60 @@ class Config(RawConfigParser):
'Could not toggle option: %s.'
' Current value is %s.' % (option, current or "empty"),
'Warning')
+ value = str(value)
if self.has_section(section):
- RawConfigParser.set(self, section, option, value)
+ self.configparser.set(section, option, value)
else:
self.add_section(section)
- RawConfigParser.set(self, section, option, value)
+ self.configparser.set(section, option, value)
if not self.write_in_file(section, option, value):
return ('Unable to write in the config file', 'Error')
+ if isinstance(option, str) and 'password' in option and 'eval_password' not in option:
+ value = '********'
return ("%s=%s" % (option, value), 'Info')
def remove_and_save(self, option: str,
- section=DEFSECTION) -> Tuple[str, str]:
+ section=USE_DEFAULT_SECTION) -> Tuple[str, str]:
"""
Remove an option and then save it the config file
"""
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
if self.has_section(section):
- RawConfigParser.remove_option(self, section, option)
+ self.configparser.remove_option(section, option)
if not self.remove_in_file(section, option):
return ('Unable to save the config file', 'Error')
return ('Option %s deleted' % option, 'Info')
- def silent_set(self, option: str, value: ConfigValue, section=DEFSECTION):
+ def silent_set(self, option: str, value: ConfigValue, section=USE_DEFAULT_SECTION):
"""
Set a value, save, and return True on success and False on failure
"""
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
if self.has_section(section):
- RawConfigParser.set(self, section, option, value)
+ self.configparser.set(section, option, str(value))
else:
self.add_section(section)
- RawConfigParser.set(self, section, option, value)
- return self.write_in_file(section, option, value)
+ self.configparser.set(section, option, str(value))
+ return self.write_in_file(section, option, str(value))
- def set(self, option: str, value: ConfigValue, section=DEFSECTION):
+ def set(self, option: str, value: ConfigValue, section=USE_DEFAULT_SECTION):
"""
Set the value of an option temporarily
"""
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
try:
- RawConfigParser.set(self, section, option, value)
+ self.configparser.set(section, option, str(value))
except NoSectionError:
pass
- def to_dict(self) -> Dict[str, Dict[str, ConfigValue]]:
+ def to_dict(self) -> Dict[str, Dict[str, Optional[ConfigValue]]]:
"""
Returns a dict of the form {section: {option: value, option: value}, …}
"""
- res = {} # Dict[str, Dict[str, ConfigValue]]
+ res: Dict[str, Dict[str, Optional[ConfigValue]]] = {}
for section in self.sections():
res[section] = {}
for option in self.options(section):
@@ -518,10 +597,10 @@ def file_ok(filepath: Path) -> bool:
return bool(val)
-def get_image_cache() -> Path:
+def get_image_cache() -> Optional[Path]:
if not config.get('extract_inline_images'):
return None
- tmp_dir = config.get('tmp_image_dir')
+ tmp_dir = config.getstr('tmp_image_dir')
if tmp_dir:
return Path(tmp_dir)
return xdg.CACHE_HOME / 'images'
@@ -560,43 +639,11 @@ def check_config():
print(' \033[31m%s\033[0m' % option)
-def run_cmdline_args():
- "Parse the command line arguments"
- global options
- options = parse_args(xdg.CONFIG_HOME)
-
- # Copy a default file if none exists
- if not options.filename.is_file():
- try:
- options.filename.parent.mkdir(parents=True, exist_ok=True)
- except OSError as e:
- sys.stderr.write(
- 'Poezio was unable to create the config directory: %s\n' % e)
- sys.exit(1)
- default = Path(__file__).parent / '..' / 'data' / 'default_config.cfg'
- other = Path(
- pkg_resources.resource_filename('poezio', 'default_config.cfg'))
- if default.is_file():
- copy2(str(default), str(options.filename))
- elif other.is_file():
- copy2(str(other), str(options.filename))
-
- # Inside the nixstore and possibly other distributions, the reference
- # file is readonly, so is the copy.
- # Make it writable by the user who just created it.
- if options.filename.exists():
- options.filename.chmod(options.filename.stat().st_mode
- | stat.S_IWUSR)
-
- global firstrun
- firstrun = True
-
-
-def create_global_config():
+def create_global_config(filename):
"Create the global config object, or crash"
try:
global config
- config = Config(options.filename, DEFAULT_CONFIG)
+ config = Config(filename, DEFAULT_CONFIG)
except:
import traceback
sys.stderr.write('Poezio was unable to read or'
@@ -605,11 +652,13 @@ def create_global_config():
sys.exit(1)
-def setup_logging():
+def setup_logging(debug_file=''):
"Change the logging config according to the cmdline options and config"
global LOG_DIR
LOG_DIR = config.get('log_dir')
LOG_DIR = Path(LOG_DIR).expanduser() if LOG_DIR else xdg.DATA_HOME / 'logs'
+ from copy import deepcopy
+ logging_config = deepcopy(LOGGING_CONFIG)
if config.get('log_errors'):
try:
LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -617,8 +666,8 @@ def setup_logging():
# We can’t really log any error here, because logging isn’t setup yet.
pass
else:
- LOGGING_CONFIG['root']['handlers'].append('error')
- LOGGING_CONFIG['handlers']['error'] = {
+ logging_config['root']['handlers'].append('error')
+ logging_config['handlers']['error'] = {
'level': 'ERROR',
'class': 'logging.FileHandler',
'filename': str(LOG_DIR / 'errors.log'),
@@ -626,37 +675,26 @@ def setup_logging():
}
logging.disable(logging.WARNING)
- if options.debug:
- LOGGING_CONFIG['root']['handlers'].append('debug')
- LOGGING_CONFIG['handlers']['debug'] = {
+ if debug_file:
+ logging_config['root']['handlers'].append('debug')
+ logging_config['handlers']['debug'] = {
'level': 'DEBUG',
'class': 'logging.FileHandler',
- 'filename': options.debug,
+ 'filename': debug_file,
'formatter': 'simple',
}
logging.disable(logging.NOTSET)
- if LOGGING_CONFIG['root']['handlers']:
- logging.config.dictConfig(LOGGING_CONFIG)
+ if logging_config['root']['handlers']:
+ logging.config.dictConfig(logging_config)
else:
logging.disable(logging.ERROR)
logging.basicConfig(level=logging.CRITICAL)
- global log
- log = logging.getLogger(__name__)
-
-
-def post_logging_setup():
- # common imports slixmpp, which creates then its loggers, so
- # it needs to be after logger configuration
- from poezio.common import safeJID as JID
- global safeJID
- safeJID = JID
-
LOGGING_CONFIG = {
'version': 1,
- 'disable_existing_loggers': True,
+ 'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '%(asctime)s %(levelname)s:%(module)s:%(message)s'
@@ -670,21 +708,8 @@ LOGGING_CONFIG = {
}
}
-# True if this is the first run, in this case we will display
-# some help in the info buffer
-firstrun = False
-
-# Global config object. Is setup in poezio.py
-config = None # type: Optional[Config]
-
-# The logger object for this module
-log = None # type: Optional[logging.Logger]
-
-# The command-line options
-options = None
-
-# delayed import from common.py
-safeJID = None # type: Optional[Callable]
+# Global config object. Is setup for real in poezio.py
+config = Config(Path('/dev/null'))
# the global log dir
LOG_DIR = Path()
diff --git a/poezio/connection.py b/poezio/connection.py
index 57254069..503d9169 100644
--- a/poezio/connection.py
+++ b/poezio/connection.py
@@ -3,7 +3,7 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Defines the Connection class
"""
@@ -16,8 +16,10 @@ import subprocess
import sys
import base64
import random
+from pathlib import Path
import slixmpp
+from slixmpp import JID, InvalidJID
from slixmpp.xmlstream import ET
from slixmpp.plugins.xep_0184 import XEP_0184
from slixmpp.plugins.xep_0030 import DiscoInfo
@@ -26,8 +28,7 @@ from slixmpp.util import FileSystemCache
from poezio import common
from poezio import fixes
from poezio import xdg
-from poezio.common import safeJID
-from poezio.config import config, options
+from poezio.config import config
class Connection(slixmpp.ClientXMPP):
@@ -37,25 +38,25 @@ class Connection(slixmpp.ClientXMPP):
"""
__init = False
- def __init__(self):
- keyfile = config.get('keyfile')
- certfile = config.get('certfile')
+ def __init__(self, custom_version=''):
+ keyfile = config.getstr('keyfile')
+ certfile = config.getstr('certfile')
- device_id = config.get('device_id')
+ device_id = config.getstr('device_id')
if not device_id:
rng = random.SystemRandom()
device_id = base64.urlsafe_b64encode(
rng.getrandbits(24).to_bytes(3, 'little')).decode('ascii')
config.set_and_save('device_id', device_id)
- if config.get('jid'):
+ if config.getstr('jid'):
# Field used to know if we are anonymous or not.
# many features will be handled differently
# depending on this setting
self.anon = False
- jid = config.get('jid')
- password = config.get('password')
- eval_password = config.get('eval_password')
+ jid = config.getstr('jid')
+ password = config.getstr('password')
+ eval_password = config.getstr('eval_password')
if not password and not eval_password and not (keyfile
and certfile):
password = getpass.getpass()
@@ -79,25 +80,29 @@ class Connection(slixmpp.ClientXMPP):
'\n')
else: # anonymous auth
self.anon = True
- jid = config.get('server')
+ jid = config.getstr('server')
password = None
- jid = safeJID(jid)
+ try:
+ jid = JID(jid)
+ except InvalidJID:
+ sys.stderr.write('Invalid jid option: "%s" is not a valid JID\n' % jid)
+ sys.exit(1)
jid.resource = '%s-%s' % (
jid.resource,
device_id) if jid.resource else 'poezio-%s' % device_id
# TODO: use the system language
slixmpp.ClientXMPP.__init__(
- self, jid, password, lang=config.get('lang'))
+ self, jid, password, lang=config.getstr('lang'))
- force_encryption = config.get('force_encryption')
+ force_encryption = config.getbool('force_encryption')
if force_encryption:
self['feature_mechanisms'].unencrypted_plain = False
self['feature_mechanisms'].unencrypted_digest = False
self['feature_mechanisms'].unencrypted_cram = False
self['feature_mechanisms'].unencrypted_scram = False
- self.keyfile = config.get('keyfile')
- self.certfile = config.get('certfile')
+ self.keyfile = keyfile
+ self.certfile = certfile
if keyfile and not certfile:
log.error(
'keyfile is present in configuration file without certfile')
@@ -106,15 +111,18 @@ class Connection(slixmpp.ClientXMPP):
'certfile is present in configuration file without keyfile')
self.core = None
- self.auto_reconnect = config.get('auto_reconnect')
+ self.auto_reconnect = config.getbool('auto_reconnect')
self.auto_authorize = None
# prosody defaults, lowest is AES128-SHA, it should be a minimum
# for anything that came out after 2002
- self.ciphers = config.get(
+ self.ciphers = config.getstr(
'ciphers', 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK'
':!SRP:!3DES:!aNULL')
- self.ca_certs = config.get('ca_cert_path') or None
- interval = config.get('whitespace_interval')
+ self.ca_certs = None
+ ca_certs = config.getlist('ca_cert_path')
+ if ca_certs and ca_certs != ['']:
+ self.ca_certs = list(map(Path, config.getlist('ca_cert_path')))
+ interval = config.getint('whitespace_interval')
if int(interval) > 0:
self.whitespace_keepalive_interval = int(interval)
else:
@@ -152,33 +160,21 @@ class Connection(slixmpp.ClientXMPP):
# without a body
XEP_0184._filter_add_receipt_request = fixes._filter_add_receipt_request
self.register_plugin('xep_0184')
- self.plugin['xep_0184'].auto_ack = config.get('ack_message_receipts')
- self.plugin['xep_0184'].auto_request = config.get(
+ self.plugin['xep_0184'].auto_ack = config.getbool('ack_message_receipts')
+ self.plugin['xep_0184'].auto_request = config.getbool(
'request_message_receipts')
self.register_plugin('xep_0191')
- if config.get('enable_smacks'):
+ if config.getbool('enable_smacks'):
self.register_plugin('xep_0198')
self.register_plugin('xep_0199')
- if config.get('enable_user_tune'):
- self.register_plugin('xep_0118')
-
- if config.get('enable_user_nick'):
+ if config.getbool('enable_user_nick'):
self.register_plugin('xep_0172')
- if config.get('enable_user_mood'):
- self.register_plugin('xep_0107')
-
- if config.get('enable_user_activity'):
- self.register_plugin('xep_0108')
-
- if config.get('enable_user_gaming'):
- self.register_plugin('xep_0196')
-
- if config.get('send_poezio_info'):
- info = {'name': 'poezio', 'version': options.version}
- if config.get('send_os_info'):
+ if config.getbool('send_poezio_info'):
+ info = {'name': 'poezio', 'version': custom_version}
+ if config.getbool('send_os_info'):
info['os'] = common.get_os_info()
self.plugin['xep_0030'].set_identities(identities={('client',
'console',
@@ -190,7 +186,7 @@ class Connection(slixmpp.ClientXMPP):
'console',
None, '')})
self.register_plugin('xep_0092', pconfig=info)
- if config.get('send_time'):
+ if config.getbool('send_time'):
self.register_plugin('xep_0202')
self.register_plugin('xep_0224')
self.register_plugin('xep_0231')
@@ -199,18 +195,20 @@ class Connection(slixmpp.ClientXMPP):
self.register_plugin('xep_0280')
self.register_plugin('xep_0297')
self.register_plugin('xep_0308')
- self.register_plugin('xep_0319')
+ self.register_plugin('xep_0313')
self.register_plugin('xep_0334')
self.register_plugin('xep_0352')
try:
self.register_plugin('xep_0363')
- except SyntaxError:
- log.error('Failed to load HTTP File Upload plugin, it can only be '
- 'used on Python 3.5+')
except slixmpp.plugins.base.PluginNotFound:
log.error('Failed to load HTTP File Upload plugin, it can only be '
'used with aiohttp installed')
self.register_plugin('xep_0380')
+ try:
+ self.register_plugin('xep_0454')
+ except slixmpp.plugins.base.PluginNotFound:
+ log.error('Failed to load Media Sharing plugin, '
+ 'it requires slixmpp 1.8.2.')
self.init_plugins()
def set_keepalive_values(self, option=None, value=None):
@@ -223,8 +221,8 @@ class Connection(slixmpp.ClientXMPP):
# Happens when we change the value with /set while we are not
# connected. Do nothing in that case
return
- ping_interval = config.get('connection_check_interval')
- timeout_delay = config.get('connection_timeout_delay')
+ ping_interval = config.getint('connection_check_interval')
+ timeout_delay = config.getint('connection_timeout_delay')
if timeout_delay <= 0:
# We help the stupid user (with a delay of 0, poezio will try to
# reconnect immediately because the timeout is immediately
@@ -241,7 +239,7 @@ class Connection(slixmpp.ClientXMPP):
"""
Connect and process events.
"""
- custom_host = config.get('custom_host')
+ custom_host = config.getstr('custom_host')
custom_port = config.get('custom_port', 5222)
if custom_port == -1:
custom_port = 5222
diff --git a/poezio/contact.py b/poezio/contact.py
index 27b0598c..90f34c7e 100644
--- a/poezio/contact.py
+++ b/poezio/contact.py
@@ -3,7 +3,7 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Defines the Resource and Contact classes, which are used in
the roster.
@@ -11,10 +11,17 @@ the roster.
from collections import defaultdict
import logging
-from typing import Dict, Iterator, List, Optional, Union
-
-from poezio.common import safeJID
-from slixmpp import JID
+from typing import (
+ Any,
+ Dict,
+ Iterator,
+ List,
+ Optional,
+ Union,
+)
+
+from slixmpp import InvalidJID, JID
+from slixmpp.roster import RosterItem
log = logging.getLogger(__name__)
@@ -30,8 +37,8 @@ class Resource:
data: the dict to use as a source
"""
# Full JID
- self._jid = jid # type: str
- self._data = data # type: Dict[str, Union[str, int]]
+ self._jid: str = jid
+ self._data: Dict[str, Union[str, int]] = data
@property
def jid(self) -> str:
@@ -39,15 +46,18 @@ class Resource:
@property
def priority(self) -> int:
- return self._data.get('priority') or 0
+ try:
+ return int(self._data.get('priority', 0))
+ except Exception:
+ return 0
@property
def presence(self) -> str:
- return self._data.get('show') or ''
+ return str(self._data.get('show')) or ''
@property
def status(self) -> str:
- return self._data.get('status') or ''
+ return str(self._data.get('status')) or ''
def __repr__(self) -> str:
return '<%s>' % self._jid
@@ -65,19 +75,16 @@ class Contact:
to get the resource with the highest priority, etc
"""
- def __init__(self, item):
+ def __init__(self, item: RosterItem):
"""
item: a slixmpp RosterItem pointing to that contact
"""
self.__item = item
- self.folded_states = defaultdict(lambda: True) # type: Dict[str, bool]
+ self.folded_states: Dict[str, bool] = defaultdict(lambda: True)
self._name = ''
self.avatar = None
self.error = None
- self.tune = {} # type: Dict[str, str]
- self.gaming = {} # type: Dict[str, str]
- self.mood = ''
- self.activity = ''
+ self.rich_presence: Dict[str, Any] = defaultdict(lambda: None)
@property
def groups(self) -> List[str]:
@@ -90,7 +97,7 @@ class Contact:
return self.__item.jid
@property
- def name(self):
+ def name(self) -> str:
"""The name of the contact or an empty string."""
return self.__item['name'] or self._name or ''
@@ -100,26 +107,27 @@ class Contact:
self._name = value
@property
- def ask(self):
+ def ask(self) -> Optional[str]:
if self.__item['pending_out']:
return 'asked'
+ return None
@property
- def pending_in(self):
+ def pending_in(self) -> bool:
"""We received a subscribe stanza from this contact."""
return self.__item['pending_in']
@pending_in.setter
- def pending_in(self, value):
+ def pending_in(self, value: bool):
self.__item['pending_in'] = value
@property
- def pending_out(self):
+ def pending_out(self) -> bool:
"""We sent a subscribe stanza to this contact."""
return self.__item['pending_out']
@pending_out.setter
- def pending_out(self, value):
+ def pending_out(self, value: bool):
self.__item['pending_out'] = value
@property
@@ -134,8 +142,12 @@ class Contact:
return self.__item['subscription']
def __contains__(self, value):
- return value in self.__item.resources or safeJID(
- value).resource in self.__item.resources
+ try:
+ resource = JID(value).resource
+ except InvalidJID:
+ resource = None
+ return value in self.__item.resources or \
+ (resource is not None and resource in self.__item.resources)
def __len__(self) -> int:
"""Number of resources"""
@@ -147,7 +159,10 @@ class Contact:
def __getitem__(self, key) -> Optional[Resource]:
"""Return the corresponding Resource object, or None"""
- res = safeJID(key).resource
+ try:
+ res = JID(key).resource
+ except InvalidJID:
+ return None
resources = self.__item.resources
item = resources.get(res, None) or resources.get(key, None)
return Resource(key, item) if item else None
diff --git a/poezio/core/command_defs.py b/poezio/core/command_defs.py
new file mode 100644
index 00000000..770b3492
--- /dev/null
+++ b/poezio/core/command_defs.py
@@ -0,0 +1,452 @@
+from typing import Callable, List, Optional
+
+from poezio.core.commands import CommandCore
+from poezio.core.completions import CompletionCore
+from poezio.plugin_manager import PluginManager
+from poezio.types import TypedDict
+
+
+CommandDict = TypedDict(
+ "CommandDict",
+ {
+ "name": str,
+ "func": Callable,
+ "shortdesc": str,
+ "desc": str,
+ "usage": str,
+ "completion": Optional[Callable],
+ },
+ total=False,
+)
+
+
+def get_commands(commands: CommandCore, completions: CompletionCore, plugin_manager: PluginManager) -> List[CommandDict]:
+ """
+ Get the set of default poezio commands.
+ """
+ return [
+ {
+ "name": "help",
+ "func": commands.help,
+ "usage": "[command]",
+ "shortdesc": "\\_o< KOIN KOIN KOIN",
+ "completion": completions.help,
+ },
+ {
+ "name": "join",
+ "func": commands.join,
+ "usage": "[room_name][@server][/nick] [password]",
+ "desc": (
+ "Join the specified room. You can specify a nickname "
+ "after a slash (/). If no nickname is specified, you will"
+ " use the default_nick in the configuration file. You can"
+ " omit the room name: you will then join the room you're"
+ " looking at (useful if you were kicked). You can also "
+ "provide a room_name without specifying a server, the "
+ "server of the room you're currently in will be used. You"
+ " can also provide a password to join the room.\nExamples"
+ ":\n/join room@server.tld\n/join room@server.tld/John\n"
+ "/join room2\n/join /me_again\n/join\n/join room@server"
+ ".tld/my_nick password\n/join / password"
+ ),
+ "shortdesc": "Join a room",
+ "completion": completions.join,
+ },
+ {
+ "name": "exit",
+ "func": commands.quit,
+ "desc": "Just disconnect from the server and exit poezio.",
+ "shortdesc": "Exit poezio.",
+ },
+ {
+ "name": "quit",
+ "func": commands.quit,
+ "desc": "Just disconnect from the server and exit poezio.",
+ "shortdesc": "Exit poezio.",
+ },
+ {
+ "name": "next",
+ "func": commands.rotate_rooms_right,
+ "shortdesc": "Go to the next room.",
+ },
+ {
+ "name": "prev",
+ "func": commands.rotate_rooms_left,
+ "shortdesc": "Go to the previous room.",
+ },
+ {
+ "name": "win",
+ "func": commands.win,
+ "usage": "<number or name>",
+ "shortdesc": "Go to the specified room",
+ "completion": completions.win,
+ },
+ {
+ "name": "w",
+ "func": commands.win,
+ "usage": "<number or name>",
+ "shortdesc": "Go to the specified room",
+ "completion": completions.win,
+ },
+ {
+ "name": "wup",
+ "func": commands.wup,
+ "usage": "<prefix>",
+ "shortdesc": "Go to the tab whose name uniquely starts with prefix",
+ "completion": completions.win,
+ },
+ {
+ "name": "move_tab",
+ "func": commands.move_tab,
+ "usage": "<source> <destination>",
+ "desc": (
+ "Insert the <source> tab at the position of "
+ "<destination>. This will make the following tabs shift in"
+ " some cases (refer to the documentation). A tab can be "
+ "designated by its number or by the beginning of its "
+ 'address. You can use "." as a shortcut for the current '
+ "tab."
+ ),
+ "shortdesc": "Move a tab.",
+ "completion": completions.move_tab,
+ },
+ {
+ "name": "destroy_room",
+ "func": commands.destroy_room,
+ "usage": "[room JID]",
+ "desc": (
+ "Try to destroy the room [room JID], or the current"
+ " tab if it is a multi-user chat and [room JID] is "
+ "not given."
+ ),
+ "shortdesc": "Destroy a room.",
+ "completion": None,
+ },
+ {
+ "name": "status",
+ "func": commands.status,
+ "usage": "<availability> [status message]",
+ "desc": (
+ "Sets your availability and (optionally) your status "
+ 'message. The <availability> argument is one of "available'
+ ', chat, away, afk, dnd, busy, xa" and the optional '
+ "[status message] argument will be your status message."
+ ),
+ "shortdesc": "Change your availability.",
+ "completion": completions.status,
+ },
+ {
+ "name": "show",
+ "func": commands.status,
+ "usage": "<availability> [status message]",
+ "desc": (
+ "Sets your availability and (optionally) your status "
+ 'message. The <availability> argument is one of "available'
+ ', chat, away, afk, dnd, busy, xa" and the optional '
+ "[status message] argument will be your status message."
+ ),
+ "shortdesc": "Change your availability.",
+ "completion": completions.status,
+ },
+ {
+ "name": "bookmark_local",
+ "func": commands.bookmark_local,
+ "usage": "[roomname][/nick] [password]",
+ "desc": (
+ "Bookmark Local: Bookmark locally the specified room "
+ "(you will then auto-join it on each poezio start). This"
+ " commands uses almost the same syntaxe as /join. Type "
+ "/help join for syntax examples. Note that when typing "
+ '"/bookmark" on its own, the room will be bookmarked '
+ "with the nickname you're currently using in this room "
+ "(instead of default_nick)"
+ ),
+ "shortdesc": "Bookmark a room locally.",
+ "completion": completions.bookmark_local,
+ },
+ {
+ "name": "bookmark",
+ "func": commands.bookmark,
+ "usage": "[roomname][/nick] [autojoin] [password]",
+ "desc": (
+ "Bookmark: Bookmark online the specified room (you "
+ "will then auto-join it on each poezio start if autojoin"
+ " is specified and is 'true'). This commands uses almost"
+ " the same syntax as /join. Type /help join for syntax "
+ 'examples. Note that when typing "/bookmark" alone, the'
+ " room will be bookmarked with the nickname you're "
+ "currently using in this room (instead of default_nick)."
+ ),
+ "shortdesc": "Bookmark a room online.",
+ "completion": completions.bookmark,
+ },
+ {
+ "name": "accept",
+ "func": commands.accept,
+ "usage": "[jid]",
+ "desc": (
+ "Allow the provided JID (or the selected contact "
+ "in your roster), to see your presence."
+ ),
+ "shortdesc": "Allow a user your presence.",
+ "completion": completions.roster_barejids,
+ },
+ {
+ "name": "add",
+ "func": commands.add,
+ "usage": "<jid>",
+ "desc": (
+ "Add the specified JID to your roster, ask them to"
+ " allow you to see his presence, and allow them to"
+ " see your presence."
+ ),
+ "shortdesc": "Add a user to your roster.",
+ },
+ {
+ "name": "deny",
+ "func": commands.deny,
+ "usage": "[jid]",
+ "desc": (
+ "Deny your presence to the provided JID (or the "
+ "selected contact in your roster), who is asking"
+ "you to be in their roster."
+ ),
+ "shortdesc": "Deny a user your presence.",
+ "completion": completions.roster_barejids,
+ },
+ {
+ "name": "remove",
+ "func": commands.remove,
+ "usage": "[jid]",
+ "desc": (
+ "Remove the specified JID from your roster. This "
+ "will unsubscribe you from its presence, cancel "
+ "its subscription to yours, and remove the item "
+ "from your roster."
+ ),
+ "shortdesc": "Remove a user from your roster.",
+ "completion": completions.remove,
+ },
+ {
+ "name": "reconnect",
+ "func": commands.command_reconnect,
+ "usage": "[reconnect]",
+ "desc": (
+ "Disconnect from the remote server if you are "
+ "currently connected and then connect to it again."
+ ),
+ "shortdesc": "Disconnect and reconnect to the server.",
+ },
+ {
+ "name": "set",
+ "func": commands.set,
+ "usage": "[plugin|][section] <option> [value]",
+ "desc": (
+ "Set the value of an option in your configuration file."
+ " You can, for example, change your default nickname by "
+ "doing `/set default_nick toto` or your resource with `/set"
+ " resource blabla`. You can also set options in specific "
+ "sections with `/set bindings M-i ^i` or in specific plugin"
+ " with `/set mpd_client| host 127.0.0.1`. `toggle` can be "
+ "used as a special value to toggle a boolean option."
+ ),
+ "shortdesc": "Set the value of an option",
+ "completion": completions.set,
+ },
+ {
+ "name": "set_default",
+ "func": commands.set_default,
+ "usage": "[section] <option>",
+ "desc": (
+ "Set the default value of an option. For example, "
+ "`/set_default resource` will reset the resource "
+ "option. You can also reset options in specific "
+ "sections by doing `/set_default section option`."
+ ),
+ "shortdesc": "Set the default value of an option",
+ "completion": completions.set_default,
+ },
+ {
+ "name": "toggle",
+ "func": commands.toggle,
+ "usage": "<option>",
+ "desc": "Shortcut for /set <option> toggle",
+ "shortdesc": "Toggle an option",
+ "completion": completions.toggle,
+ },
+ {
+ "name": "theme",
+ "func": commands.theme,
+ "usage": "[theme name]",
+ "desc": (
+ "Reload the theme defined in the config file. If theme"
+ "_name is provided, set that theme before reloading it."
+ ),
+ "shortdesc": "Load a theme",
+ "completion": completions.theme,
+ },
+ {
+ "name": "list",
+ "func": commands.list,
+ "usage": "[server]",
+ "desc": "Get the list of public rooms" " on the specified server.",
+ "shortdesc": "List the rooms.",
+ "completion": completions.list,
+ },
+ {
+ "name": "message",
+ "func": commands.message,
+ "usage": "<jid> [optional message]",
+ "desc": (
+ "Open a conversation with the specified JID (even if it"
+ " is not in our roster), and send a message to it, if the "
+ "message is specified."
+ ),
+ "shortdesc": "Send a message",
+ "completion": completions.message,
+ },
+ {
+ "name": "version",
+ "func": commands.version,
+ "usage": "<jid>",
+ "desc": (
+ "Get the software version of the given JID (usually its"
+ " XMPP client and Operating System)."
+ ),
+ "shortdesc": "Get the software version of a JID.",
+ "completion": completions.version,
+ },
+ {
+ "name": "server_cycle",
+ "func": commands.server_cycle,
+ "usage": "[domain] [message]",
+ "desc": "Disconnect and reconnect in all the rooms in domain.",
+ "shortdesc": "Cycle a range of rooms",
+ "completion": completions.server_cycle,
+ },
+ {
+ "name": "bind",
+ "func": commands.bind,
+ "usage": "<key> <equ>",
+ "desc": (
+ "Bind a key to another key or to a “command”. For "
+ 'example "/bind ^H KEY_UP" makes Control + h do the'
+ " same same as the Up key."
+ ),
+ "completion": completions.bind,
+ "shortdesc": "Bind a key to another key.",
+ },
+ {
+ "name": "load",
+ "func": commands.load,
+ "usage": "<plugin> [<otherplugin> …]",
+ "shortdesc": "Load the specified plugin(s)",
+ "completion": plugin_manager.completion_load,
+ },
+ {
+ "name": "unload",
+ "func": commands.unload,
+ "usage": "<plugin> [<otherplugin> …]",
+ "shortdesc": "Unload the specified plugin(s)",
+ "completion": plugin_manager.completion_unload,
+ },
+ {
+ "name": "plugins",
+ "func": commands.plugins,
+ "shortdesc": "Show the plugins in use.",
+ },
+ {
+ "name": "presence",
+ "func": commands.presence,
+ "usage": "<JID> [type] [status]",
+ "desc": "Send a directed presence to <JID> and using"
+ " [type] and [status] if provided.",
+ "shortdesc": "Send a directed presence.",
+ "completion": completions.presence,
+ },
+ {
+ "name": "rawxml",
+ "func": commands.rawxml,
+ "usage": "<xml>",
+ "shortdesc": "Send a custom xml stanza.",
+ },
+ {
+ "name": "invite",
+ "func": commands.invite,
+ "usage": "<jid> <room> [reason]",
+ "desc": "Invite jid in room with reason.",
+ "shortdesc": "Invite someone in a room.",
+ "completion": completions.invite,
+ },
+ {
+ "name": "impromptu",
+ "func": commands.impromptu,
+ "usage": "<jid> [jid ...]",
+ "desc": "Invite specified JIDs into a newly created room.",
+ "shortdesc": "Invite specified JIDs into newly created room.",
+ "completion": completions.impromptu,
+ },
+ {
+ "name": "invitations",
+ "func": commands.invitations,
+ "shortdesc": "Show the pending invitations.",
+ },
+ {
+ "name": "bookmarks",
+ "func": commands.bookmarks,
+ "shortdesc": "Show the current bookmarks.",
+ },
+ {
+ "name": "remove_bookmark",
+ "func": commands.remove_bookmark,
+ "usage": "[jid]",
+ "desc": "Remove the specified bookmark, or the "
+ "bookmark on the current tab, if any.",
+ "shortdesc": "Remove a bookmark",
+ "completion": completions.remove_bookmark,
+ },
+ {
+ "name": "xml_tab",
+ "func": commands.xml_tab,
+ "shortdesc": "Open an XML tab.",
+ },
+ {
+ "name": "runkey",
+ "func": commands.runkey,
+ "usage": "<key>",
+ "shortdesc": "Execute the action defined for <key>.",
+ "completion": completions.runkey,
+ },
+ {
+ "name": "self",
+ "func": commands.self_,
+ "shortdesc": "Remind you of who you are.",
+ },
+ {
+ "name": "last_activity",
+ "func": commands.last_activity,
+ "usage": "<jid>",
+ "desc": "Informs you of the last activity of a JID.",
+ "shortdesc": "Get the activity of someone.",
+ "completion": completions.last_activity,
+ },
+ {
+ "name": "ad-hoc",
+ "func": commands.adhoc,
+ "usage": "<jid>",
+ "shortdesc": "List available ad-hoc commands on the given jid",
+ },
+ {
+ "name": "reload",
+ "func": commands.reload,
+ "shortdesc": "Reload the config. You can achieve the same by "
+ "sending SIGUSR1 to poezio.",
+ },
+ {
+ "name": "debug",
+ "func": commands.debug,
+ "usage": "[debug_filename]",
+ "shortdesc": "Enable or disable debug logging according to the "
+ "presence of [debug_filename].",
+ },
+ ]
diff --git a/poezio/core/commands.py b/poezio/core/commands.py
index 86df9a93..fe91ca67 100644
--- a/poezio/core/commands.py
+++ b/poezio/core/commands.py
@@ -2,38 +2,46 @@
Global commands which are to be linked to the Core class
"""
-import logging
-
-log = logging.getLogger(__name__)
-
import asyncio
-from xml.etree import cElementTree as ET
+from urllib.parse import unquote
+from xml.etree import ElementTree as ET
+from typing import List, Optional, Tuple
+import logging
-from slixmpp.exceptions import XMPPError
+from slixmpp import JID, InvalidJID
+from slixmpp.exceptions import XMPPError, IqError, IqTimeout
from slixmpp.xmlstream.xmlstream import NotConnectedError
from slixmpp.xmlstream.stanzabase import StanzaBase
from slixmpp.xmlstream.handler import Callback
from slixmpp.xmlstream.matcher import StanzaPath
-from poezio import common
-from poezio import pep
-from poezio import tabs
+from poezio import common, config as config_module, tabs, multiuserchat as muc
from poezio.bookmarks import Bookmark
-from poezio.common import safeJID
-from poezio.config import config, DEFAULT_CONFIG, options as config_opts
-from poezio import multiuserchat as muc
+from poezio.config import config, DEFAULT_CONFIG
+from poezio.contact import Contact, Resource
+from poezio.decorators import deny_anonymous
from poezio.plugin import PluginConfig
from poezio.roster import roster
from poezio.theming import dump_tuple, get_theme
from poezio.decorators import command_args_parser
-
from poezio.core.structs import Command, POSSIBLE_SHOW
+log = logging.getLogger(__name__)
+
+
class CommandCore:
def __init__(self, core):
self.core = core
+ @command_args_parser.ignored
+ def rotate_rooms_left(self, args=None):
+ self.core.rotate_rooms_left()
+
+ @command_args_parser.ignored
+ def rotate_rooms_right(self, args=None):
+ self.core.rotate_rooms_right()
+
@command_args_parser.quoted(0, 1)
def help(self, args):
"""
@@ -132,7 +140,7 @@ class CommandCore:
current.send_chat_state('inactive')
for tab in self.core.tabs:
if isinstance(tab, tabs.MucTab) and tab.joined:
- muc.change_show(self.core.xmpp, tab.name, tab.own_nick, show,
+ muc.change_show(self.core.xmpp, tab.jid, tab.own_nick, show,
msg)
if hasattr(tab, 'directed_presence'):
del tab.directed_presence
@@ -150,7 +158,7 @@ class CommandCore:
jid, ptype, status = args[0], args[1], args[2]
if jid == '.' and isinstance(self.core.tabs.current_tab, tabs.ChatTab):
- jid = self.core.tabs.current_tab.name
+ jid = self.core.tabs.current_tab.jid
if ptype == 'available':
ptype = None
try:
@@ -216,6 +224,20 @@ class CommandCore:
return
self.core.tabs.set_current_tab(match)
+ @command_args_parser.quoted(1)
+ def wup(self, args):
+ """
+ /wup <prefix of name>
+ """
+ if args is None:
+ return self.help('wup')
+
+ prefix = args[0]
+ _, match = self.core.tabs.find_by_unique_prefix(prefix)
+ if match is None:
+ return
+ self.core.tabs.set_current_tab(match)
+
@command_args_parser.quoted(2)
def move_tab(self, args):
"""
@@ -257,7 +279,7 @@ class CommandCore:
self.core.refresh_window()
@command_args_parser.quoted(0, 1)
- def list(self, args):
+ def list(self, args: List[str]) -> None:
"""
/list [server]
Opens a MucListTab containing the list of the room in the specified server
@@ -265,51 +287,76 @@ class CommandCore:
if args is None:
return self.help('list')
elif args:
- jid = safeJID(args[0])
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ return self.core.information('Invalid server %r' % jid, 'Error')
else:
if not isinstance(self.core.tabs.current_tab, tabs.MucTab):
return self.core.information('Please provide a server',
'Error')
- jid = safeJID(self.core.tabs.current_tab.name)
+ jid = self.core.tabs.current_tab.jid
+ if jid is None or not jid.domain:
+ return None
+ asyncio.create_task(
+ self._list_async(jid)
+ )
+
+ async def _list_async(self, jid: JID):
+ jid = JID(jid.domain)
list_tab = tabs.MucListTab(self.core, jid)
self.core.add_tab(list_tab, True)
- cb = list_tab.on_muc_list_item_received
- self.core.xmpp.plugin['xep_0030'].get_items(jid=jid, callback=cb)
+ iq = await self.core.xmpp.plugin['xep_0030'].get_items(jid=jid)
+ list_tab.on_muc_list_item_received(iq)
@command_args_parser.quoted(1)
- def version(self, args):
+ async def version(self, args):
"""
/version <jid>
"""
if args is None:
return self.help('version')
- jid = safeJID(args[0])
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ return self.core.information(
+ 'Invalid JID for /version: %s' % args[0],
+ 'Error'
+ )
if jid.resource or jid not in roster or not roster[jid].resources:
- self.core.xmpp.plugin['xep_0092'].get_version(
- jid, callback=self.core.handler.on_version_result)
+ iq = await self.core.xmpp.plugin['xep_0092'].get_version(jid)
+ self.core.handler.on_version_result(iq)
elif jid in roster:
for resource in roster[jid].resources:
- self.core.xmpp.plugin['xep_0092'].get_version(
- resource.jid, callback=self.core.handler.on_version_result)
+ iq = await self.core.xmpp.plugin['xep_0092'].get_version(
+ resource.jid
+ )
+ self.core.handler.on_version_result(iq)
def _empty_join(self):
tab = self.core.tabs.current_tab
if not isinstance(tab, (tabs.MucTab, tabs.PrivateTab)):
return (None, None)
- room = safeJID(tab.name).bare
+ room = tab.jid.bare
nick = tab.own_nick
return (room, nick)
- def _parse_join_jid(self, jid_string):
+ def _parse_join_jid(self, jid_string: str) -> Tuple[Optional[str], Optional[str]]:
# we try to join a server directly
- if jid_string.startswith('@'):
- server_root = True
- info = safeJID(jid_string[1:])
- else:
- info = safeJID(jid_string)
- server_root = False
+ server_root = False
+ if jid_string.startswith('xmpp:') and jid_string.endswith('?join'):
+ jid_string = unquote(jid_string[5:-5])
+ try:
+ if jid_string.startswith('@'):
+ server_root = True
+ info = JID(jid_string[1:])
+ else:
+ info = JID(jid_string)
+ server_root = False
+ except InvalidJID:
+ info = JID('')
- set_nick = ''
+ set_nick: Optional[str] = ''
if len(jid_string) > 1 and jid_string.startswith('/'):
set_nick = jid_string[1:]
elif info.resource:
@@ -321,7 +368,7 @@ class CommandCore:
if not isinstance(tab, tabs.MucTab):
room, set_nick = (None, None)
else:
- room = tab.name
+ room = tab.jid.bare
if not set_nick:
set_nick = tab.own_nick
else:
@@ -331,14 +378,12 @@ class CommandCore:
# check if the current room's name has a server
if room.find('@') == -1 and not server_root:
tab = self.core.tabs.current_tab
- if isinstance(tab, tabs.MucTab):
- if tab.name.find('@') != -1:
- domain = safeJID(tab.name).domain
- room += '@%s' % domain
+ if isinstance(tab, tabs.MucTab) and tab.jid.domain:
+ room += '@%s' % tab.jid.domain
return (room, set_nick)
@command_args_parser.quoted(0, 2)
- def join(self, args):
+ async def join(self, args):
"""
/join [room][/nick] [password]
"""
@@ -350,7 +395,11 @@ class CommandCore:
return # nothing was parsed
room = room.lower()
+
+ # Has the nick been specified explicitely when joining
+ config_nick = False
if nick == '':
+ config_nick = True
nick = self.core.own_nick
# a password is provided
@@ -377,10 +426,16 @@ class CommandCore:
tab.password = password
tab.join()
- if config.get('bookmark_on_join'):
- method = 'remote' if config.get(
+ if config.getbool('synchronise_open_rooms') and room not in self.core.bookmarks:
+ method = 'remote' if config.getbool(
'use_remote_bookmarks') else 'local'
- self._add_bookmark('%s/%s' % (room, nick), True, password, method)
+ await self._add_bookmark(
+ room=room,
+ nick=nick if not config_nick else None,
+ autojoin=True,
+ password=password,
+ method=method,
+ )
if tab == self.core.tabs.current_tab:
tab.refresh()
@@ -391,57 +446,99 @@ class CommandCore:
"""
/bookmark_local [room][/nick] [password]
"""
- if not args and not isinstance(self.core.tabs.current_tab,
- tabs.MucTab):
+ tab = self.core.tabs.current_tab
+ if not args and not isinstance(tab, tabs.MucTab):
return
+
+ room, nick = self._parse_join_jid(args[0] if args else '')
password = args[1] if len(args) > 1 else None
- jid = args[0] if args else None
- self._add_bookmark(jid, True, password, 'local')
+ if not room:
+ room = tab.jid.bare
+ if password is None and tab.password is not None:
+ password = tab.password
+
+ asyncio.create_task(
+ self._add_bookmark(
+ room=room,
+ nick=nick,
+ autojoin=True,
+ password=password,
+ method='local',
+ )
+ )
@command_args_parser.quoted(0, 3)
def bookmark(self, args):
"""
/bookmark [room][/nick] [autojoin] [password]
"""
- if not args and not isinstance(self.core.tabs.current_tab,
- tabs.MucTab):
+ tab = self.core.tabs.current_tab
+ if not args and not isinstance(tab, tabs.MucTab):
return
- jid = args[0] if args else ''
+ room, nick = self._parse_join_jid(args[0] if args else '')
password = args[2] if len(args) > 2 else None
- if not config.get('use_remote_bookmarks'):
- return self._add_bookmark(jid, True, password, 'local')
-
- if len(args) > 1:
- autojoin = False if args[1].lower() != 'true' else True
- else:
- autojoin = True
+ method = 'remote' if config.getbool('use_remote_bookmarks') else 'local'
+ autojoin = (method == 'local' or
+ (len(args) > 1 and args[1].lower() == 'true'))
+
+ if not room:
+ room = tab.jid.bare
+ if password is None and tab.password is not None:
+ password = tab.password
+
+ asyncio.create_task(
+ self._add_bookmark(room, nick, autojoin, password, method)
+ )
+
+ async def _add_bookmark(
+ self,
+ room: str,
+ nick: Optional[str],
+ autojoin: bool,
+ password: str,
+ method: str,
+ ) -> None:
+ '''
+ Adds a bookmark.
+
+ Args:
+ room: room Jid.
+ nick: optional nick. Will always be added to the bookmark if
+ specified. This takes precedence over tab.own_nick which takes
+ precedence over core.own_nick (global config).
+ autojoin: set the bookmark to join automatically.
+ password: room password.
+ method: 'local' or 'remote'.
+ '''
+
+
+ if room == '*':
+ return await self._add_wildcard_bookmarks(method)
+
+ # Once we found which room to bookmark, find corresponding tab if it
+ # exists and fill nickname if none was specified and not default.
+ tab = self.core.tabs.by_name_and_class(room, tabs.MucTab)
+ if tab and isinstance(tab, tabs.MucTab) and \
+ tab.joined and tab.own_nick != self.core.own_nick:
+ nick = nick or tab.own_nick
- self._add_bookmark(jid, autojoin, password, 'remote')
+ # Validate / Normalize
+ try:
+ if not nick:
+ jid = JID(room)
+ else:
+ jid = JID('{}/{}'.format(room, nick))
+ room = jid.bare
+ nick = jid.resource or None
+ except InvalidJID:
+ self.core.information(f'Invalid address for bookmark: {room}/{nick}', 'Error')
+ return
- def _add_bookmark(self, jid, autojoin, password, method):
- nick = None
- if not jid:
- tab = self.core.tabs.current_tab
- roomname = tab.name
- if tab.joined and tab.own_nick != self.core.own_nick:
- nick = tab.own_nick
- if password is None and tab.password is not None:
- password = tab.password
- elif jid == '*':
- return self._add_wildcard_bookmarks(method)
- else:
- info = safeJID(jid)
- roomname, nick = info.bare, info.resource
- if roomname == '':
- tab = self.core.tabs.current_tab
- if not isinstance(tab, tabs.MucTab):
- return
- roomname = tab.name
- bookmark = self.core.bookmarks[roomname]
+ bookmark = self.core.bookmarks[room]
if bookmark is None:
- bookmark = Bookmark(roomname)
+ bookmark = Bookmark(room)
self.core.bookmarks.append(bookmark)
bookmark.method = method
bookmark.autojoin = autojoin
@@ -451,15 +548,20 @@ class CommandCore:
bookmark.password = password
self.core.bookmarks.save_local()
- self.core.bookmarks.save_remote(self.core.xmpp,
- self.core.handler.on_bookmark_result)
-
- def _add_wildcard_bookmarks(self, method):
+ try:
+ result = await self.core.bookmarks.save_remote(
+ self.core.xmpp,
+ )
+ self.core.handler.on_bookmark_result(result)
+ except (IqError, IqTimeout) as iq:
+ self.core.handler.on_bookmark_result(iq)
+
+ async def _add_wildcard_bookmarks(self, method):
new_bookmarks = []
for tab in self.core.get_tabs(tabs.MucTab):
- bookmark = self.core.bookmarks[tab.name]
+ bookmark = self.core.bookmarks[tab.jid.bare]
if not bookmark:
- bookmark = Bookmark(tab.name, autojoin=True, method=method)
+ bookmark = Bookmark(tab.jid.bare, autojoin=True, method=method)
new_bookmarks.append(bookmark)
else:
bookmark.method = method
@@ -468,8 +570,11 @@ class CommandCore:
new_bookmarks.extend(self.core.bookmarks.bookmarks)
self.core.bookmarks.set(new_bookmarks)
self.core.bookmarks.save_local()
- self.core.bookmarks.save_remote(self.core.xmpp,
- self.core.handler.on_bookmark_result)
+ try:
+ iq = await self.core.bookmarks.save_remote(self.core.xmpp)
+ self.core.handler.on_bookmark_result(iq)
+ except IqError as iq:
+ self.core.handler.on_bookmark_result(iq)
@command_args_parser.ignored
def bookmarks(self):
@@ -486,33 +591,173 @@ class CommandCore:
@command_args_parser.quoted(0, 1)
def remove_bookmark(self, args):
"""/remove_bookmark [jid]"""
+ jid = None
+ if not args:
+ tab = self.core.tabs.current_tab
+ if isinstance(tab, tabs.MucTab):
+ jid = tab.jid.bare
+ else:
+ jid = args[0]
+
+ asyncio.create_task(
+ self._remove_bookmark_routine(jid)
+ )
- def cb(success):
- if success:
+ async def _remove_bookmark_routine(self, jid: str):
+ """Asynchronously remove a bookmark"""
+ if self.core.bookmarks[jid]:
+ self.core.bookmarks.remove(jid)
+ try:
+ await self.core.bookmarks.save(self.core.xmpp)
self.core.information('Bookmark deleted', 'Info')
- else:
+ except (IqError, IqTimeout):
self.core.information('Error while deleting the bookmark',
'Error')
+ else:
+ self.core.information('No bookmark to remove', 'Info')
+ @deny_anonymous
+ @command_args_parser.quoted(0, 1)
+ def accept(self, args):
+ """
+ Accept a JID. Authorize it AND subscribe to it
+ """
if not args:
tab = self.core.tabs.current_tab
- if isinstance(tab, tabs.MucTab) and self.core.bookmarks[tab.name]:
- self.core.bookmarks.remove(tab.name)
- self.core.bookmarks.save(self.core.xmpp, callback=cb)
+ RosterInfoTab = tabs.RosterInfoTab
+ if not isinstance(tab, RosterInfoTab):
+ return self.core.information('No JID specified', 'Error')
else:
- self.core.information('No bookmark to remove', 'Info')
+ item = tab.selected_row
+ if isinstance(item, Contact):
+ jid = item.bare_jid
+ else:
+ return self.core.information('No subscription to accept', 'Warning')
else:
- if self.core.bookmarks[args[0]]:
- self.core.bookmarks.remove(args[0])
- self.core.bookmarks.save(self.core.xmpp, callback=cb)
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ return self.core.information('Invalid JID for /accept: %s' % args[0], 'Error')
+ jid = JID(jid)
+ nodepart = jid.user
+ # crappy transports putting resources inside the node part
+ if '\\2f' in nodepart:
+ jid.user = nodepart.split('\\2f')[0]
+ contact = roster[jid]
+ if contact is None:
+ return self.core.information('No subscription to accept', 'Warning')
+ contact.pending_in = False
+ roster.modified()
+ self.core.xmpp.send_presence(pto=jid, ptype='subscribed')
+ self.core.xmpp.client_roster.send_last_presence()
+ if contact.subscription in ('from',
+ 'none') and not contact.pending_out:
+ self.core.xmpp.send_presence(
+ pto=jid, ptype='subscribe', pnick=self.core.own_nick)
+ self.core.information('%s is now authorized' % jid, 'Roster')
+
+ @deny_anonymous
+ @command_args_parser.quoted(1)
+ def add(self, args):
+ """
+ Add the specified JID to the roster, and automatically
+ accept the reverse subscription
+ """
+ if args is None:
+ tab = self.core.tabs.current_tab
+ ConversationTab = tabs.ConversationTab
+ if isinstance(tab, ConversationTab):
+ jid = tab.general_jid
+ if jid in roster and roster[jid].subscription in ('to', 'both'):
+ return self.core.information('Already subscribed.', 'Roster')
+ roster.add(jid)
+ roster.modified()
+ return self.core.information('%s was added to the roster' % jid, 'Roster')
else:
- self.core.information('No bookmark to remove', 'Info')
+ return self.core.information('No JID specified', 'Error')
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ return self.core.information('Invalid JID for /add: %s' % args[0], 'Error')
+ if jid in roster and roster[jid].subscription in ('to', 'both'):
+ return self.core.information('Already subscribed.', 'Roster')
+ roster.add(jid)
+ roster.modified()
+ self.core.information('%s was added to the roster' % jid, 'Roster')
+
+ @deny_anonymous
+ @command_args_parser.quoted(0, 1)
+ def deny(self, args):
+ """
+ /deny [jid]
+ Denies a JID from our roster
+ """
+ jid = None
+ if not args:
+ tab = self.core.tabs.current_tab
+ if isinstance(tab, tabs.RosterInfoTab):
+ item = tab.roster_win.selected_row
+ if isinstance(item, Contact):
+ jid = item.bare_jid
+ else:
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ return self.core.information('Invalid JID for /deny: %s' % args[0], 'Error')
+ if jid not in [jid for jid in roster.jids()]:
+ jid = None
+ if jid is None:
+ self.core.information('No subscription to deny', 'Warning')
+ return
+
+ contact = roster[jid]
+ if contact:
+ contact.unauthorize()
+ self.core.information('Subscription to %s was revoked' % jid,
+ 'Roster')
+
+ @deny_anonymous
+ @command_args_parser.quoted(0, 1)
+ def remove(self, args):
+ """
+ Remove the specified JID from the roster. i.e.: unsubscribe
+ from its presence, and cancel its subscription to our.
+ """
+ jid = None
+ if args:
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ return self.core.information('Invalid JID for /remove: %s' % args[0], 'Error')
+ else:
+ tab = self.core.tabs.current_tab
+ if isinstance(tab, tabs.RosterInfoTab):
+ item = tab.roster_win.selected_row
+ if isinstance(item, Contact):
+ jid = item.bare_jid
+ if jid is None:
+ self.core.information('No roster item to remove', 'Error')
+ return
+ roster.remove(jid)
+ del roster[jid]
+
+ @command_args_parser.ignored
+ def command_reconnect(self):
+ """
+ /reconnect
+ """
+ if self.core.xmpp.is_connected():
+ self.core.disconnect(reconnect=True)
+ else:
+ self.core.xmpp.start()
@command_args_parser.quoted(0, 3)
def set(self, args):
"""
/set [module|][section] <option> [value]
"""
+ if len(args) == 3 and args[1] == '=':
+ args = [args[0], args[2]]
if args is None or len(args) == 0:
config_dict = config.to_dict()
lines = []
@@ -525,6 +770,9 @@ class CommandCore:
theme.COLOR_INFORMATION_TEXT),
})
for option_name, option_value in section.items():
+ if isinstance(option_name, str) and \
+ 'password' in option_name and 'eval_password' not in option_name:
+ option_value = '********'
lines.append(
'%s\x19%s}=\x19o%s' %
(option_name, dump_tuple(
@@ -533,6 +781,9 @@ class CommandCore:
elif len(args) == 1:
option = args[0]
value = config.get(option)
+ if isinstance(option, str) and \
+ 'password' in option and 'eval_password' not in option and value is not None:
+ value = '********'
if value is None and '=' in option:
args = option.split('=', 1)
info = ('%s=%s' % (option, value), 'Info')
@@ -553,7 +804,8 @@ class CommandCore:
info = ('%s=%s' % (option, value), 'Info')
else:
possible_section = args[0]
- if config.has_section(possible_section):
+ if (not config.has_option(section='Poezio', option=possible_section)
+ and config.has_section(possible_section)):
section = possible_section
option = args[1]
value = config.get(option, section=section)
@@ -580,7 +832,7 @@ class CommandCore:
info = plugin_config.set_and_save(option, value, section)
else:
if args[0] == '.':
- name = safeJID(self.core.tabs.current_tab.name).bare
+ name = self.core.tabs.current_tab.jid.bare
if not name:
self.core.information(
'Invalid tab to use the "." argument.', 'Error')
@@ -632,137 +884,88 @@ class CommandCore:
def server_cycle(self, args):
"""
Do a /cycle on each room of the given server.
- If none, do it on the current tab
+ If none, do it on the server of the current tab
"""
tab = self.core.tabs.current_tab
message = ""
if args:
- domain = args[0]
+ try:
+ domain = JID(args[0]).domain
+ except InvalidJID:
+ return self.core.information(
+ "Invalid server domain: %s" % args[0],
+ "Error"
+ )
if len(args) == 2:
message = args[1]
else:
if isinstance(tab, tabs.MucTab):
- domain = safeJID(tab.name).domain
+ domain = tab.jid.domain
else:
return self.core.information("No server specified", "Error")
for tab in self.core.get_tabs(tabs.MucTab):
- if tab.name.endswith(domain):
+ if tab.jid.domain == domain:
tab.leave_room(message)
tab.join()
@command_args_parser.quoted(1)
- def last_activity(self, args):
+ async def last_activity(self, args):
"""
/last_activity <jid>
"""
- def callback(iq):
- "Callback for the last activity"
- if iq['type'] != 'result':
- if iq['error']['type'] == 'auth':
- self.core.information(
- 'You are not allowed to see the '
- 'activity of this contact.', 'Error')
- else:
- self.core.information('Error retrieving the activity',
- 'Error')
- return
- seconds = iq['last_activity']['seconds']
- status = iq['last_activity']['status']
- from_ = iq['from']
- if not safeJID(from_).user:
- msg = 'The uptime of %s is %s.' % (
- from_, common.parse_secs_to_str(seconds))
- else:
- msg = 'The last activity of %s was %s ago%s' % (
- from_, common.parse_secs_to_str(seconds),
- (' and his/her last status was %s' % status)
- if status else '')
- self.core.information(msg, 'Info')
-
if args is None:
return self.help('last_activity')
- jid = safeJID(args[0])
- self.core.xmpp.plugin['xep_0012'].get_last_activity(
- jid, callback=callback)
-
- @command_args_parser.quoted(0, 2)
- def mood(self, args):
- """
- /mood [<mood> [text]]
- """
- if not args:
- return self.core.xmpp.plugin['xep_0107'].stop()
-
- mood = args[0]
- if mood not in pep.MOODS:
- return self.core.information(
- '%s is not a correct value for a mood.' % mood, 'Error')
- if len(args) == 2:
- text = args[1]
- else:
- text = None
- self.core.xmpp.plugin['xep_0107'].publish_mood(
- mood, text, callback=dumb_callback)
-
- @command_args_parser.quoted(0, 3)
- def activity(self, args):
- """
- /activity [<general> [specific] [text]]
- """
- length = len(args)
- if not length:
- return self.core.xmpp.plugin['xep_0108'].stop()
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ return self.core.information('Invalid JID for /last_activity: %s' % args[0], 'Error')
- general = args[0]
- if general not in pep.ACTIVITIES:
- return self.core.information(
- '%s is not a correct value for an activity' % general, 'Error')
- specific = None
- text = None
- if length == 2:
- if args[1] in pep.ACTIVITIES[general]:
- specific = args[1]
+ try:
+ iq = await self.core.xmpp.plugin['xep_0012'].get_last_activity(jid)
+ except IqError as error:
+ if error.etype == 'auth':
+ msg = 'You are not allowed to see the activity of %s' % jid
else:
- text = args[1]
- elif length == 3:
- specific = args[1]
- text = args[2]
- if specific and specific not in pep.ACTIVITIES[general]:
- return self.core.information(
- '%s is not a correct value '
- 'for an activity' % specific, 'Error')
- self.core.xmpp.plugin['xep_0108'].publish_activity(
- general, specific, text, callback=dumb_callback)
-
- @command_args_parser.quoted(0, 2)
- def gaming(self, args):
- """
- /gaming [<game name> [server address]]
- """
- if not args:
- return self.core.xmpp.plugin['xep_0196'].stop()
-
- name = args[0]
- if len(args) > 1:
- address = args[1]
+ msg = 'Error retrieving the activity of %s: %s' % (jid, error)
+ return self.core.information(msg, 'Error')
+ except IqTimeout:
+ return self.core.information('Timeout while retrieving the last activity of %s' % jid, 'Error')
+
+ seconds = iq['last_activity']['seconds']
+ status = iq['last_activity']['status']
+ from_ = iq['from']
+ if not from_.user:
+ msg = 'The uptime of %s is %s.' % (
+ from_, common.parse_secs_to_str(seconds))
else:
- address = None
- return self.core.xmpp.plugin['xep_0196'].publish_gaming(
- name=name, server_address=address, callback=dumb_callback)
+ msg = 'The last activity of %s was %s ago%s' % (
+ from_, common.parse_secs_to_str(seconds),
+ (' and their last status was %s' % status)
+ if status else '')
+ self.core.information(msg, 'Info')
@command_args_parser.quoted(2, 1, [None])
- def invite(self, args):
+ async def invite(self, args):
"""/invite <to> <room> [reason]"""
if args is None:
return self.help('invite')
reason = args[2]
- to = safeJID(args[0])
- room = safeJID(args[1]).bare
- self.core.invite(to.full, room, reason=reason)
- self.core.information('Invited %s to %s' % (to.bare, room), 'Info')
+ try:
+ to = JID(args[0])
+ except InvalidJID:
+ self.core.information('Invalid JID specified for invite: %s' % args[0], 'Error')
+ return None
+ try:
+ room = JID(args[1]).bare
+ except InvalidJID:
+ self.core.information('Invalid room JID specified to invite: %s' % args[1], 'Error')
+ return None
+ result = await self.core.invite(to.full, room, reason=reason)
+ if result:
+ self.core.information('Invited %s to %s' % (to.bare, room), 'Info')
@command_args_parser.quoted(1, 0)
def impromptu(self, args: str) -> None:
@@ -777,17 +980,23 @@ class CommandCore:
jids.add(current_tab.general_jid)
for jid in common.shell_split(' '.join(args)):
- jids.add(safeJID(jid).bare)
+ try:
+ bare = JID(jid).bare
+ except InvalidJID:
+ return self.core.information('Invalid JID for /impromptu: %s' % args[0], 'Error')
+ jids.add(JID(bare))
- asyncio.ensure_future(self.core.impromptu(jids))
- self.core.information('Invited %s to a random room' % (' '.join(jids)), 'Info')
+ asyncio.create_task(self.core.impromptu(jids))
@command_args_parser.quoted(1, 1, [''])
def decline(self, args):
"""/decline <room@server.tld> [reason]"""
if args is None:
return self.help('decline')
- jid = safeJID(args[0])
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ return self.core.information('Invalid JID for /decline: %s' % args[0], 'Error')
if jid.bare not in self.core.pending_invites:
return
reason = args[1]
@@ -795,21 +1004,135 @@ class CommandCore:
self.core.xmpp.plugin['xep_0045'].decline_invite(
jid.bare, self.core.pending_invites[jid.bare], reason)
+ @command_args_parser.quoted(0, 1)
+ def block(self, args: List[str]) -> None:
+ """
+ /block [jid]
+
+ If a JID is specified, use it. Otherwise if in RosterInfoTab, use the
+ selected JID, if in ConversationsTab use the Tab's JID.
+ """
+
+ jid = None
+ if args:
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ self.core.information('Invalid JID %s' % args, 'Error')
+ return
+
+ current_tab = self.core.tabs.current_tab
+ if jid is None:
+ if isinstance(current_tab, tabs.RosterInfoTab):
+ roster_win = self.core.tabs.by_name_and_class(
+ 'Roster',
+ tabs.RosterInfoTab,
+ )
+ item = roster_win.selected_row
+ if isinstance(item, Contact):
+ jid = item.bare_jid
+ elif isinstance(item, Resource):
+ jid = JID(item.jid)
+
+ chattabs = (
+ tabs.ConversationTab,
+ tabs.StaticConversationTab,
+ tabs.DynamicConversationTab,
+ )
+ if isinstance(current_tab, chattabs):
+ jid = JID(current_tab.jid.bare)
+
+ if jid is None:
+ self.core.information('No specified JID to block', 'Error')
+ else:
+ asyncio.create_task(self._block_async(jid))
+
+ async def _block_async(self, jid: JID):
+ """Block a JID, asynchronously"""
+ try:
+ await self.core.xmpp.plugin['xep_0191'].block(jid)
+ return self.core.information('Blocked %s.' % jid, 'Info')
+ except (IqError, IqTimeout):
+ return self.core.information(
+ 'Could not block %s.' % jid, 'Error',
+ )
+
+ @command_args_parser.quoted(0, 1)
+ def unblock(self, args: List[str]) -> None:
+ """
+ /unblock [jid]
+ """
+
+ item = self.core.tabs.by_name_and_class(
+ 'Roster',
+ tabs.RosterInfoTab,
+ ).selected_row
+
+ jid = None
+ if args:
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ self.core.information('Invalid JID %s' % args, 'Error')
+ return
+
+ current_tab = self.core.tabs.current_tab
+ if jid is None:
+ if isinstance(current_tab, tabs.RosterInfoTab):
+ roster_win = self.core.tabs.by_name_and_class(
+ 'Roster',
+ tabs.RosterInfoTab,
+ )
+ item = roster_win.selected_row
+ if isinstance(item, Contact):
+ jid = item.bare_jid
+ elif isinstance(item, Resource):
+ jid = JID(item.jid)
+
+ chattabs = (
+ tabs.ConversationTab,
+ tabs.StaticConversationTab,
+ tabs.DynamicConversationTab,
+ )
+ if isinstance(current_tab, chattabs):
+ jid = JID(current_tab.jid.bare)
+
+ if jid is not None:
+ asyncio.create_task(
+ self._unblock_async(jid)
+ )
+ else:
+ self.core.information('No specified JID to unblock', 'Error')
+
+ async def _unblock_async(self, jid: JID):
+ """Unblock a JID, asynchrously"""
+ try:
+ await self.core.xmpp.plugin['xep_0191'].unblock(jid)
+ return self.core.information('Unblocked %s.' % jid, 'Info')
+ except (IqError, IqTimeout):
+ return self.core.information('Could not unblock the contact.',
+ 'Error')
### Commands without a completion in this class ###
@command_args_parser.ignored
def invitations(self):
"""/invitations"""
- build = ""
- for invite in self.core.pending_invites:
- build += "%s by %s" % (
- invite, safeJID(self.core.pending_invites[invite]).bare)
- if self.core.pending_invites:
- build = "You are invited to the following rooms:\n" + build
+ build = []
+ for room, inviter in self.core.pending_invites.items():
+ try:
+ bare = JID(inviter).bare
+ except InvalidJID:
+ self.core.information(
+ f'Invalid JID found in /invitations: {inviter}',
+ 'Error'
+ )
+ build.append(f'{room} by {bare}')
+ if build:
+ message = 'You are invited to the following rooms:\n' + ','.join(build)
else:
- build = "You do not have any pending invitations."
- self.core.information(build, 'Info')
+ message = 'You do not have any pending invitations.'
+ self.core.information(message, 'Info')
@command_args_parser.quoted(0, 1, [None])
def quit(self, args):
@@ -821,32 +1144,51 @@ class CommandCore:
return
msg = args[0]
- if config.get('enable_user_mood'):
- self.core.xmpp.plugin['xep_0107'].stop()
- if config.get('enable_user_activity'):
- self.core.xmpp.plugin['xep_0108'].stop()
- if config.get('enable_user_gaming'):
- self.core.xmpp.plugin['xep_0196'].stop()
self.core.save_config()
self.core.plugin_manager.disable_plugins()
- self.core.disconnect(msg)
self.core.xmpp.add_event_handler(
"disconnected", self.core.exit, disposable=True)
+ self.core.disconnect(msg)
- @command_args_parser.quoted(0, 1, [''])
- def destroy_room(self, args):
+ @command_args_parser.quoted(0, 3, ['', '', ''])
+ def destroy_room(self, args: List[str]):
"""
- /destroy_room [JID]
+ /destroy_room [JID [reason [alternative room JID]]]
"""
- room = safeJID(args[0]).bare
- if room:
- muc.destroy_room(self.core.xmpp, room)
- elif isinstance(self.core.tabs.current_tab,
- tabs.MucTab) and not args[0]:
- muc.destroy_room(self.core.xmpp,
- self.core.tabs.current_tab.general_jid)
+ async def do_destroy(room: JID, reason: str, altroom: Optional[JID]):
+ try:
+ await self.core.xmpp['xep_0045'].destroy(room, reason, altroom)
+ except (IqError, IqTimeout) as e:
+ self.core.information('Unable to destroy room %s: %s' % (room, e), 'Info')
+ else:
+ self.core.information('Room %s destroyed' % room, 'Info')
+
+ room: Optional[JID]
+ if not args[0] and isinstance(self.core.tabs.current_tab, tabs.MucTab):
+ room = self.core.tabs.current_tab.general_jid
else:
- self.core.information('Invalid JID: "%s"' % args[0], 'Error')
+ try:
+ room = JID(args[0])
+ except InvalidJID:
+ room = None
+ else:
+ if room.resource:
+ room = None
+
+ if room is None:
+ self.core.information('Invalid room JID: "%s"' % args[0], 'Error')
+ return
+
+ reason = args[1]
+ altroom = None
+ if args[2]:
+ try:
+ altroom = JID(args[2])
+ except InvalidJID:
+ self.core.information('Invalid alternative room JID: "%s"' % args[2], 'Error')
+ return
+
+ asyncio.create_task(do_destroy(room, reason, altroom))
@command_args_parser.quoted(1, 1, [''])
def bind(self, args):
@@ -903,11 +1245,17 @@ class CommandCore:
exc_info=True)
@command_args_parser.quoted(1, 256)
- def load(self, args):
+ def load(self, args: List[str]) -> None:
"""
/load <plugin> [<otherplugin> …]
# TODO: being able to load more than 256 plugins at once, hihi.
"""
+
+ usage = '/load <plugin> [<otherplugin> …]'
+ if not args:
+ self.core.information(usage, 'Error')
+ return
+
for plugin in args:
self.core.plugin_manager.load(plugin)
@@ -916,6 +1264,12 @@ class CommandCore:
"""
/unload <plugin> [<otherplugin> …]
"""
+
+ usage = '/unload <plugin> [<otherplugin> …]'
+ if not args:
+ self.core.information(usage, 'Error')
+ return
+
for plugin in args:
self.core.plugin_manager.unload(plugin)
@@ -929,20 +1283,23 @@ class CommandCore:
list(self.core.plugin_manager.plugins.keys())), 'Info')
@command_args_parser.quoted(1, 1)
- def message(self, args):
+ async def message(self, args):
"""
/message <jid> [message]
"""
if args is None:
return self.help('message')
- jid = safeJID(args[0])
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ return self.core.information('Invalid JID for /message: %s' % args[0], 'Error')
if not jid.user and not jid.domain and not jid.resource:
return self.core.information('Invalid JID.', 'Error')
tab = self.core.get_conversation_by_jid(
jid.full, False, fallback_barejid=False)
muc = self.core.tabs.by_name_and_class(jid.bare, tabs.MucTab)
if not tab and not muc:
- tab = self.core.open_conversation_window(jid.full, focus=True)
+ tab = self.core.open_conversation_window(JID(jid.full), focus=True)
elif muc:
if jid.resource:
tab = self.core.tabs.by_name_and_class(jid.full,
@@ -956,7 +1313,7 @@ class CommandCore:
else:
self.core.focus_tab(tab)
if len(args) == 2:
- tab.command_say(args[1])
+ await tab.command_say(args[1])
@command_args_parser.ignored
def xml_tab(self):
@@ -968,15 +1325,23 @@ class CommandCore:
self.core.xml_tab = tab
@command_args_parser.quoted(1)
- def adhoc(self, args):
+ async def adhoc(self, args):
if not args:
return self.help('ad-hoc')
- jid = safeJID(args[0])
+ try:
+ jid = JID(args[0])
+ except InvalidJID:
+ return self.core.information(
+ 'Invalid JID for ad-hoc command: %s' % args[0],
+ 'Error',
+ )
list_tab = tabs.AdhocCommandsListTab(self.core, jid)
self.core.add_tab(list_tab, True)
- cb = list_tab.on_list_received
- self.core.xmpp.plugin['xep_0050'].get_commands(
- jid=jid, local=False, callback=cb)
+ iq = await self.core.xmpp.plugin['xep_0050'].get_commands(
+ jid=jid,
+ local=False
+ )
+ list_tab.on_list_received(iq)
@command_args_parser.ignored
def self_(self):
@@ -990,7 +1355,7 @@ class CommandCore:
info = ('Your JID is %s\nYour current status is "%s" (%s)'
'\nYour default nickname is %s\nYou are running poezio %s' %
(jid, message if message else '', show
- if show else 'available', nick, config_opts.version))
+ if show else 'available', nick, self.core.custom_version))
self.core.information(info, 'Info')
@command_args_parser.ignored
@@ -1000,6 +1365,16 @@ class CommandCore:
"""
self.core.reload_config()
+ @command_args_parser.raw
+ def debug(self, args):
+ """/debug [filename]"""
+ if not args.strip():
+ config_module.setup_logging('')
+ self.core.information('Debug logging disabled!', 'Info')
+ elif args:
+ config_module.setup_logging(args)
+ self.core.information(f'Debug logging to {args} enabled!', 'Info')
+
def dumb_callback(*args, **kwargs):
"mock callback"
diff --git a/poezio/core/completions.py b/poezio/core/completions.py
index 87bb2d47..084910a2 100644
--- a/poezio/core/completions.py
+++ b/poezio/core/completions.py
@@ -2,23 +2,23 @@
Completions for the global commands
"""
import logging
-
-log = logging.getLogger(__name__)
-
import os
-from pathlib import Path
from functools import reduce
+from pathlib import Path
+from typing import List, Optional
+
+from slixmpp import JID, InvalidJID
from poezio import common
-from poezio import pep
from poezio import tabs
from poezio import xdg
-from poezio.common import safeJID
from poezio.config import config
from poezio.roster import roster
from poezio.core.structs import POSSIBLE_SHOW, Completion
+log = logging.getLogger(__name__)
+
class CompletionCore:
def __init__(self, core):
@@ -41,6 +41,19 @@ class CompletionCore:
' ',
quotify=False)
+ def roster_barejids(self, the_input):
+ """Complete roster bare jids"""
+ jids = sorted(
+ str(contact.bare_jid) for contact in roster.contacts.values()
+ if contact.pending_in
+ )
+ return Completion(the_input.new_completion, jids, 1, '', quotify=False)
+
+ def remove(self, the_input):
+ """Completion for /remove"""
+ jids = [jid for jid in roster.jids()]
+ return Completion(the_input.auto_completion, jids, '', quotify=False)
+
def presence(self, the_input):
"""
Completion of /presence
@@ -67,7 +80,7 @@ class CompletionCore:
def theme(self, the_input):
""" Completion for /theme"""
- themes_dir = config.get('themes_dir')
+ themes_dir = config.getstr('themes_dir')
themes_dir = Path(themes_dir).expanduser(
) if themes_dir else xdg.DATA_HOME / 'themes'
try:
@@ -109,9 +122,12 @@ class CompletionCore:
return False
if len(args) == 1:
args.append('')
- jid = safeJID(args[1])
- if args[1].endswith('@') and not jid.user and not jid.server:
- jid.user = args[1][:-1]
+ try:
+ jid = JID(args[1])
+ except InvalidJID:
+ jid = JID('')
+ if args[1].endswith('@'):
+ jid.user = args[1][:-1]
relevant_rooms = []
relevant_rooms.extend(sorted(self.core.pending_invites.keys()))
@@ -134,7 +150,8 @@ class CompletionCore:
for tab in self.core.get_tabs(tabs.MucTab):
if tab.joined:
serv_list.append(
- '%s@%s' % (jid.user, safeJID(tab.name).host))
+ '%s@%s' % (jid.user, tab.general_jid.server)
+ )
serv_list.extend(relevant_rooms)
return Completion(
the_input.new_completion, serv_list, 1, quotify=True)
@@ -161,8 +178,8 @@ class CompletionCore:
muc_serv_list = []
for tab in self.core.get_tabs(
tabs.MucTab): # TODO, also from an history
- if tab.name not in muc_serv_list:
- muc_serv_list.append(safeJID(tab.name).server)
+ if tab.jid.server not in muc_serv_list:
+ muc_serv_list.append(tab.jid.server)
if muc_serv_list:
return Completion(
the_input.new_completion, muc_serv_list, 1, quotify=False)
@@ -198,14 +215,13 @@ class CompletionCore:
if len(args) == 1:
args.append('')
- jid = safeJID(args[1])
-
- if jid.server and (jid.resource or jid.full.endswith('/')):
+ try:
+ jid = JID(args[1])
tab = self.core.tabs.by_name_and_class(jid.bare, tabs.MucTab)
nicks = [tab.own_nick] if tab else []
default = os.environ.get('USER') if os.environ.get(
'USER') else 'poezio'
- nick = config.get('default_nick')
+ nick = config.getstr('default_nick')
if not nick:
if default not in nicks:
nicks.append(default)
@@ -215,6 +231,8 @@ class CompletionCore:
jids_list = ['%s/%s' % (jid.bare, nick) for nick in nicks]
return Completion(
the_input.new_completion, jids_list, 1, quotify=True)
+ except InvalidJID:
+ pass
muc_list = [tab.name for tab in self.core.get_tabs(tabs.MucTab)]
muc_list.sort()
muc_list.append('*')
@@ -284,7 +302,7 @@ class CompletionCore:
rooms = []
for tab in self.core.get_tabs(tabs.MucTab):
if tab.joined:
- rooms.append(tab.name)
+ rooms.append(tab.jid.bare)
rooms.sort()
return Completion(
the_input.new_completion, rooms, n, '', quotify=True)
@@ -302,33 +320,6 @@ class CompletionCore:
comp = sorted(onlines) + sorted(offlines)
return Completion(the_input.new_completion, comp, n, quotify=True)
- def activity(self, the_input):
- """Completion for /activity"""
- n = the_input.get_argument_position(quoted=True)
- args = common.shell_split(the_input.text)
- if n == 1:
- return Completion(
- the_input.new_completion,
- sorted(pep.ACTIVITIES.keys()),
- n,
- quotify=True)
- elif n == 2:
- if args[1] in pep.ACTIVITIES:
- l = list(pep.ACTIVITIES[args[1]])
- l.remove('category')
- l.sort()
- return Completion(the_input.new_completion, l, n, quotify=True)
-
- def mood(self, the_input):
- """Completion for /mood"""
- n = the_input.get_argument_position(quoted=True)
- if n == 1:
- return Completion(
- the_input.new_completion,
- sorted(pep.MOODS.keys()),
- 1,
- quotify=True)
-
def last_activity(self, the_input):
"""
Completion for /last_activity <jid>
@@ -346,8 +337,7 @@ class CompletionCore:
"""Completion for /server_cycle"""
serv_list = set()
for tab in self.core.get_tabs(tabs.MucTab):
- serv = safeJID(tab.name).server
- serv_list.add(serv)
+ serv_list.add(tab.jid.server)
return Completion(the_input.new_completion, sorted(serv_list), 1, ' ')
def set(self, the_input):
@@ -442,14 +432,13 @@ class CompletionCore:
return False
if len(args) == 1:
args.append('')
- jid = safeJID(args[1])
-
- if jid.server and (jid.resource or jid.full.endswith('/')):
+ try:
+ jid = JID(args[1])
tab = self.core.tabs.by_name_and_class(jid.bare, tabs.MucTab)
nicks = [tab.own_nick] if tab else []
default = os.environ.get('USER') if os.environ.get(
'USER') else 'poezio'
- nick = config.get('default_nick')
+ nick = config.getstr('default_nick')
if not nick:
if default not in nicks:
nicks.append(default)
@@ -459,6 +448,45 @@ class CompletionCore:
jids_list = ['%s/%s' % (jid.bare, nick) for nick in nicks]
return Completion(
the_input.new_completion, jids_list, 1, quotify=True)
+ except InvalidJID:
+ pass
muc_list = [tab.name for tab in self.core.get_tabs(tabs.MucTab)]
muc_list.append('*')
return Completion(the_input.new_completion, muc_list, 1, quotify=True)
+
+ def block(self, the_input) -> Optional[Completion]:
+ """
+ Completion for /block
+ """
+ if the_input.get_argument_position() == 1:
+
+ current_tab = self.core.tabs.current_tab
+ chattabs = (
+ tabs.ConversationTab,
+ tabs.StaticConversationTab,
+ tabs.DynamicConversationTab,
+ )
+ tabjid: List[str] = []
+ if isinstance(current_tab, chattabs):
+ tabjid = [current_tab.jid.bare]
+
+ jids = [str(i) for i in roster.jids()]
+ jids += tabjid
+ return Completion(
+ the_input.new_completion, jids, 1, '', quotify=False)
+ return None
+
+ def unblock(self, the_input) -> Optional[Completion]:
+ """
+ Completion for /unblock
+ """
+
+ def on_result(iq):
+ if iq['type'] == 'error':
+ return None
+ l = sorted(str(item) for item in iq['blocklist']['items'])
+ return Completion(the_input.new_completion, l, 1, quotify=False)
+
+ if the_input.get_argument_position():
+ self.core.xmpp.plugin['xep_0191'].get_blocked(callback=on_result)
+ return None
diff --git a/poezio/core/core.py b/poezio/core/core.py
index 9651a73b..6582402d 100644
--- a/poezio/core/core.py
+++ b/poezio/core/core.py
@@ -5,6 +5,8 @@ of everything; it also contains global commands, completions and event
handlers but those are defined in submodules in order to avoir cluttering
this file.
"""
+from __future__ import annotations
+
import logging
import asyncio
import curses
@@ -13,29 +15,47 @@ import pipes
import sys
import shutil
import time
-import uuid
from collections import defaultdict
-from typing import Callable, Dict, List, Optional, Set, Tuple, Type
-from xml.etree import cElementTree as ET
-from functools import partial
-
-from slixmpp import JID
+from typing import (
+ Any,
+ Callable,
+ Dict,
+ List,
+ Optional,
+ Set,
+ Tuple,
+ Type,
+ TypeVar,
+ TYPE_CHECKING,
+)
+from xml.etree import ElementTree as ET
+from pathlib import Path
+
+from slixmpp import Iq, JID, InvalidJID
from slixmpp.util import FileSystemPerJidCache
+from slixmpp.xmlstream.xmlstream import InvalidCABundle
from slixmpp.xmlstream.handler import Callback
-from slixmpp.exceptions import IqError, IqTimeout
+from slixmpp.exceptions import IqError, IqTimeout, XMPPError
from poezio import connection
from poezio import decorators
from poezio import events
-from poezio import multiuserchat as muc
-from poezio import tabs
from poezio import theming
from poezio import timed_events
from poezio import windows
-
-from poezio.bookmarks import BookmarkList
-from poezio.common import safeJID
-from poezio.config import config, firstrun
+from poezio import utils
+
+from poezio.bookmarks import (
+ BookmarkList,
+ Bookmark,
+)
+from poezio.tabs import (
+ Tab, XMLTab, ChatTab, ConversationTab, PrivateTab, MucTab, OneToOneTab,
+ GapTab, RosterInfoTab, StaticConversationTab, DataFormsTab,
+ DynamicConversationTab, STATE_PRIORITY
+)
+from poezio.common import get_error_message
+from poezio.config import config
from poezio.contact import Contact, Resource
from poezio.daemon import Executor
from poezio.fifo import Fifo
@@ -46,45 +66,92 @@ from poezio.size_manager import SizeManager
from poezio.user import User
from poezio.text_buffer import TextBuffer
from poezio.timed_events import DelayedEvent
-from poezio.theming import get_theme
from poezio import keyboard, xdg
from poezio.core.completions import CompletionCore
from poezio.core.tabs import Tabs
from poezio.core.commands import CommandCore
+from poezio.core.command_defs import get_commands
from poezio.core.handlers import HandlerCore
-from poezio.core.structs import POSSIBLE_SHOW, DEPRECATED_ERRORS, \
- ERROR_AND_STATUS_CODES, Command, Status
+from poezio.core.structs import (
+ Command,
+ Status,
+ POSSIBLE_SHOW,
+)
+
+from poezio.ui.types import (
+ PersistentInfoMessage,
+ UIMessage,
+)
+
+if TYPE_CHECKING:
+ from _curses import _CursesWindow # pylint: disable=no-name-in-module
log = logging.getLogger(__name__)
+T = TypeVar('T', bound=Tab)
+
class Core:
"""
“Main” class of poezion
"""
- def __init__(self):
+ custom_version: str
+ firstrun: bool
+ completion: CompletionCore
+ command: CommandCore
+ handler: HandlerCore
+ bookmarks: BookmarkList
+ status: Status
+ commands: Dict[str, Command]
+ room_number_jump: List[str]
+ initial_joins: List[JID]
+ pending_invites: Dict[str, str]
+ configuration_change_handlers: Dict[str, List[Callable[..., None]]]
+ own_nick: str
+ connection_time: float
+ xmpp: connection.Connection
+ avatar_cache: FileSystemPerJidCache
+ plugins_autoloaded: bool
+ previous_tab_nb: int
+ tabs: Tabs
+ size: SizeManager
+ plugin_manager: PluginManager
+ events: events.EventHandler
+ legitimate_disconnect: bool
+ information_buffer: TextBuffer
+ information_win_size: int
+ stdscr: Optional[_CursesWindow]
+ xml_buffer: TextBuffer
+ xml_tab: Optional[XMLTab]
+ last_stream_error: Optional[Tuple[float, XMPPError]]
+ remote_fifo: Optional[Fifo]
+ key_func: KeyDict
+ tab_win: windows.GlobalInfoBar
+ left_tab_win: Optional[windows.VerticalGlobalInfoBar]
+
+ def __init__(self, custom_version: str, firstrun: bool):
self.completion = CompletionCore(self)
self.command = CommandCore(self)
self.handler = HandlerCore(self)
+ self.firstrun = firstrun
# All uncaught exception are given to this callback, instead
# of being displayed on the screen and exiting the program.
sys.excepthook = self.on_exception
self.connection_time = time.time()
self.last_stream_error = None
self.stdscr = None
- status = config.get('status')
- status = POSSIBLE_SHOW.get(status, None)
- self.status = Status(show=status, message=config.get('status_message'))
- self.running = True
- self.xmpp = connection.Connection()
+ status = config.getstr('status')
+ status = POSSIBLE_SHOW.get(status) or ''
+ self.status = Status(show=status, message=config.getstr('status_message'))
+ self.custom_version = custom_version
+ self.xmpp = connection.Connection(custom_version)
self.xmpp.core = self
self.keyboard = keyboard.Keyboard()
roster.set_node(self.xmpp.client_roster)
decorators.refresh_wrapper.core = self
self.bookmarks = BookmarkList()
- self.debug = False
self.remote_fifo = None
self.avatar_cache = FileSystemPerJidCache(
str(xdg.CACHE_HOME), 'avatars', binary=True)
@@ -92,13 +159,8 @@ class Core:
# that are displayed in almost all tabs, in an
# information window.
self.information_buffer = TextBuffer()
- self.information_win_size = config.get(
- 'info_win_height', section='var')
- self.information_win = windows.TextWin(300)
- self.information_buffer.add_window(self.information_win)
- self.left_tab_win = None
+ self.information_win_size = config.getint('info_win_height', section='var')
- self.tab_win = windows.GlobalInfoBar(self)
# Whether the XML tab is opened
self.xml_tab = None
self.xml_buffer = TextBuffer()
@@ -108,14 +170,13 @@ class Core:
self.events = events.EventHandler()
self.events.add_event_handler('tab_change', self.on_tab_change)
- self.tabs = Tabs(self.events)
+ self.tabs = Tabs(self.events, GapTab())
self.previous_tab_nb = 0
- own_nick = config.get('default_nick')
- own_nick = own_nick or self.xmpp.boundjid.user
- own_nick = own_nick or os.environ.get('USER')
- own_nick = own_nick or 'poezio'
- self.own_nick = own_nick
+ self.own_nick: str = (
+ config.getstr('default_nick') or self.xmpp.boundjid.user or
+ os.environ.get('USER') or 'poezio_user'
+ )
self.size = SizeManager(self)
@@ -202,6 +263,7 @@ class Core:
'_show_plugins': self.command.plugins,
'_show_xmltab': self.command.xml_tab,
'_toggle_pane': self.toggle_left_pane,
+ "_go_to_room_name": self.go_to_room_name,
###### status actions ######
'_available': lambda: self.command.status('available'),
'_away': lambda: self.command.status('away'),
@@ -209,12 +271,12 @@ class Core:
'_dnd': lambda: self.command.status('dnd'),
'_xa': lambda: self.command.status('xa'),
##### Custom actions ########
- '_exc_': self.try_execute,
}
self.key_func.update(key_func)
+ self.key_func.try_execute = self.try_execute
# Add handlers
- xmpp_event_handlers = [
+ xmpp_event_handlers: List[Tuple[str, Callable[..., Any]]] = [
('attention', self.handler.on_attention),
('carbon_received', self.handler.on_carbon_received),
('carbon_sent', self.handler.on_carbon_sent),
@@ -227,6 +289,7 @@ class Core:
('connected', self.handler.on_connected),
('connection_failed', self.handler.on_failed_connection),
('disconnected', self.handler.on_disconnected),
+ ('reconnect_delay', self.handler.on_reconnect_delay),
('failed_all_auth', self.handler.on_failed_all_auth),
('got_offline', self.handler.on_got_offline),
('got_online', self.handler.on_got_online),
@@ -240,6 +303,7 @@ class Core:
('groupchat_subject', self.handler.on_groupchat_subject),
('http_confirm', self.handler.http_confirm),
('message', self.handler.on_message),
+ ('message_encryption', self.handler.on_encrypted_message),
('message_error', self.handler.on_error_message),
('message_xform', self.handler.on_data_form),
('no_auth', self.handler.on_no_auth),
@@ -256,6 +320,9 @@ class Core:
('roster_update', self.handler.on_roster_update),
('session_start', self.handler.on_session_start),
('session_start', self.handler.on_session_start_features),
+ ('session_end', self.handler.on_session_end),
+ ('sm_failed', self.handler.on_session_end),
+ ('session_resumed', self.handler.on_session_resumed),
('ssl_cert', self.handler.validate_ssl),
('ssl_invalid_chain', self.handler.ssl_invalid_chain),
('stream_error', self.handler.on_stream_error),
@@ -263,35 +330,20 @@ class Core:
for name, handler in xmpp_event_handlers:
self.xmpp.add_event_handler(name, handler)
- if config.get('enable_avatars'):
+ if config.getbool('enable_avatars'):
self.xmpp.add_event_handler("vcard_avatar_update",
self.handler.on_vcard_avatar)
self.xmpp.add_event_handler("avatar_metadata_publish",
self.handler.on_0084_avatar)
- if config.get('enable_user_tune'):
- self.xmpp.add_event_handler("user_tune_publish",
- self.handler.on_tune_event)
- if config.get('enable_user_nick'):
+ if config.getbool('enable_user_nick'):
self.xmpp.add_event_handler("user_nick_publish",
self.handler.on_nick_received)
- if config.get('enable_user_mood'):
- self.xmpp.add_event_handler("user_mood_publish",
- self.handler.on_mood_event)
- if config.get('enable_user_activity'):
- self.xmpp.add_event_handler("user_activity_publish",
- self.handler.on_activity_event)
- if config.get('enable_user_gaming'):
- self.xmpp.add_event_handler("user_gaming_publish",
- self.handler.on_gaming_event)
-
all_stanzas = Callback('custom matcher', connection.MatchAll(None),
self.handler.incoming_stanza)
self.xmpp.register_handler(all_stanzas)
self.initial_joins = []
- self.connected_events = {}
-
self.pending_invites = {}
# a dict of the form {'config_option': [list, of, callbacks]}
@@ -307,13 +359,12 @@ class Core:
# The callback takes two argument: the config option, and the new
# value
self.configuration_change_handlers = defaultdict(list)
- config_handlers = [
+ config_handlers: List[Tuple[str, Callable[..., Any]]] = [
('', self.on_any_config_change),
('ack_message_receipts', self.on_ack_receipts_config_change),
('connection_check_interval', self.xmpp.set_keepalive_values),
('connection_timeout_delay', self.xmpp.set_keepalive_values),
('create_gaps', self.on_gaps_config_change),
- ('deterministic_nick_colors', self.on_nick_determinism_changed),
('enable_carbons', self.on_carbons_switch),
('enable_vertical_tab_list',
self.on_vertical_tab_list_config_change),
@@ -324,6 +375,7 @@ class Core:
('plugins_dir', self.plugin_manager.on_plugins_dir_change),
('request_message_receipts',
self.on_request_receipts_config_change),
+ ('show_timestamps', self.on_show_timestamps_changed),
('theme', self.on_theme_config_change),
('themes_dir', theming.update_themes_dir),
('use_bookmarks_method', self.on_bookmarks_method_config_change),
@@ -333,7 +385,14 @@ class Core:
for option, handler in config_handlers:
self.add_configuration_handler(option, handler)
- def on_tab_change(self, old_tab: tabs.Tab, new_tab: tabs.Tab):
+ def _create_windows(self):
+ """Create the windows (delayed after curses init)"""
+ self.information_win = windows.TextWin(300)
+ self.information_buffer.add_window(self.information_win)
+ self.left_tab_win = None
+ self.tab_win = windows.GlobalInfoBar(self)
+
+ def on_tab_change(self, old_tab: Tab, new_tab: Tab):
"""Whenever the current tab changes, change focus and refresh"""
old_tab.on_lose_focus()
new_tab.on_gain_focus()
@@ -374,6 +433,12 @@ class Core:
"""
self.call_for_resize()
+ def on_show_timestamps_changed(self, option, value):
+ """
+ Called when the show_timestamps option changes
+ """
+ self.call_for_resize(ui_config_changed=True)
+
def on_bookmarks_method_config_change(self, option, value):
"""
Called when the use_bookmarks_method option changes
@@ -381,7 +446,9 @@ class Core:
if value not in ('pep', 'privatexml'):
return
self.bookmarks.preferred = value
- self.bookmarks.save(self.xmpp, core=self)
+ asyncio.create_task(
+ self.bookmarks.save(self.xmpp, core=self)
+ )
def on_gaps_config_change(self, option, value):
"""
@@ -425,14 +492,6 @@ class Core:
"""
self.xmpp.password = value
- def on_nick_determinism_changed(self, option, value):
- """If we change the value to true, we call /recolor on all the MucTabs, to
- make the current nick colors reflect their deterministic value.
- """
- if value.lower() == "true":
- for tab in self.get_tabs(tabs.MucTab):
- tab.command_recolor('')
-
def on_carbons_switch(self, option, value):
"""Whenever the user enables or disables carbons using /set, we should
inform the server immediately, this way we do not require a restart
@@ -496,12 +555,6 @@ class Core:
}
log.error("%s received. Exiting…", signals[sig])
- if config.get('enable_user_mood'):
- self.xmpp.plugin['xep_0107'].stop()
- if config.get('enable_user_activity'):
- self.xmpp.plugin['xep_0108'].stop()
- if config.get('enable_user_gaming'):
- self.xmpp.plugin['xep_0196'].stop()
self.plugin_manager.disable_plugins()
self.disconnect('%s received' % signals.get(sig))
self.xmpp.add_event_handler("disconnected", self.exit, disposable=True)
@@ -510,13 +563,13 @@ class Core:
"""
Load the plugins on startup.
"""
- plugins = config.get('plugins_autoload')
+ plugins = config.getstr('plugins_autoload')
if ':' in plugins:
for plugin in plugins.split(':'):
- self.plugin_manager.load(plugin)
+ self.plugin_manager.load(plugin, unload_first=False)
else:
for plugin in plugins.split():
- self.plugin_manager.load(plugin)
+ self.plugin_manager.load(plugin, unload_first=False)
self.plugins_autoloaded = True
def start(self):
@@ -525,12 +578,20 @@ class Core:
"""
self.stdscr = curses.initscr()
self._init_curses(self.stdscr)
+ windows.base_wins.TAB_WIN = self.stdscr
+ self._create_windows()
self.call_for_resize()
- default_tab = tabs.RosterInfoTab(self)
+ default_tab = RosterInfoTab(self)
default_tab.on_gain_focus()
self.tabs.append(default_tab)
self.information('Welcome to poezio!', 'Info')
- if firstrun:
+ if curses.COLORS < 256:
+ self.information(
+ 'Your terminal does not appear to support 256 colors, the UI'
+ ' colors will probably be ugly',
+ 'Error',
+ )
+ if self.firstrun:
self.information(
'It seems that it is the first time you start poezio.\n'
'The online help is here https://doc.poez.io/\n\n'
@@ -558,7 +619,7 @@ class Core:
pass
sys.__excepthook__(typ, value, trace)
- def sigwinch_handler(self):
+ def sigwinch_handler(self, *args):
"""A work-around for ncurses resize stuff, which sucks. Normally, ncurses
catches SIGWINCH itself. In its signal handler, it updates the
windows structures (for example the size, etc) and it
@@ -600,7 +661,7 @@ class Core:
except ValueError:
pass
else:
- if self.tabs.current_tab.nb == nb and config.get(
+ if self.tabs.current_tab.nb == nb and config.getbool(
'go_to_previous_tab_on_alt_number'):
self.go_to_previous_tab()
else:
@@ -613,10 +674,28 @@ class Core:
self.do_command(replace_line_breaks(char), False)
else:
self.do_command(''.join(char_list), True)
- if self.status.show not in ('xa', 'away'):
- self.xmpp.plugin['xep_0319'].idle()
self.doupdate()
+ def loop_exception_handler(self, loop, context) -> None:
+ """Do not log unhandled iq errors and timeouts"""
+ handled_exceptions = (IqError, IqTimeout, InvalidCABundle)
+ if not isinstance(context['exception'], handled_exceptions):
+ loop.default_exception_handler(context)
+ elif isinstance(context['exception'], InvalidCABundle):
+ paths = context['exception'].path
+ error = (
+ 'Poezio could not find a valid CA bundle file automatically. '
+ 'Ensure the ca_cert_path configuration is set to a valid '
+ 'CA bundle path, generally provided by the \'ca-certificates\' '
+ 'package in your distribution.'
+ )
+ if isinstance(paths, (str, Path)):
+ # error += '\nFound the following value: {path}'.format(path=str(path))
+ paths = [paths]
+ if paths is not None:
+ error += f"\nThe following values were tried: {str([str(s) for s in paths])}"
+ self.information(error, 'Error')
+
def save_config(self):
"""
Save config in the file just before exit
@@ -635,13 +714,13 @@ class Core:
"""
if isinstance(roster_row, Contact):
if not self.get_conversation_by_jid(roster_row.bare_jid, False):
- self.open_conversation_window(roster_row.bare_jid)
+ self.open_conversation_window(JID(roster_row.bare_jid))
else:
self.focus_tab_named(roster_row.bare_jid)
if isinstance(roster_row, Resource):
if not self.get_conversation_by_jid(
roster_row.jid, False, fallback_barejid=False):
- self.open_conversation_window(roster_row.jid)
+ self.open_conversation_window(JID(roster_row.jid))
else:
self.focus_tab_named(roster_row.jid)
self.refresh_window()
@@ -654,7 +733,7 @@ class Core:
Messages are namedtuples of the form
('txt nick_color time str_time nickname user')
"""
- if not isinstance(self.tabs.current_tab, tabs.ChatTab):
+ if not isinstance(self.tabs.current_tab, ChatTab):
return None
return self.tabs.current_tab.get_conversation_messages()
@@ -711,9 +790,9 @@ class Core:
work. If you try to do anything else, your |, [, <<, etc will be
interpreted as normal command arguments, not shell special tokens.
"""
- if config.get('exec_remote'):
+ if config.getbool('exec_remote'):
# We just write the command in the fifo
- fifo_path = config.get('remote_fifo_path')
+ fifo_path = config.getstr('remote_fifo_path')
filename = os.path.join(fifo_path, 'poezio.fifo')
if not self.remote_fifo:
try:
@@ -785,16 +864,18 @@ class Core:
def remove_timed_event(self, event: DelayedEvent) -> None:
"""Remove an existing timed event"""
- event.handler.cancel()
+ if event.handler is not None:
+ event.handler.cancel()
def add_timed_event(self, event: DelayedEvent) -> None:
"""Add a new timed event"""
event.handler = asyncio.get_event_loop().call_later(
- event.delay, event.callback, *event.args)
+ event.delay, event.callback, *event.args
+ )
####################### XMPP-related actions ##################################
- def get_status(self) -> str:
+ def get_status(self) -> Status:
"""
Get the last status that was previously set
"""
@@ -807,7 +888,7 @@ class Core:
or to use it when joining a new muc)
"""
self.status = Status(show=pres, message=msg)
- if config.get('save_status'):
+ if config.getbool('save_status'):
ok = config.silent_set('status', pres if pres else '')
msg = msg.replace('\n', '|') if msg else ''
ok = ok and config.silent_set('status_message', msg)
@@ -822,7 +903,7 @@ class Core:
or the default nickname
"""
bm = self.bookmarks[room_name]
- if bm:
+ if bm and bm.nick:
return bm.nick
return self.own_nick
@@ -832,16 +913,12 @@ class Core:
parts of the client (for example, set the MucTabs as not joined, etc)
"""
self.legitimate_disconnect = True
- for tab in self.get_tabs(tabs.MucTab):
- tab.command_part(msg)
- self.xmpp.disconnect()
if reconnect:
- # Add a one-time event to reconnect as soon as we are
- # effectively disconnected
- self.xmpp.add_event_handler(
- 'disconnected',
- lambda event: self.xmpp.connect(),
- disposable=True)
+ self.xmpp.reconnect(wait=0.0, reason=msg)
+ else:
+ for tab in self.get_tabs(MucTab):
+ tab.leave_room(msg)
+ self.xmpp.disconnect(reason=msg)
def send_message(self, msg: str) -> bool:
"""
@@ -849,32 +926,48 @@ class Core:
conversation.
Returns False if the current tab is not a conversation tab
"""
- if not isinstance(self.tabs.current_tab, tabs.ChatTab):
+ if not isinstance(self.tabs.current_tab, ChatTab):
return False
- self.tabs.current_tab.command_say(msg)
+ asyncio.ensure_future(
+ self.tabs.current_tab.command_say(msg)
+ )
return True
- def invite(self, jid: JID, room: JID, reason: Optional[str] = None) -> None:
+ async def invite(self, jid: JID, room: JID, reason: Optional[str] = None, force_mediated: bool = False) -> bool:
"""
Checks if the sender supports XEP-0249, then send an invitation,
or a mediated one if it does not.
TODO: allow passwords
"""
+ features = set()
- def callback(iq):
- if not iq:
- return
- if 'jabber:x:conference' in iq['disco_info'].get_features():
- self.xmpp.plugin['xep_0249'].send_invitation(
- jid, room, reason=reason)
- else: # fallback
- self.xmpp.plugin['xep_0045'].invite(
- room, jid, reason=reason or '')
-
- self.xmpp.plugin['xep_0030'].get_info(
- jid=jid, timeout=5, callback=callback)
+ # force mediated: act as if the other entity does not
+ # support direct invites
+ if not force_mediated:
+ try:
+ iq = await self.xmpp.plugin['xep_0030'].get_info(
+ jid=jid,
+ timeout=5,
+ )
+ features = iq['disco_info'].get_features()
+ except (IqError, IqTimeout):
+ pass
+ supports_direct = 'jabber:x:conference' in features
+ if supports_direct:
+ self.xmpp.plugin['xep_0249'].send_invitation(
+ jid=jid,
+ roomjid=room,
+ reason=reason
+ )
+ else: # fallback
+ self.xmpp.plugin['xep_0045'].invite(
+ jid=jid,
+ room=room,
+ reason=reason or '',
+ )
+ return True
- def _impromptu_room_form(self, room):
+ def _impromptu_room_form(self, room) -> Iq:
fields = [
('hidden', 'FORM_TYPE', 'http://jabber.org/protocol/muc#roomconfig'),
('boolean', 'muc#roomconfig_changesubject', True),
@@ -935,74 +1028,78 @@ class Core:
)
return
- nick = self.own_nick
- localpart = uuid.uuid4().hex
- room = '{!s}@{!s}'.format(localpart, default_muc)
+ # Retries generating a name until we find a non-existing room.
+ # Abort otherwise.
+ retries = 3
+ while retries > 0:
+ localpart = utils.pronounceable()
+ room_str = f'{localpart}@{default_muc}'
+ try:
+ room = JID(room_str)
+ except InvalidJID:
+ self.information(
+ f'The generated XMPP address is invalid: {room_str}',
+ 'Error'
+ )
+ return None
- self.open_new_room(room, nick).join()
- iq = self._impromptu_room_form(room)
- try:
- await iq.send()
- except (IqError, IqTimeout):
- self.information('Failed to configure impromptu room.', 'Info')
- # TODO: destroy? leave room.
+ try:
+ iq = await self.xmpp['xep_0030'].get_info(
+ jid=room,
+ cached=False,
+ )
+ except IqTimeout:
+ pass
+ except IqError as exn:
+ if exn.etype == 'cancel' and exn.condition == 'item-not-found':
+ log.debug('Found empty room for /impromptu')
+ break
+
+ retries = retries - 1
+
+ if retries == 0:
+ self.information(
+ 'Couldn\'t generate a room name that isn\'t already used.',
+ 'Error',
+ )
return None
- self.information('Room %s created' % room, 'Info')
+ self.open_new_room(room, self.own_nick).join()
- for jid in jids:
- self.invite(jid, room)
+ async def configure_and_invite(_presence):
+ iq = self._impromptu_room_form(room)
+ try:
+ await iq.send()
+ except (IqError, IqTimeout):
+ self.information('Failed to configure impromptu room.', 'Info')
+ # TODO: destroy? leave room.
+ return None
- def get_error_message(self, stanza, deprecated: bool = False):
- """
- Takes a stanza of the form <message type='error'><error/></message>
- and return a well formed string containing error information
- """
- sender = stanza['from']
- msg = stanza['error']['type']
- condition = stanza['error']['condition']
- code = stanza['error']['code']
- body = stanza['error']['text']
- if not body:
- if deprecated:
- if code in DEPRECATED_ERRORS:
- body = DEPRECATED_ERRORS[code]
- else:
- body = condition or 'Unknown error'
- else:
- if code in ERROR_AND_STATUS_CODES:
- body = ERROR_AND_STATUS_CODES[code]
- else:
- body = condition or 'Unknown error'
- if code:
- message = '%(from)s: %(code)s - %(msg)s: %(body)s' % {
- 'from': sender,
- 'msg': msg,
- 'body': body,
- 'code': code
- }
- else:
- message = '%(from)s: %(msg)s: %(body)s' % {
- 'from': sender,
- 'msg': msg,
- 'body': body
- }
- return message
+ self.information(f'Room {room} created', 'Info')
+
+ for jid in jids:
+ await self.invite(jid, room, force_mediated=True)
+ jids_str = ', '.join(jids)
+ self.information(f'Invited {jids_str} to {room.bare}', 'Info')
+
+ self.xmpp.add_event_handler(
+ f'muc::{room.bare}::groupchat_subject',
+ configure_and_invite,
+ disposable=True,
+ )
####################### Tab logic-related things ##############################
### Tab getters ###
- def get_tabs(self, cls: Type[tabs.Tab] = None) -> List[tabs.Tab]:
+ def get_tabs(self, cls: Type[T]) -> List[T]:
"Get all the tabs of a type"
- if cls is None:
- return self.tabs.get_tabs()
return self.tabs.by_class(cls)
def get_conversation_by_jid(self,
jid: JID,
create: bool = True,
- fallback_barejid: bool = True) -> Optional[tabs.ChatTab]:
+ fallback_barejid: bool = True) -> Optional[ChatTab]:
"""
From a JID, get the tab containing the conversation with it.
If none already exist, and create is "True", we create it
@@ -1011,31 +1108,32 @@ class Core:
If fallback_barejid is True, then this method will seek other
tabs with the same barejid, instead of searching only by fulljid.
"""
- jid = safeJID(jid)
+ jid = JID(jid)
# We first check if we have a static conversation opened
# with this precise resource
+ conversation: Optional[ConversationTab]
conversation = self.tabs.by_name_and_class(jid.full,
- tabs.StaticConversationTab)
+ StaticConversationTab)
if jid.bare == jid.full and not conversation:
conversation = self.tabs.by_name_and_class(
- jid.full, tabs.DynamicConversationTab)
+ jid.full, DynamicConversationTab)
if not conversation and fallback_barejid:
# If not, we search for a conversation with the bare jid
conversation = self.tabs.by_name_and_class(
- jid.bare, tabs.DynamicConversationTab)
+ jid.bare, DynamicConversationTab)
if not conversation:
if create:
# We create a dynamic conversation with the bare Jid if
# nothing was found (and we lock it to the resource
# later)
conversation = self.open_conversation_window(
- jid.bare, False)
+ JID(jid.bare), False)
else:
conversation = None
return conversation
- def add_tab(self, new_tab: tabs.Tab, focus: bool = False) -> None:
+ def add_tab(self, new_tab: Tab, focus: bool = False) -> None:
"""
Appends the new_tab in the tab list and
focus it if focus==True
@@ -1050,21 +1148,21 @@ class Core:
returns False if it could not move the tab, True otherwise
"""
return self.tabs.insert_tab(old_pos, new_pos,
- config.get('create_gaps'))
+ config.getbool('create_gaps'))
### Move actions (e.g. go to next room) ###
- def rotate_rooms_right(self, args=None) -> None:
+ def rotate_rooms_right(self) -> None:
"""
rotate the rooms list to the right
"""
- self.tabs.next()
+ self.tabs.next() # pylint: disable=not-callable
- def rotate_rooms_left(self, args=None) -> None:
+ def rotate_rooms_left(self) -> None:
"""
rotate the rooms list to the right
"""
- self.tabs.prev()
+ self.tabs.prev() # pylint: disable=not-callable
def go_to_room_number(self) -> None:
"""
@@ -1092,6 +1190,34 @@ class Core:
keyboard.continuation_keys_callback = read_next_digit
+ def go_to_room_name(self) -> None:
+ room_name_jump = []
+
+ def read_next_letter(s) -> None:
+ nonlocal room_name_jump
+ room_name_jump.append(s)
+ any_matched, unique_tab = self.tabs.find_by_unique_prefix(
+ "".join(room_name_jump)
+ )
+
+ if not any_matched:
+ return
+
+ if unique_tab is not None:
+ self.tabs.set_current_tab(unique_tab)
+ # NOTE: returning here means that as soon as the tab is
+ # matched, normal input resumes. If we do *not* return here,
+ # any further characters matching the prefix of the tab will
+ # be swallowed (and a lot of tab switching will happen...),
+ # until a non-matching character or escape or something is
+ # pressed.
+ # This behaviour *may* be desirable.
+ return
+
+ keyboard.continuation_keys_callback = read_next_letter
+
+ keyboard.continuation_keys_callback = read_next_letter
+
def go_to_roster(self) -> None:
"Select the roster as the current tab"
self.tabs.set_current_tab(self.tabs.first())
@@ -1103,11 +1229,11 @@ class Core:
def go_to_important_room(self) -> None:
"""
Go to the next room with activity, in the order defined in the
- dict tabs.STATE_PRIORITY
+ dict STATE_PRIORITY
"""
# shortcut
- priority = tabs.STATE_PRIORITY
- tab_refs = {} # type: Dict[str, List[tabs.Tab]]
+ priority = STATE_PRIORITY
+ tab_refs: Dict[str, List[Tab]] = {}
# put all the active tabs in a dict of lists by state
for tab in self.tabs.get_tabs():
if not tab:
@@ -1132,7 +1258,7 @@ class Core:
def focus_tab_named(self,
tab_name: str,
- type_: Type[tabs.Tab] = None) -> bool:
+ type_: Type[Tab] = None) -> bool:
"""Returns True if it found a tab to focus on"""
if type_ is None:
tab = self.tabs.by_name(tab_name)
@@ -1143,23 +1269,24 @@ class Core:
return True
return False
- def focus_tab(self, tab: tabs.Tab) -> bool:
+ def focus_tab(self, tab: Tab) -> bool:
"""Focus a tab"""
return self.tabs.set_current_tab(tab)
### Opening actions ###
def open_conversation_window(self, jid: JID,
- focus=True) -> tabs.ConversationTab:
+ focus=True) -> ConversationTab:
"""
Open a new conversation tab and focus it if needed. If a resource is
provided, we open a StaticConversationTab, else a
DynamicConversationTab
"""
- if safeJID(jid).resource:
- new_tab = tabs.StaticConversationTab(self, jid)
+ new_tab: ConversationTab
+ if jid.resource:
+ new_tab = StaticConversationTab(self, jid)
else:
- new_tab = tabs.DynamicConversationTab(self, jid)
+ new_tab = DynamicConversationTab(self, jid)
if not focus:
new_tab.state = "private"
self.add_tab(new_tab, focus)
@@ -1167,41 +1294,41 @@ class Core:
return new_tab
def open_private_window(self, room_name: str, user_nick: str,
- focus=True) -> Optional[tabs.PrivateTab]:
+ focus=True) -> Optional[PrivateTab]:
"""
Open a Private conversation in a MUC and focus if needed.
"""
complete_jid = room_name + '/' + user_nick
# if the room exists, focus it and return
- for tab in self.get_tabs(tabs.PrivateTab):
+ for tab in self.get_tabs(PrivateTab):
if tab.name == complete_jid:
self.tabs.set_current_tab(tab)
return tab
# create the new tab
- tab = self.tabs.by_name_and_class(room_name, tabs.MucTab)
- if not tab:
+ muc_tab = self.tabs.by_name_and_class(room_name, MucTab)
+ if not muc_tab:
return None
- new_tab = tabs.PrivateTab(self, complete_jid, tab.own_nick)
+ tab = PrivateTab(self, complete_jid, muc_tab.own_nick)
if hasattr(tab, 'directed_presence'):
- new_tab.directed_presence = tab.directed_presence
+ tab.directed_presence = tab.directed_presence
if not focus:
- new_tab.state = "private"
+ tab.state = "private"
# insert it in the tabs
- self.add_tab(new_tab, focus)
+ self.add_tab(tab, focus)
self.refresh_window()
- tab.privates.append(new_tab)
- return new_tab
+ muc_tab.privates.append(tab)
+ return tab
def open_new_room(self,
- room: str,
+ room: JID,
nick: str,
*,
password: Optional[str] = None,
- focus=True) -> tabs.MucTab:
+ focus=True) -> MucTab:
"""
Open a new tab.MucTab containing a muc Room, using the specified nick
"""
- new_tab = tabs.MucTab(self, room, nick, password=password)
+ new_tab = MucTab(self, room, nick, password=password)
self.add_tab(new_tab, focus)
self.refresh_window()
return new_tab
@@ -1213,19 +1340,19 @@ class Core:
The callback are called with the completed form as parameter in
addition with kwargs
"""
- form_tab = tabs.DataFormsTab(self, form, on_cancel, on_send, kwargs)
+ form_tab = DataFormsTab(self, form, on_cancel, on_send, kwargs)
self.add_tab(form_tab, True)
### Modifying actions ###
def rename_private_tabs(self, room_name: str, old_nick: str, user: User) -> None:
"""
- Call this method when someone changes his/her nick in a MUC,
+ Call this method when someone changes their nick in a MUC,
this updates the name of all the opened private conversations
with him/her
"""
tab = self.tabs.by_name_and_class('%s/%s' % (room_name, old_nick),
- tabs.PrivateTab)
+ PrivateTab)
if tab:
tab.rename_user(old_nick, user)
@@ -1236,7 +1363,7 @@ class Core:
private conversation
"""
tab = self.tabs.by_name_and_class('%s/%s' % (room_name, user.nick),
- tabs.PrivateTab)
+ PrivateTab)
if tab:
tab.user_left(status_message, user)
@@ -1246,7 +1373,7 @@ class Core:
private conversation
"""
tab = self.tabs.by_name_and_class('%s/%s' % (room_name, nick),
- tabs.PrivateTab)
+ PrivateTab)
if tab:
tab.user_rejoined(nick)
@@ -1258,7 +1385,7 @@ class Core:
"""
if reason is None:
reason = '\x195}You left the room\x193}'
- for tab in self.get_tabs(tabs.PrivateTab):
+ for tab in self.get_tabs(PrivateTab):
if tab.name.startswith(room_name):
tab.deactivate(reason=reason)
@@ -1269,28 +1396,28 @@ class Core:
"""
if reason is None:
reason = '\x195}You joined the room\x193}'
- for tab in self.get_tabs(tabs.PrivateTab):
+ for tab in self.get_tabs(PrivateTab):
if tab.name.startswith(room_name):
tab.activate(reason=reason)
- def on_user_changed_status_in_private(self, jid: JID, status: str) -> None:
- tab = self.tabs.by_name_and_class(jid, tabs.ChatTab)
+ def on_user_changed_status_in_private(self, jid: JID, status: Status) -> None:
+ tab = self.tabs.by_name_and_class(jid, OneToOneTab)
if tab is not None: # display the message in private
tab.update_status(status)
- def close_tab(self, to_close: tabs.Tab = None) -> None:
+ def close_tab(self, to_close: Tab = None) -> None:
"""
Close the given tab. If None, close the current one
"""
was_current = to_close is None
tab = to_close or self.tabs.current_tab
- if isinstance(tab, tabs.RosterInfoTab):
+ if isinstance(tab, RosterInfoTab):
return # The tab 0 should NEVER be closed
tab.on_close()
del tab.key_func # Remove self references
del tab.commands # and make the object collectable
- self.tabs.delete(tab, gap=config.get('create_gaps'))
+ self.tabs.delete(tab, gap=config.getbool('create_gaps'))
logger.close(tab.name)
if was_current:
self.tabs.current_tab.on_gain_focus()
@@ -1306,9 +1433,9 @@ class Core:
Search for a ConversationTab with the given jid (full or bare),
if yes, add the given message to it
"""
- tab = self.tabs.by_name_and_class(jid, tabs.ConversationTab)
+ tab = self.tabs.by_name_and_class(jid, ConversationTab)
if tab is not None:
- tab.add_message(msg, typ=2)
+ tab.add_message(PersistentInfoMessage(msg))
if self.tabs.current_tab is tab:
self.refresh_window()
@@ -1316,36 +1443,36 @@ class Core:
def doupdate(self) -> None:
"Do a curses update"
- if not self.running:
- return
curses.doupdate()
def information(self, msg: str, typ: str = '') -> bool:
"""
Displays an informational message in the "Info" buffer
"""
- filter_types = config.get('information_buffer_type_filter').split(':')
+ filter_types = config.getlist('information_buffer_type_filter')
if typ.lower() in filter_types:
log.debug(
'Did not show the message:\n\t%s> %s \n\tdue to '
'information_buffer_type_filter configuration', typ, msg)
return False
- filter_messages = config.get('filter_info_messages').split(':')
+ filter_messages = config.getlist('filter_info_messages')
for words in filter_messages:
if words and words in msg:
log.debug(
'Did not show the message:\n\t%s> %s \n\tdue to filter_info_messages configuration',
typ, msg)
return False
- colors = get_theme().INFO_COLORS
- color = colors.get(typ.lower(), colors.get('default', None))
nb_lines = self.information_buffer.add_message(
- msg, nickname=typ, nick_color=color)
- popup_on = config.get('information_buffer_popup_on').split()
- if isinstance(self.tabs.current_tab, tabs.RosterInfoTab):
+ UIMessage(
+ txt=msg,
+ level=typ,
+ )
+ )
+ popup_on = config.getlist('information_buffer_popup_on')
+ if isinstance(self.tabs.current_tab, RosterInfoTab):
self.refresh_window()
elif typ != '' and typ.lower() in popup_on:
- popup_time = config.get('popup_time') + (nb_lines - 1) * 2
+ popup_time = config.getint('popup_time') + (nb_lines - 1) * 2
self._pop_information_win_up(nb_lines, popup_time)
else:
if self.information_win_size != 0:
@@ -1493,7 +1620,7 @@ class Core:
Scroll the information buffer up
"""
self.information_win.scroll_up(self.information_win.height)
- if not isinstance(self.tabs.current_tab, tabs.RosterInfoTab):
+ if not isinstance(self.tabs.current_tab, RosterInfoTab):
self.information_win.refresh()
else:
info = self.tabs.current_tab.information_win
@@ -1505,7 +1632,7 @@ class Core:
Scroll the information buffer down
"""
self.information_win.scroll_down(self.information_win.height)
- if not isinstance(self.tabs.current_tab, tabs.RosterInfoTab):
+ if not isinstance(self.tabs.current_tab, RosterInfoTab):
self.information_win.refresh()
else:
info = self.tabs.current_tab.information_win
@@ -1530,57 +1657,47 @@ class Core:
"""
Enable/disable the left panel.
"""
- enabled = config.get('enable_vertical_tab_list')
+ enabled = config.getbool('enable_vertical_tab_list')
if not config.silent_set('enable_vertical_tab_list', str(not enabled)):
self.information('Unable to write in the config file', 'Error')
self.call_for_resize()
- def resize_global_information_win(self):
+ def resize_global_information_win(self, ui_config_changed: bool = False):
"""
Resize the global_information_win only once at each resize.
"""
- if self.information_win_size > tabs.Tab.height - 6:
- self.information_win_size = tabs.Tab.height - 6
- if tabs.Tab.height < 6:
+ if self.information_win_size > Tab.height - 6:
+ self.information_win_size = Tab.height - 6
+ if Tab.height < 6:
self.information_win_size = 0
- height = (tabs.Tab.height - 1 - self.information_win_size -
- tabs.Tab.tab_win_height())
- self.information_win.resize(self.information_win_size, tabs.Tab.width,
- height, 0)
+ height = (Tab.height - 1 - self.information_win_size -
+ Tab.tab_win_height())
+ self.information_win.resize(self.information_win_size, Tab.width,
+ height, 0, self.information_buffer,
+ force=ui_config_changed)
def resize_global_info_bar(self):
"""
Resize the GlobalInfoBar only once at each resize
"""
height, width = self.stdscr.getmaxyx()
- if config.get('enable_vertical_tab_list'):
+ if config.getbool('enable_vertical_tab_list'):
if self.size.core_degrade_x:
return
try:
height, _ = self.stdscr.getmaxyx()
truncated_win = self.stdscr.subwin(
- height, config.get('vertical_tab_list_size'), 0, 0)
+ height, config.getint('vertical_tab_list_size'), 0, 0)
except:
log.error('Curses error on infobar resize', exc_info=True)
return
self.left_tab_win = windows.VerticalGlobalInfoBar(
self, truncated_win)
elif not self.size.core_degrade_y:
- self.tab_win.resize(1, tabs.Tab.width, tabs.Tab.height - 2, 0)
+ self.tab_win.resize(1, Tab.width, Tab.height - 2, 0)
self.left_tab_win = None
- def add_message_to_text_buffer(self, buff, txt, nickname=None):
- """
- Add the message to the room if possible, else, add it to the Info window
- (in the Info tab of the info window in the RosterTab)
- """
- if not buff:
- self.information('Trying to add a message in no room: %s' % txt,
- 'Error')
- return
- buff.add_message(txt, nickname=nickname)
-
def full_screen_redraw(self):
"""
Completely erase and redraw the screen
@@ -1588,7 +1705,7 @@ class Core:
self.stdscr.clear()
self.refresh_window()
- def call_for_resize(self):
+ def call_for_resize(self, ui_config_changed: bool = False):
"""
Called when we want to resize the screen
"""
@@ -1596,22 +1713,27 @@ class Core:
# window to each Tab class, so they draw themself in the portion of
# the screen that they can occupy, and we draw the tab list on the
# remaining space, on the left
+ if self.stdscr is None:
+ raise ValueError('No output available')
height, width = self.stdscr.getmaxyx()
- if (config.get('enable_vertical_tab_list')
+ if (config.getbool('enable_vertical_tab_list')
and not self.size.core_degrade_x):
try:
- scr = self.stdscr.subwin(0,
- config.get('vertical_tab_list_size'))
+ scr = self.stdscr.subwin(
+ 0,
+ config.getint('vertical_tab_list_size')
+ )
except:
log.error('Curses error on resize', exc_info=True)
return
else:
scr = self.stdscr
- tabs.Tab.resize(scr)
+ Tab.initial_resize(scr)
self.resize_global_info_bar()
- self.resize_global_information_win()
+ self.resize_global_information_win(ui_config_changed)
for tab in self.tabs:
- if config.get('lazy_resize'):
+ tab.ui_config_changed = True
+ if config.getbool('lazy_resize'):
tab.need_resize = True
else:
tab.resize()
@@ -1654,342 +1776,56 @@ class Core:
"""
Register the commands when poezio starts
"""
- self.register_command(
- 'help',
- self.command.help,
- usage='[command]',
- shortdesc='\\_o< KOIN KOIN KOIN',
- completion=self.completion.help)
- self.register_command(
- 'join',
- self.command.join,
- usage="[room_name][@server][/nick] [password]",
- desc="Join the specified room. You can specify a nickname "
- "after a slash (/). If no nickname is specified, you will"
- " use the default_nick in the configuration file. You can"
- " omit the room name: you will then join the room you\'re"
- " looking at (useful if you were kicked). You can also "
- "provide a room_name without specifying a server, the "
- "server of the room you're currently in will be used. You"
- " can also provide a password to join the room.\nExamples"
- ":\n/join room@server.tld\n/join room@server.tld/John\n"
- "/join room2\n/join /me_again\n/join\n/join room@server"
- ".tld/my_nick password\n/join / password",
- shortdesc='Join a room',
- completion=self.completion.join)
- self.register_command(
- 'exit',
- self.command.quit,
- desc='Just disconnect from the server and exit poezio.',
- shortdesc='Exit poezio.')
- self.register_command(
- 'quit',
- self.command.quit,
- desc='Just disconnect from the server and exit poezio.',
- shortdesc='Exit poezio.')
- self.register_command(
- 'next', self.rotate_rooms_right, shortdesc='Go to the next room.')
- self.register_command(
- 'prev',
- self.rotate_rooms_left,
- shortdesc='Go to the previous room.')
- self.register_command(
- 'win',
- self.command.win,
- usage='<number or name>',
- shortdesc='Go to the specified room',
- completion=self.completion.win)
- self.commands['w'] = self.commands['win']
- self.register_command(
- 'move_tab',
- self.command.move_tab,
- usage='<source> <destination>',
- desc="Insert the <source> tab at the position of "
- "<destination>. This will make the following tabs shift in"
- " some cases (refer to the documentation). A tab can be "
- "designated by its number or by the beginning of its "
- "address. You can use \".\" as a shortcut for the current "
- "tab.",
- shortdesc='Move a tab.',
- completion=self.completion.move_tab)
- self.register_command(
- 'destroy_room',
- self.command.destroy_room,
- usage='[room JID]',
- desc='Try to destroy the room [room JID], or the current'
- ' tab if it is a multi-user chat and [room JID] is '
- 'not given.',
- shortdesc='Destroy a room.',
- completion=None)
- self.register_command(
- 'show',
- self.command.status,
- usage='<availability> [status message]',
- desc="Sets your availability and (optionally) your status "
- "message. The <availability> argument is one of \"available"
- ", chat, away, afk, dnd, busy, xa\" and the optional "
- "[status message] argument will be your status message.",
- shortdesc='Change your availability.',
- completion=self.completion.status)
- self.commands['status'] = self.commands['show']
- self.register_command(
- 'bookmark_local',
- self.command.bookmark_local,
- usage="[roomname][/nick] [password]",
- desc="Bookmark Local: Bookmark locally the specified room "
- "(you will then auto-join it on each poezio start). This"
- " commands uses almost the same syntaxe as /join. Type "
- "/help join for syntax examples. Note that when typing "
- "\"/bookmark\" on its own, the room will be bookmarked "
- "with the nickname you\'re currently using in this room "
- "(instead of default_nick)",
- shortdesc='Bookmark a room locally.',
- completion=self.completion.bookmark_local)
- self.register_command(
- 'bookmark',
- self.command.bookmark,
- usage="[roomname][/nick] [autojoin] [password]",
- desc="Bookmark: Bookmark online the specified room (you "
- "will then auto-join it on each poezio start if autojoin"
- " is specified and is 'true'). This commands uses almost"
- " the same syntax as /join. Type /help join for syntax "
- "examples. Note that when typing \"/bookmark\" alone, the"
- " room will be bookmarked with the nickname you\'re "
- "currently using in this room (instead of default_nick).",
- shortdesc="Bookmark a room online.",
- completion=self.completion.bookmark)
- self.register_command(
- 'set',
- self.command.set,
- usage="[plugin|][section] <option> [value]",
- desc="Set the value of an option in your configuration file."
- " You can, for example, change your default nickname by "
- "doing `/set default_nick toto` or your resource with `/set"
- " resource blabla`. You can also set options in specific "
- "sections with `/set bindings M-i ^i` or in specific plugin"
- " with `/set mpd_client| host 127.0.0.1`. `toggle` can be "
- "used as a special value to toggle a boolean option.",
- shortdesc="Set the value of an option",
- completion=self.completion.set)
- self.register_command(
- 'set_default',
- self.command.set_default,
- usage="[section] <option>",
- desc="Set the default value of an option. For example, "
- "`/set_default resource` will reset the resource "
- "option. You can also reset options in specific "
- "sections by doing `/set_default section option`.",
- shortdesc="Set the default value of an option",
- completion=self.completion.set_default)
- self.register_command(
- 'toggle',
- self.command.toggle,
- usage='<option>',
- desc='Shortcut for /set <option> toggle',
- shortdesc='Toggle an option',
- completion=self.completion.toggle)
- self.register_command(
- 'theme',
- self.command.theme,
- usage='[theme name]',
- desc="Reload the theme defined in the config file. If theme"
- "_name is provided, set that theme before reloading it.",
- shortdesc='Load a theme',
- completion=self.completion.theme)
- self.register_command(
- 'list',
- self.command.list,
- usage='[server]',
- desc="Get the list of public rooms"
- " on the specified server.",
- shortdesc='List the rooms.',
- completion=self.completion.list)
- self.register_command(
- 'message',
- self.command.message,
- usage='<jid> [optional message]',
- desc="Open a conversation with the specified JID (even if it"
- " is not in our roster), and send a message to it, if the "
- "message is specified.",
- shortdesc='Send a message',
- completion=self.completion.message)
- self.register_command(
- 'version',
- self.command.version,
- usage='<jid>',
- desc="Get the software version of the given JID (usually its"
- " XMPP client and Operating System).",
- shortdesc='Get the software version of a JID.',
- completion=self.completion.version)
- self.register_command(
- 'server_cycle',
- self.command.server_cycle,
- usage='[domain] [message]',
- desc='Disconnect and reconnect in all the rooms in domain.',
- shortdesc='Cycle a range of rooms',
- completion=self.completion.server_cycle)
- self.register_command(
- 'bind',
- self.command.bind,
- usage='<key> <equ>',
- desc="Bind a key to another key or to a “command”. For "
- "example \"/bind ^H KEY_UP\" makes Control + h do the"
- " same same as the Up key.",
- completion=self.completion.bind,
- shortdesc='Bind a key to another key.')
- self.register_command(
- 'load',
- self.command.load,
- usage='<plugin> [<otherplugin> …]',
- shortdesc='Load the specified plugin(s)',
- completion=self.plugin_manager.completion_load)
- self.register_command(
- 'unload',
- self.command.unload,
- usage='<plugin> [<otherplugin> …]',
- shortdesc='Unload the specified plugin(s)',
- completion=self.plugin_manager.completion_unload)
- self.register_command(
- 'plugins',
- self.command.plugins,
- shortdesc='Show the plugins in use.')
- self.register_command(
- 'presence',
- self.command.presence,
- usage='<JID> [type] [status]',
- desc="Send a directed presence to <JID> and using"
- " [type] and [status] if provided.",
- shortdesc='Send a directed presence.',
- completion=self.completion.presence)
- self.register_command(
- 'rawxml',
- self.command.rawxml,
- usage='<xml>',
- shortdesc='Send a custom xml stanza.')
- self.register_command(
- 'invite',
- self.command.invite,
- usage='<jid> <room> [reason]',
- desc='Invite jid in room with reason.',
- shortdesc='Invite someone in a room.',
- completion=self.completion.invite)
- self.register_command(
- 'impromptu',
- self.command.impromptu,
- usage='<jid> [jid ...]',
- desc='Invite specified JIDs into a newly created room.',
- shortdesc='Invite specified JIDs into newly created room.',
- completion=self.completion.impromptu)
- self.register_command(
- 'invitations',
- self.command.invitations,
- shortdesc='Show the pending invitations.')
- self.register_command(
- 'bookmarks',
- self.command.bookmarks,
- shortdesc='Show the current bookmarks.')
- self.register_command(
- 'remove_bookmark',
- self.command.remove_bookmark,
- usage='[jid]',
- desc="Remove the specified bookmark, or the "
- "bookmark on the current tab, if any.",
- shortdesc='Remove a bookmark',
- completion=self.completion.remove_bookmark)
- self.register_command(
- 'xml_tab', self.command.xml_tab, shortdesc='Open an XML tab.')
- self.register_command(
- 'runkey',
- self.command.runkey,
- usage='<key>',
- shortdesc='Execute the action defined for <key>.',
- completion=self.completion.runkey)
- self.register_command(
- 'self', self.command.self_, shortdesc='Remind you of who you are.')
- self.register_command(
- 'last_activity',
- self.command.last_activity,
- usage='<jid>',
- desc='Informs you of the last activity of a JID.',
- shortdesc='Get the activity of someone.',
- completion=self.completion.last_activity)
- self.register_command(
- 'ad-hoc',
- self.command.adhoc,
- usage='<jid>',
- shortdesc='List available ad-hoc commands on the given jid')
- self.register_command(
- 'reload',
- self.command.reload,
- shortdesc='Reload the config. You can achieve the same by '
- 'sending SIGUSR1 to poezio.')
-
- if config.get('enable_user_activity'):
- self.register_command(
- 'activity',
- self.command.activity,
- usage='[<general> [specific] [text]]',
- desc='Send your current activity to your contacts '
- '(use the completion). Nothing means '
- '"stop broadcasting an activity".',
- shortdesc='Send your activity.',
- completion=self.completion.activity)
- if config.get('enable_user_mood'):
+ for command in get_commands(self.command, self.completion, self.plugin_manager):
+ self.register_command(**command)
+
+ def check_blocking(self, features: List[str]):
+ if 'urn:xmpp:blocking' in features and not self.xmpp.anon:
self.register_command(
- 'mood',
- self.command.mood,
- usage='[<mood> [text]]',
- desc='Send your current mood to your contacts '
- '(use the completion). Nothing means '
- '"stop broadcasting a mood".',
- shortdesc='Send your mood.',
- completion=self.completion.mood)
- if config.get('enable_user_gaming'):
+ 'block',
+ self.command.block,
+ usage='[jid]',
+ shortdesc='Prevent a JID from talking to you.',
+ completion=self.completion.block)
self.register_command(
- 'gaming',
- self.command.gaming,
- usage='[<game name> [server address]]',
- desc='Send your current gaming activity to '
- 'your contacts. Nothing means "stop '
- 'broadcasting a gaming activity".',
- shortdesc='Send your gaming activity.',
- completion=None)
+ 'unblock',
+ self.command.unblock,
+ usage='[jid]',
+ shortdesc='Allow a JID to talk to you.',
+ completion=self.completion.unblock)
+ self.xmpp.del_event_handler('session_start', self.check_blocking)
####################### Random things to move #################################
- def join_initial_rooms(self, bookmarks):
+ def join_initial_rooms(self, bookmarks: List[Bookmark]):
"""Join all rooms given in the iterator `bookmarks`"""
for bm in bookmarks:
- if not (bm.autojoin or config.get('open_all_bookmarks')):
+ if not (bm.autojoin or config.getbool('open_all_bookmarks')):
continue
- tab = self.tabs.by_name_and_class(bm.jid, tabs.MucTab)
+ tab = self.tabs.by_name_and_class(bm.jid, MucTab)
nick = bm.nick if bm.nick else self.own_nick
if not tab:
- self.open_new_room(
+ tab = self.open_new_room(
bm.jid, nick, focus=False, password=bm.password)
self.initial_joins.append(bm.jid)
# do not join rooms that do not have autojoin
# but display them anyway
- if bm.autojoin:
- muc.join_groupchat(
- self,
- bm.jid,
- nick,
- passwd=bm.password,
- status=self.status.message,
- show=self.status.show)
-
- def check_bookmark_storage(self, features):
+ if bm.autojoin and tab:
+ tab.join()
+
+ async def check_bookmark_storage(self, features: List[str]):
private = 'jabber:iq:private' in features
pep_ = 'http://jabber.org/protocol/pubsub#publish' in features
self.bookmarks.available_storage['private'] = private
self.bookmarks.available_storage['pep'] = pep_
- def _join_remote_only(iq):
- if iq['type'] == 'error':
- type_ = iq['error']['type']
- condition = iq['error']['condition']
+ if not self.xmpp.anon and config.getbool('use_remote_bookmarks'):
+ try:
+ await self.bookmarks.get_remote(self.xmpp, self.information)
+ except IqError as error:
+ type_ = error.iq['error']['type']
+ condition = error.iq['error']['condition']
if not (type_ == 'cancel' and condition == 'item-not-found'):
self.information(
'Unable to fetch the remote'
@@ -1998,38 +1834,37 @@ class Core:
remote_bookmarks = self.bookmarks.remote()
self.join_initial_rooms(remote_bookmarks)
- if not self.xmpp.anon and config.get('use_remote_bookmarks'):
- self.bookmarks.get_remote(self.xmpp, self.information,
- _join_remote_only)
-
- def room_error(self, error, room_name):
+ def room_error(self, error, room_name: str) -> None:
"""
Display the error in the tab
"""
- tab = self.tabs.by_name_and_class(room_name, tabs.MucTab)
+ tab = self.tabs.by_name_and_class(room_name, MucTab)
if not tab:
return
- error_message = self.get_error_message(error)
+ error_message = get_error_message(error)
tab.add_message(
- error_message,
- highlight=True,
- nickname='Error',
- nick_color=get_theme().COLOR_ERROR_MSG,
- typ=2)
+ UIMessage(
+ error_message,
+ level='Error',
+ ),
+ )
code = error['error']['code']
if code == '401':
msg = 'To provide a password in order to join the room, type "/join / password" (replace "password" by the real password)'
- tab.add_message(msg, typ=2)
+ tab.add_message(PersistentInfoMessage(msg))
if code == '409':
- if config.get('alternative_nickname') != '':
+ if config.getstr('alternative_nickname') != '':
if not tab.joined:
- tab.own_nick += config.get('alternative_nickname')
+ tab.own_nick += config.getstr('alternative_nickname')
tab.join()
else:
if not tab.joined:
tab.add_message(
- 'You can join the room with an other nick, by typing "/join /other_nick"',
- typ=2)
+ PersistentInfoMessage(
+ 'You can join the room with another nick, '
+ 'by typing "/join /other_nick"'
+ )
+ )
self.refresh_window()
@@ -2038,13 +1873,18 @@ class KeyDict(dict):
A dict, with a wrapper for get() that will return a custom value
if the key starts with _exc_
"""
+ try_execute: Optional[Callable[[str], Any]]
- def get(self, key: str, default: Optional[Callable] = None) -> Callable:
+ def get(self, key: str, default=None) -> Callable:
if isinstance(key, str) and key.startswith('_exc_') and len(key) > 5:
- return lambda: dict.get(self, '_exc_')(key[5:])
+ if self.try_execute is not None:
+ try_execute = self.try_execute
+ return lambda: try_execute(key[5:])
+ raise ValueError("KeyDict not initialized")
return dict.get(self, key, default)
+
def replace_key_with_bound(key: str) -> str:
"""
Replace an inputted key with the one defined as its replacement
diff --git a/poezio/core/handlers.py b/poezio/core/handlers.py
index 0a6e7e50..e92e4aac 100644
--- a/poezio/core/handlers.py
+++ b/poezio/core/handlers.py
@@ -3,40 +3,41 @@ XMPP-related handlers for the Core class
"""
import logging
-log = logging.getLogger(__name__)
+
+from typing import Optional
import asyncio
import curses
-import functools
import select
+import signal
import ssl
import sys
import time
-from datetime import datetime
from hashlib import sha1, sha256, sha512
-from os import path
import pyasn1.codec.der.decoder
import pyasn1.codec.der.encoder
import pyasn1_modules.rfc2459
-from slixmpp import InvalidJID
+from slixmpp import InvalidJID, JID, Message, Iq, Presence
from slixmpp.xmlstream.stanzabase import StanzaBase, ElementBase
from xml.etree import ElementTree as ET
-from poezio import common
-from poezio import fixes
-from poezio import pep
from poezio import tabs
from poezio import xhtml
from poezio import multiuserchat as muc
-from poezio.common import safeJID
+from poezio.common import get_error_message
from poezio.config import config, get_image_cache
from poezio.core.structs import Status
from poezio.contact import Resource
from poezio.logger import logger
from poezio.roster import roster
-from poezio.text_buffer import CorrectionError, AckError
+from poezio.text_buffer import AckError
from poezio.theming import dump_tuple, get_theme
+from poezio.ui.types import (
+ XMLLog,
+ InfoMessage,
+ PersistentInfoMessage,
+)
from poezio.core.commands import dumb_callback
@@ -50,6 +51,8 @@ try:
except ImportError:
PYGMENTS = False
+log = logging.getLogger(__name__)
+
CERT_WARNING_TEXT = """
WARNING: CERTIFICATE FOR %s CHANGED
@@ -76,101 +79,135 @@ class HandlerCore:
def __init__(self, core):
self.core = core
- def on_session_start_features(self, _):
+ async def on_session_start_features(self, _):
"""
Enable carbons & blocking on session start if wanted and possible
"""
-
- def callback(iq):
- if not iq:
- return
- features = iq['disco_info']['features']
- rostertab = self.core.tabs.by_name_and_class(
- 'Roster', tabs.RosterInfoTab)
- rostertab.check_blocking(features)
- rostertab.check_saslexternal(features)
- if (config.get('enable_carbons')
- and 'urn:xmpp:carbons:2' in features):
- self.core.xmpp.plugin['xep_0280'].enable()
- self.core.check_bookmark_storage(features)
-
- self.core.xmpp.plugin['xep_0030'].get_info(
- jid=self.core.xmpp.boundjid.domain, callback=callback)
+ iq = await self.core.xmpp.plugin['xep_0030'].get_info(
+ jid=self.core.xmpp.boundjid.domain
+ )
+ features = iq['disco_info']['features']
+
+ rostertab = self.core.tabs.by_name_and_class(
+ 'Roster', tabs.RosterInfoTab)
+ rostertab.check_saslexternal(features)
+ rostertab.check_blocking(features)
+ self.core.check_blocking(features)
+ if (config.getbool('enable_carbons')
+ and 'urn:xmpp:carbons:2' in features):
+ self.core.xmpp.plugin['xep_0280'].enable()
+ await self.core.check_bookmark_storage(features)
def find_identities(self, _):
- asyncio.ensure_future(
+ asyncio.create_task(
self.core.xmpp['xep_0030'].get_info_from_domain(),
)
- def on_carbon_received(self, message):
+ def is_known_muc_pm(self, message: Message, with_jid: JID) -> Optional[bool]:
"""
- Carbon <received/> received
+ Try to determine whether a given message is a MUC-PM, without a roundtrip. Returns None when it's not clear
"""
- def ignore_message(recv):
- log.debug('%s has category conference, ignoring carbon',
- recv['from'].server)
+ # first, look for the x (XEP-0045 version 1.28)
+ if message.match('message/muc'):
+ log.debug('MUC-PM from %s with <x>', with_jid)
+ return True
- def receive_message(recv):
- recv['to'] = self.core.xmpp.boundjid.full
- if recv['receipt']:
- return self.on_receipt(recv)
- self.on_normal_message(recv)
+ jid_bare = with_jid.bare
+
+ # then, look whether we have a matching tab with barejid
+ tab = self.core.tabs.by_jid(JID(jid_bare))
+ if tab is not None:
+ if isinstance(tab, tabs.MucTab):
+ log.debug('MUC-PM from %s in known MucTab', with_jid)
+ return True
+ one_to_one = isinstance(tab, (
+ tabs.ConversationTab,
+ tabs.DynamicConversationTab,
+ ))
+ if one_to_one:
+ return False
+
+ # then, look whether we have a matching tab with fulljid
+ if with_jid.resource:
+ tab = self.core.tabs.by_jid(with_jid)
+ if tab is not None:
+ if isinstance(tab, tabs.PrivateTab):
+ log.debug('MUC-PM from %s in known PrivateTab', with_jid)
+ return True
+ if isinstance(tab, tabs.StaticConversationTab):
+ return False
+
+ # then, look in the roster
+ if jid_bare in roster and roster[jid_bare].subscription != 'none':
+ return False
+
+ # then, check bookmarks
+ for bm in self.core.bookmarks:
+ if bm.jid.bare == jid_bare:
+ log.debug('MUC-PM from %s in bookmarks', with_jid)
+ return True
+ return None
+
+ async def on_carbon_received(self, message: Message):
+ """
+ Carbon <received/> received
+ """
recv = message['carbon_received']
- if (recv['from'].bare not in roster
- or roster[recv['from'].bare].subscription == 'none'):
- fixes.has_identity(
- self.core.xmpp,
- recv['from'].server,
- identity='conference',
- on_true=functools.partial(ignore_message, recv),
- on_false=functools.partial(receive_message, recv))
- return
+ is_muc_pm = self.is_known_muc_pm(recv, recv['from'])
+ if is_muc_pm:
+ log.debug('%s sent a MUC-PM, ignoring carbon', recv['from'])
+ elif is_muc_pm is None:
+ is_muc = await self.core.xmpp.plugin['xep_0030'].has_identity(
+ recv['from'].bare,
+ node='conference',
+ )
+ if is_muc:
+ log.debug('%s has category conference, ignoring carbon',
+ recv['from'].server)
+ else:
+ recv['to'] = self.core.xmpp.boundjid.full
+ if recv['receipt']:
+ await self.on_receipt(recv)
+ else:
+ await self.on_normal_message(recv)
else:
- receive_message(recv)
+ recv['to'] = self.core.xmpp.boundjid.full
+ await self.on_normal_message(recv)
- def on_carbon_sent(self, message):
+ async def on_carbon_sent(self, message: Message):
"""
Carbon <sent/> received
"""
-
- def groupchat_private_message(sent):
- self.on_groupchat_private_message(sent, sent=True)
-
- def send_message(sent):
- sent['from'] = self.core.xmpp.boundjid.full
- self.on_normal_message(sent)
-
sent = message['carbon_sent']
- # todo: implement proper MUC detection logic
- if (sent['to'].resource
- and (sent['to'].bare not in roster
- or roster[sent['to'].bare].subscription == 'none')):
- fixes.has_identity(
- self.core.xmpp,
- sent['to'].server,
- identity='conference',
- on_true=functools.partial(groupchat_private_message, sent),
- on_false=functools.partial(send_message, sent))
+ is_muc_pm = self.is_known_muc_pm(sent, sent['to'])
+ if is_muc_pm:
+ await self.on_groupchat_private_message(sent, sent=True)
+ elif is_muc_pm is None:
+ is_muc = await self.core.xmpp.plugin['xep_0030'].has_identity(
+ sent['to'].bare,
+ node='conference',
+ )
+ if is_muc:
+ await self.on_groupchat_private_message(sent, sent=True)
+ else:
+ sent['from'] = self.core.xmpp.boundjid.full
+ await self.on_normal_message(sent)
else:
- send_message(sent)
+ sent['from'] = self.core.xmpp.boundjid.full
+ await self.on_normal_message(sent)
### Invites ###
- def on_groupchat_invitation(self, message):
+ async def on_groupchat_invitation(self, message: Message):
"""
Mediated invitation received
"""
jid = message['from']
if jid.bare in self.core.pending_invites:
return
- # there are 2 'x' tags in the messages, making message['x'] useless
- invite = StanzaBase(
- self.core.xmpp,
- xml=message.xml.find(
- '{http://jabber.org/protocol/muc#user}x/{http://jabber.org/protocol/muc#user}invite'
- ))
+ invite = message['muc']['invite']
# TODO: find out why pylint thinks "inviter" is a list
#pylint: disable=no-member
inviter = invite['from']
@@ -182,20 +219,23 @@ class HandlerCore:
if password:
msg += ". The password is \"%s\"." % password
self.core.information(msg, 'Info')
- if 'invite' in config.get('beep_on').split():
+ if 'invite' in config.getstr('beep_on').split():
curses.beep()
logger.log_roster_change(inviter.full, 'invited you to %s' % jid.full)
self.core.pending_invites[jid.bare] = inviter.full
- def on_groupchat_decline(self, decline):
+ async def on_groupchat_decline(self, decline):
"Mediated invitation declined; skip for now"
pass
- def on_groupchat_direct_invitation(self, message):
+ async def on_groupchat_direct_invitation(self, message: Message):
"""
Direct invitation received
"""
- room = safeJID(message['groupchat_invite']['jid'])
+ try:
+ room = JID(message['groupchat_invite']['jid'])
+ except InvalidJID:
+ return
if room.bare in self.core.pending_invites:
return
@@ -213,7 +253,7 @@ class HandlerCore:
msg += "\nreason: %s" % reason
self.core.information(msg, 'Info')
- if 'invite' in config.get('beep_on').split():
+ if 'invite' in config.getstr('beep_on').split():
curses.beep()
self.core.pending_invites[room.bare] = inviter.full
@@ -221,37 +261,40 @@ class HandlerCore:
### "classic" messages ###
- def on_message(self, message):
+ async def on_message(self, message: Message):
"""
When receiving private message from a muc OR a normal message
(from one of our contacts)
"""
- if message.xml.find(
- '{http://jabber.org/protocol/muc#user}x/{http://jabber.org/protocol/muc#user}invite'
- ) is not None:
+ if message.match('message/muc/invite'):
return
if message['type'] == 'groupchat':
return
# Differentiate both type of messages, and call the appropriate handler.
- jid_from = message['from']
- for tab in self.core.get_tabs(tabs.MucTab):
- if tab.name == jid_from.bare:
- if jid_from.resource:
- self.on_groupchat_private_message(message, sent=False)
- return
- self.on_normal_message(message)
+ if self.is_known_muc_pm(message, message['from']):
+ await self.on_groupchat_private_message(message, sent=False)
+ else:
+ await self.on_normal_message(message)
- def on_error_message(self, message):
+ async def on_encrypted_message(self, message: Message):
+ """
+ When receiving an encrypted message
+ """
+ if message["body"]:
+ return # Already being handled by on_message.
+ await self.on_message(message)
+
+ async def on_error_message(self, message: Message):
"""
When receiving any message with type="error"
"""
jid_from = message['from']
for tab in self.core.get_tabs(tabs.MucTab):
- if tab.name == jid_from.bare:
+ if tab.jid.bare == jid_from.bare:
if jid_from.full == jid_from.bare:
self.core.room_error(message, jid_from.bare)
else:
- text = self.core.get_error_message(message)
+ text = get_error_message(message)
p_tab = self.core.tabs.by_name_and_class(
jid_from.full, tabs.PrivateTab)
if p_tab:
@@ -260,17 +303,17 @@ class HandlerCore:
self.core.information(text, 'Error')
return
tab = self.core.get_conversation_by_jid(message['from'], create=False)
- error_msg = self.core.get_error_message(message, deprecated=True)
+ error_msg = get_error_message(message, deprecated=True)
if not tab:
self.core.information(error_msg, 'Error')
return
error = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_CHAR_NACK),
error_msg)
if not tab.nack_message('\n' + error, message['id'], message['to']):
- tab.add_message(error, typ=0)
+ tab.add_message(InfoMessage(error))
self.core.refresh_window()
- def on_normal_message(self, message):
+ async def on_normal_message(self, message: Message):
"""
When receiving "normal" messages (not a private message from a
muc participant)
@@ -284,94 +327,36 @@ class HandlerCore:
use_xhtml = config.get_by_tabname('enable_xhtml_im',
message['from'].bare)
tmp_dir = get_image_cache()
- body = xhtml.get_body_from_message_stanza(
- message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
- if not body:
+ if not xhtml.get_body_from_message_stanza(
+ message, use_xhtml=use_xhtml, extract_images_to=tmp_dir):
if not self.core.xmpp.plugin['xep_0380'].has_eme(message):
return
self.core.xmpp.plugin['xep_0380'].replace_body_with_eme(message)
- body = message['body']
- remote_nick = ''
# normal message, we are the recipient
if message['to'].bare == self.core.xmpp.boundjid.bare:
conv_jid = message['from']
- jid = conv_jid
- color = get_theme().COLOR_REMOTE_USER
- # check for a name
- if conv_jid.bare in roster:
- remote_nick = roster[conv_jid.bare].name
- # check for a received nick
- if not remote_nick and config.get('enable_user_nick'):
- if message.xml.find(
- '{http://jabber.org/protocol/nick}nick') is not None:
- remote_nick = message['nick']['nick']
- if not remote_nick:
- remote_nick = conv_jid.user
- if not remote_nick:
- remote_nick = conv_jid.full
own = False
# we wrote the message (happens with carbons)
elif message['from'].bare == self.core.xmpp.boundjid.bare:
conv_jid = message['to']
- jid = self.core.xmpp.boundjid
- color = get_theme().COLOR_OWN_NICK
- remote_nick = self.core.own_nick
own = True
# we are not part of that message, drop it
else:
return
- conversation = self.core.get_conversation_by_jid(conv_jid, create=True)
- if isinstance(conversation,
- tabs.DynamicConversationTab) and conv_jid.resource:
- conversation.lock(conv_jid.resource)
-
- if not own and not conversation.nick:
- conversation.nick = remote_nick
- elif not own:
- remote_nick = conversation.get_nick()
-
- if not own:
- conversation.last_remote_message = datetime.now()
-
- self.core.events.trigger('conversation_msg', message, conversation)
- if not message['body']:
- return
- body = xhtml.get_body_from_message_stanza(
- message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
- delayed, date = common.find_delayed_tag(message)
-
- def try_modify():
- if message.xml.find('{urn:xmpp:message-correct:0}replace') is None:
- return False
- replaced_id = message['replace']['id']
- if replaced_id and config.get_by_tabname('group_corrections',
- conv_jid.bare):
- try:
- conversation.modify_message(
- body,
- replaced_id,
- message['id'],
- jid=jid,
- nickname=remote_nick)
- return True
- except CorrectionError:
- log.debug('Unable to correct a message', exc_info=True)
- return False
+ conversation = self.core.get_conversation_by_jid(conv_jid, create=False)
+ if conversation is None:
+ conversation = tabs.DynamicConversationTab(
+ self.core,
+ JID(conv_jid.bare),
+ initial=message,
+ )
+ self.core.tabs.append(conversation)
+ else:
+ await conversation.handle_message(message)
- if not try_modify():
- conversation.add_message(
- body,
- date,
- nickname=remote_nick,
- nick_color=color,
- history=delayed,
- identifier=message['id'],
- jid=jid,
- typ=1)
-
- if not own and 'private' in config.get('beep_on').split():
+ if not own and 'private' in config.getstr('beep_on').split():
if not config.get_by_tabname('disable_beep', conv_jid.bare):
curses.beep()
if self.core.tabs.current_tab is not conversation:
@@ -384,7 +369,7 @@ class HandlerCore:
else:
self.core.refresh_window()
- async def on_0084_avatar(self, msg):
+ async def on_0084_avatar(self, msg: Message):
jid = msg['from'].bare
contact = roster[jid]
if not contact:
@@ -434,7 +419,7 @@ class HandlerCore:
exc_info=True)
return
- async def on_vcard_avatar(self, pres):
+ async def on_vcard_avatar(self, pres: Presence):
jid = pres['from'].bare
contact = roster[jid]
if not contact:
@@ -470,9 +455,9 @@ class HandlerCore:
log.debug(
'Failed writing %s’s avatar to cache:', jid, exc_info=True)
- def on_nick_received(self, message):
+ async def on_nick_received(self, message: Message):
"""
- Called when a pep notification for an user nickname
+ Called when a pep notification for a user nickname
is received
"""
contact = roster[message['from'].bare]
@@ -484,177 +469,10 @@ class HandlerCore:
else:
contact.name = ''
- def on_gaming_event(self, message):
- """
- Called when a pep notification for user gaming
- is received
- """
- contact = roster[message['from'].bare]
- if not contact:
- return
- item = message['pubsub_event']['items']['item']
- old_gaming = contact.gaming
- if item.xml.find('{urn:xmpp:gaming:0}gaming') is not None:
- item = item['gaming']
- # only name and server_address are used for now
- contact.gaming = {
- 'character_name': item['character_name'],
- 'character_profile': item['character_profile'],
- 'name': item['name'],
- 'level': item['level'],
- 'uri': item['uri'],
- 'server_name': item['server_name'],
- 'server_address': item['server_address'],
- }
- else:
- contact.gaming = {}
-
- if contact.gaming:
- logger.log_roster_change(
- contact.bare_jid, 'is playing %s' %
- (common.format_gaming_string(contact.gaming)))
-
- if old_gaming != contact.gaming and config.get_by_tabname(
- 'display_gaming_notifications', contact.bare_jid):
- if contact.gaming:
- self.core.information(
- '%s is playing %s' % (contact.bare_jid,
- common.format_gaming_string(
- contact.gaming)), 'Gaming')
- else:
- self.core.information(contact.bare_jid + ' stopped playing.',
- 'Gaming')
-
- def on_mood_event(self, message):
- """
- Called when a pep notification for an user mood
- is received.
- """
- contact = roster[message['from'].bare]
- if not contact:
- return
- roster.modified()
- item = message['pubsub_event']['items']['item']
- old_mood = contact.mood
- if item.xml.find('{http://jabber.org/protocol/mood}mood') is not None:
- mood = item['mood']['value']
- if mood:
- mood = pep.MOODS.get(mood, mood)
- text = item['mood']['text']
- if text:
- mood = '%s (%s)' % (mood, text)
- contact.mood = mood
- else:
- contact.mood = ''
- else:
- contact.mood = ''
-
- if contact.mood:
- logger.log_roster_change(contact.bare_jid,
- 'has now the mood: %s' % contact.mood)
-
- if old_mood != contact.mood and config.get_by_tabname(
- 'display_mood_notifications', contact.bare_jid):
- if contact.mood:
- self.core.information(
- 'Mood from ' + contact.bare_jid + ': ' + contact.mood,
- 'Mood')
- else:
- self.core.information(
- contact.bare_jid + ' stopped having his/her mood.', 'Mood')
-
- def on_activity_event(self, message):
- """
- Called when a pep notification for an user activity
- is received.
- """
- contact = roster[message['from'].bare]
- if not contact:
- return
- roster.modified()
- item = message['pubsub_event']['items']['item']
- old_activity = contact.activity
- if item.xml.find(
- '{http://jabber.org/protocol/activity}activity') is not None:
- try:
- activity = item['activity']['value']
- except ValueError:
- return
- if activity[0]:
- general = pep.ACTIVITIES.get(activity[0])
- s = general['category']
- if activity[1]:
- s = s + '/' + general.get(activity[1], 'other')
- text = item['activity']['text']
- if text:
- s = '%s (%s)' % (s, text)
- contact.activity = s
- else:
- contact.activity = ''
- else:
- contact.activity = ''
-
- if contact.activity:
- logger.log_roster_change(
- contact.bare_jid, 'has now the activity %s' % contact.activity)
-
- if old_activity != contact.activity and config.get_by_tabname(
- 'display_activity_notifications', contact.bare_jid):
- if contact.activity:
- self.core.information(
- 'Activity from ' + contact.bare_jid + ': ' +
- contact.activity, 'Activity')
- else:
- self.core.information(
- contact.bare_jid + ' stopped doing his/her activity.',
- 'Activity')
-
- def on_tune_event(self, message):
- """
- Called when a pep notification for an user tune
- is received
- """
- contact = roster[message['from'].bare]
- if not contact:
- return
- roster.modified()
- item = message['pubsub_event']['items']['item']
- old_tune = contact.tune
- if item.xml.find('{http://jabber.org/protocol/tune}tune') is not None:
- item = item['tune']
- contact.tune = {
- 'artist': item['artist'],
- 'length': item['length'],
- 'rating': item['rating'],
- 'source': item['source'],
- 'title': item['title'],
- 'track': item['track'],
- 'uri': item['uri']
- }
- else:
- contact.tune = {}
-
- if contact.tune:
- logger.log_roster_change(
- message['from'].bare, 'is now listening to %s' %
- common.format_tune_string(contact.tune))
-
- if old_tune != contact.tune and config.get_by_tabname(
- 'display_tune_notifications', contact.bare_jid):
- if contact.tune:
- self.core.information(
- 'Tune from ' + message['from'].bare + ': ' +
- common.format_tune_string(contact.tune), 'Tune')
- else:
- self.core.information(
- contact.bare_jid + ' stopped listening to music.', 'Tune')
-
- def on_groupchat_message(self, message):
+ async def on_groupchat_message(self, message: Message) -> None:
"""
Triggered whenever a message is received from a multi-user chat room.
"""
- if message['subject']:
- return
room_from = message['from'].bare
if message['type'] == 'error': # Check if it's an error
@@ -668,88 +486,33 @@ class HandlerCore:
muc.leave_groupchat(
self.core.xmpp, room_from, self.core.own_nick, msg='')
return
-
- nick_from = message['mucnick']
- user = tab.get_user_by_name(nick_from)
- if user and user in tab.ignores:
- return
-
- self.core.events.trigger('muc_msg', message, tab)
- use_xhtml = config.get_by_tabname('enable_xhtml_im', room_from)
- tmp_dir = get_image_cache()
- body = xhtml.get_body_from_message_stanza(
- message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
- if not body:
- return
-
- old_state = tab.state
- delayed, date = common.find_delayed_tag(message)
- replaced = False
- if message.xml.find('{urn:xmpp:message-correct:0}replace') is not None:
- replaced_id = message['replace']['id']
- if replaced_id is not '' and config.get_by_tabname(
- 'group_corrections', message['from'].bare):
- try:
- delayed_date = date or datetime.now()
- if tab.modify_message(
- body,
- replaced_id,
- message['id'],
- time=delayed_date,
- nickname=nick_from,
- user=user):
- self.core.events.trigger('highlight', message, tab)
- replaced = True
- except CorrectionError:
- log.debug('Unable to correct a message', exc_info=True)
- if not replaced and tab.add_message(
- body,
- date,
- nick_from,
- history=delayed,
- identifier=message['id'],
- jid=message['from'],
- typ=1):
- self.core.events.trigger('highlight', message, tab)
-
- if message['from'].resource == tab.own_nick:
- tab.last_sent_message = message
-
- if tab is self.core.tabs.current_tab:
- tab.text_win.refresh()
- tab.info_header.refresh(tab, tab.text_win, user=tab.own_user)
- tab.input.refresh()
- self.core.doupdate()
- elif tab.state != old_state:
- self.core.refresh_tab_win()
- current = self.core.tabs.current_tab
- if hasattr(current, 'input') and current.input:
- current.input.refresh()
- self.core.doupdate()
-
- if 'message' in config.get('beep_on').split():
+ valid_message = await tab.handle_message(message)
+ if valid_message and 'message' in config.getstr('beep_on').split():
if (not config.get_by_tabname('disable_beep', room_from)
and self.core.own_nick != message['from'].resource):
curses.beep()
- def on_muc_own_nickchange(self, muc):
+ def on_muc_own_nickchange(self, muc: tabs.MucTab):
"We changed our nick in a MUC"
for tab in self.core.get_tabs(tabs.PrivateTab):
if tab.parent_muc == muc:
tab.own_nick = muc.own_nick
- def on_groupchat_private_message(self, message, sent):
+ async def on_groupchat_private_message(self, message: Message, sent: bool):
"""
We received a Private Message (from someone in a Muc)
"""
jid = message['to'] if sent else message['from']
with_nick = jid.resource
if not with_nick:
- self.on_groupchat_message(message)
+ await self.on_groupchat_message(message)
return
room_from = jid.bare
- use_xhtml = config.get_by_tabname('enable_xhtml_im', jid.bare)
+ use_xhtml = config.get_by_tabname(
+ 'enable_xhtml_im',
+ jid.bare
+ )
tmp_dir = get_image_cache()
body = xhtml.get_body_from_message_stanza(
message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
@@ -757,57 +520,27 @@ class HandlerCore:
jid.full,
tabs.PrivateTab) # get the tab with the private conversation
ignore = config.get_by_tabname('ignore_private', room_from)
- if not tab: # It's the first message we receive: create the tab
- if body and not ignore:
- tab = self.core.open_private_window(room_from, with_nick,
- False)
- sender_nick = (tab.own_nick
- or self.core.own_nick) if sent else with_nick
if ignore and not sent:
- self.core.events.trigger('ignored_private', message, tab)
+ await self.core.events.trigger_async('ignored_private', message, tab)
msg = config.get_by_tabname('private_auto_response', room_from)
if msg and body:
self.core.xmpp.send_message(
mto=jid.full, mbody=msg, mtype='chat')
return
- self.core.events.trigger('private_msg', message, tab)
- body = xhtml.get_body_from_message_stanza(
- message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
- if not body or not tab:
- return
- replaced = False
- user = tab.parent_muc.get_user_by_name(with_nick)
- if message.xml.find('{urn:xmpp:message-correct:0}replace') is not None:
- replaced_id = message['replace']['id']
- if replaced_id is not '' and config.get_by_tabname(
- 'group_corrections', room_from):
- try:
- tab.modify_message(
- body,
- replaced_id,
- message['id'],
- user=user,
- jid=message['from'],
- nickname=sender_nick)
- replaced = True
- except CorrectionError:
- log.debug('Unable to correct a message', exc_info=True)
- if not replaced:
- tab.add_message(
- body,
- time=None,
- nickname=sender_nick,
- nick_color=get_theme().COLOR_OWN_NICK if sent else None,
- forced_user=user,
- identifier=message['id'],
- jid=message['from'],
- typ=1)
- if sent:
- tab.last_sent_message = msg
+ if tab is None: # It's the first message we receive: create the tab
+ if body and not ignore:
+ tab = tabs.PrivateTab(
+ self.core,
+ jid,
+ self.core.own_nick,
+ initial=message,
+ )
+ self.core.tabs.append(tab)
+ tab.parent_muc.privates.append(tab)
else:
- tab.last_remote_message = datetime.now()
+ await tab.handle_message(message)
- if not sent and 'private' in config.get('beep_on').split():
+ if not sent and 'private' in config.getstr('beep_on').split():
if not config.get_by_tabname('disable_beep', jid.full):
curses.beep()
if tab is self.core.tabs.current_tab:
@@ -818,37 +551,37 @@ class HandlerCore:
### Chatstates ###
- def on_chatstate_active(self, message):
- self._on_chatstate(message, "active")
+ async def on_chatstate_active(self, message: Message):
+ await self._on_chatstate(message, "active")
- def on_chatstate_inactive(self, message):
- self._on_chatstate(message, "inactive")
+ async def on_chatstate_inactive(self, message: Message):
+ await self._on_chatstate(message, "inactive")
- def on_chatstate_composing(self, message):
- self._on_chatstate(message, "composing")
+ async def on_chatstate_composing(self, message: Message):
+ await self._on_chatstate(message, "composing")
- def on_chatstate_paused(self, message):
- self._on_chatstate(message, "paused")
+ async def on_chatstate_paused(self, message: Message):
+ await self._on_chatstate(message, "paused")
- def on_chatstate_gone(self, message):
- self._on_chatstate(message, "gone")
+ async def on_chatstate_gone(self, message: Message):
+ await self._on_chatstate(message, "gone")
- def _on_chatstate(self, message, state):
+ async def _on_chatstate(self, message: Message, state: str):
if message['type'] == 'chat':
- if not self._on_chatstate_normal_conversation(message, state):
+ if not await self._on_chatstate_normal_conversation(message, state):
tab = self.core.tabs.by_name_and_class(message['from'].full,
tabs.PrivateTab)
if not tab:
return
- self._on_chatstate_private_conversation(message, state)
+ await self._on_chatstate_private_conversation(message, state)
elif message['type'] == 'groupchat':
- self.on_chatstate_groupchat_conversation(message, state)
+ await self.on_chatstate_groupchat_conversation(message, state)
- def _on_chatstate_normal_conversation(self, message, state):
+ async def _on_chatstate_normal_conversation(self, message: Message, state: str):
tab = self.core.get_conversation_by_jid(message['from'], False)
if not tab:
return False
- self.core.events.trigger('normal_chatstate', message, tab)
+ await self.core.events.trigger_async('normal_chatstate', message, tab)
tab.chatstate = state
if state == 'gone' and isinstance(tab, tabs.DynamicConversationTab):
tab.unlock()
@@ -860,7 +593,7 @@ class HandlerCore:
self.core.refresh_tab_win()
return True
- def _on_chatstate_private_conversation(self, message, state):
+ async def _on_chatstate_private_conversation(self, message: Message, state: str):
"""
Chatstate received in a private conversation from a MUC
"""
@@ -868,7 +601,7 @@ class HandlerCore:
tabs.PrivateTab)
if not tab:
return
- self.core.events.trigger('private_chatstate', message, tab)
+ await self.core.events.trigger_async('private_chatstate', message, tab)
tab.chatstate = state
if tab == self.core.tabs.current_tab:
tab.refresh_info_header()
@@ -877,7 +610,7 @@ class HandlerCore:
_composing_tab_state(tab, state)
self.core.refresh_tab_win()
- def on_chatstate_groupchat_conversation(self, message, state):
+ async def on_chatstate_groupchat_conversation(self, message: Message, state: str):
"""
Chatstate received in a MUC
"""
@@ -885,7 +618,7 @@ class HandlerCore:
room_from = message.get_mucroom()
tab = self.core.tabs.by_name_and_class(room_from, tabs.MucTab)
if tab and tab.get_user_by_name(nick):
- self.core.events.trigger('muc_chatstate', message, tab)
+ await self.core.events.trigger_async('muc_chatstate', message, tab)
tab.get_user_by_name(nick).chatstate = state
if tab == self.core.tabs.current_tab:
if not self.core.size.tab_degrade_x:
@@ -903,7 +636,7 @@ class HandlerCore:
return '%s: %s' % (error_condition,
error_text) if error_text else error_condition
- def on_version_result(self, iq):
+ def on_version_result(self, iq: Iq):
"""
Handle the result of a /version command.
"""
@@ -920,7 +653,7 @@ class HandlerCore:
'an unknown platform'))
self.core.information(version, 'Info')
- def on_bookmark_result(self, iq):
+ def on_bookmark_result(self, iq: Iq):
"""
Handle the result of a /bookmark commands.
"""
@@ -932,7 +665,7 @@ class HandlerCore:
### subscription-related handlers ###
- def on_roster_update(self, iq):
+ async def on_roster_update(self, iq: Iq):
"""
The roster was received.
"""
@@ -951,7 +684,7 @@ class HandlerCore:
if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab):
self.core.refresh_window()
- def on_subscription_request(self, presence):
+ async def on_subscription_request(self, presence: Presence):
"""subscribe received"""
jid = presence['from'].bare
contact = roster[jid]
@@ -974,7 +707,7 @@ class HandlerCore:
if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab):
self.core.refresh_window()
- def on_subscription_authorized(self, presence):
+ async def on_subscription_authorized(self, presence: Presence):
"""subscribed received"""
jid = presence['from'].bare
contact = roster[jid]
@@ -989,7 +722,7 @@ class HandlerCore:
if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab):
self.core.refresh_window()
- def on_subscription_remove(self, presence):
+ async def on_subscription_remove(self, presence: Presence):
"""unsubscribe received"""
jid = presence['from'].bare
contact = roster[jid]
@@ -1002,7 +735,7 @@ class HandlerCore:
if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab):
self.core.refresh_window()
- def on_subscription_removed(self, presence):
+ async def on_subscription_removed(self, presence: Presence):
"""unsubscribed received"""
jid = presence['from'].bare
contact = roster[jid]
@@ -1015,7 +748,7 @@ class HandlerCore:
contact.pending_out = False
else:
self.core.information(
- '%s does not want you to receive his/her/its status anymore.' %
+ '%s does not want you to receive their/its status anymore.' %
jid, 'Roster')
self.core.tabs.first().state = 'highlight'
if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab):
@@ -1023,9 +756,8 @@ class HandlerCore:
### Presence-related handlers ###
- def on_presence(self, presence):
- if presence.match('presence/muc') or presence.xml.find(
- '{http://jabber.org/protocol/muc#user}x') is not None:
+ async def on_presence(self, presence: Presence):
+ if presence.match('presence/muc'):
return
jid = presence['from']
contact = roster[jid.bare]
@@ -1039,8 +771,8 @@ class HandlerCore:
return
roster.modified()
contact.error = None
- self.core.events.trigger('normal_presence', presence,
- contact[jid.full])
+ await self.core.events.trigger_async('normal_presence', presence,
+ contact[jid.full])
tab = self.core.get_conversation_by_jid(jid, create=False)
if tab:
tab.update_status(
@@ -1051,21 +783,20 @@ class HandlerCore:
tab.refresh()
self.core.doupdate()
- def on_presence_error(self, presence):
+ async def on_presence_error(self, presence: Presence):
jid = presence['from']
contact = roster[jid.bare]
if not contact:
return
roster.modified()
- contact.error = presence['error']['type'] + ': ' + presence['error']['condition']
+ contact.error = presence['error']['text'] or presence['error']['type'] + ': ' + presence['error']['condition']
# TODO: reset chat states status on presence error
- def on_got_offline(self, presence):
+ async def on_got_offline(self, presence: Presence):
"""
A JID got offline
"""
- if presence.match('presence/muc') or presence.xml.find(
- '{http://jabber.org/protocol/muc#user}x') is not None:
+ if presence.match('presence/muc'):
return
jid = presence['from']
status = presence['status']
@@ -1093,12 +824,11 @@ class HandlerCore:
if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab):
self.core.refresh_window()
- def on_got_online(self, presence):
+ async def on_got_online(self, presence: Presence):
"""
A JID got online
"""
- if presence.match('presence/muc') or presence.xml.find(
- '{http://jabber.org/protocol/muc#user}x') is not None:
+ if presence.match('presence/muc'):
return
jid = presence['from']
contact = roster[jid.bare]
@@ -1115,7 +845,7 @@ class HandlerCore:
'status': presence['status'],
'show': presence['show'],
})
- self.core.events.trigger('normal_presence', presence, resource)
+ await self.core.events.trigger_async('normal_presence', presence, resource)
name = contact.name if contact.name else jid.bare
self.core.add_information_message_to_conversation_tab(
jid.full, '\x195}%s is \x194}online' % name)
@@ -1133,7 +863,7 @@ class HandlerCore:
if isinstance(self.core.tabs.current_tab, tabs.RosterInfoTab):
self.core.refresh_window()
- def on_groupchat_presence(self, presence):
+ async def on_groupchat_presence(self, presence: Presence):
"""
Triggered whenever a presence stanza is received from a user in a multi-user chat room.
Display the presence on the room window and update the
@@ -1142,44 +872,63 @@ class HandlerCore:
from_room = presence['from'].bare
tab = self.core.tabs.by_name_and_class(from_room, tabs.MucTab)
if tab:
- self.core.events.trigger('muc_presence', presence, tab)
+ await self.core.events.trigger_async('muc_presence', presence, tab)
tab.handle_presence(presence)
### Connection-related handlers ###
- def on_failed_connection(self, error):
+ async def on_failed_connection(self, error: str):
"""
We cannot contact the remote server
"""
self.core.information(
"Connection to remote server failed: %s" % (error, ), 'Error')
+ async def on_session_end(self, event):
+ """
+ Called when a session is terminated (e.g. due to a manual disconnect or a 0198 resume fail)
+ """
+ roster.connected = 0
+ roster.modified()
+ for tab in self.core.get_tabs(tabs.MucTab):
+ tab.disconnect()
+
+ async def on_session_resumed(self, event):
+ """
+ Called when a session is successfully resumed by 0198
+ """
+ self.core.information("Resumed session as %s" % self.core.xmpp.boundjid.full, 'Info')
+ self.core.xmpp.plugin['xep_0199'].enable_keepalive()
+
async def on_disconnected(self, event):
"""
When we are disconnected from remote server
"""
- if 'disconnect' in config.get('beep_on').split():
+ if 'disconnect' in config.getstr('beep_on').split():
curses.beep()
- roster.connected = 0
# Stop the ping plugin. It would try to send stanza on regular basis
self.core.xmpp.plugin['xep_0199'].disable_keepalive()
- roster.modified()
- for tab in self.core.get_tabs(tabs.MucTab):
- tab.disconnect()
msg_typ = 'Error' if not self.core.legitimate_disconnect else 'Info'
- self.core.information("Disconnected from server.", msg_typ)
- if self.core.legitimate_disconnect or not config.get(
- 'auto_reconnect', True):
+ self.core.information("Disconnected from server%s." % (event and ": %s" % event or ""), msg_typ)
+ if self.core.legitimate_disconnect or not config.getbool(
+ 'auto_reconnect'):
return
if (self.core.last_stream_error
and self.core.last_stream_error[1]['condition'] in (
'conflict', 'host-unknown')):
return
await asyncio.sleep(1)
- self.core.information("Auto-reconnecting.", 'Info')
- self.core.xmpp.start()
+ if not self.core.xmpp.is_connecting() and not self.core.xmpp.is_connected():
+ self.core.information("Auto-reconnecting.", 'Info')
+ self.core.xmpp.start()
- def on_stream_error(self, event):
+ async def on_reconnect_delay(self, event):
+ """
+ When the reconnection is delayed
+ """
+ self.core.information("Reconnecting in %d seconds..." % (event), 'Info')
+
+ async def on_stream_error(self, event):
"""
When we receive a stream error
"""
@@ -1188,7 +937,7 @@ class HandlerCore:
if event:
self.core.last_stream_error = (time.time(), event)
- def on_failed_all_auth(self, event):
+ async def on_failed_all_auth(self, event):
"""
Authentication failed
"""
@@ -1196,7 +945,7 @@ class HandlerCore:
'Error')
self.core.legitimate_disconnect = True
- def on_no_auth(self, event):
+ async def on_no_auth(self, event):
"""
Authentication failed (no mech)
"""
@@ -1204,14 +953,14 @@ class HandlerCore:
"Authentication failed, no login method available.", 'Error')
self.core.legitimate_disconnect = True
- def on_connected(self, event):
+ async def on_connected(self, event):
"""
Remote host responded, but we are not yet authenticated
"""
self.core.information("Connected to server.", 'Info')
self.core.legitimate_disconnect = False
- def on_session_start(self, event):
+ async def on_session_start(self, event):
"""
Called when we are connected and authenticated
"""
@@ -1226,26 +975,26 @@ class HandlerCore:
self.core.xmpp.get_roster()
roster.update_contact_groups(self.core.xmpp.boundjid.bare)
# send initial presence
- if config.get('send_initial_presence'):
+ if config.getbool('send_initial_presence'):
pres = self.core.xmpp.make_presence()
pres['show'] = self.core.status.show
pres['status'] = self.core.status.message
- self.core.events.trigger('send_normal_presence', pres)
+ await self.core.events.trigger_async('send_normal_presence', pres)
pres.send()
self.core.bookmarks.get_local()
# join all the available bookmarks. As of yet, this is just the local ones
- self.core.join_initial_rooms(self.core.bookmarks)
+ self.core.join_initial_rooms(self.core.bookmarks.local())
- if config.get('enable_user_nick'):
+ if config.getbool('enable_user_nick'):
self.core.xmpp.plugin['xep_0172'].publish_nick(
nick=self.core.own_nick, callback=dumb_callback)
- asyncio.ensure_future(self.core.xmpp.plugin['xep_0115'].update_caps())
+ asyncio.create_task(self.core.xmpp.plugin['xep_0115'].update_caps())
# Start the ping's plugin regular event
self.core.xmpp.set_keepalive_values()
### Other handlers ###
- def on_status_codes(self, message):
+ async def on_status_codes(self, message: Message):
"""
Handle groupchat messages with status codes.
Those are received when a room configuration change occurs.
@@ -1270,76 +1019,61 @@ class HandlerCore:
semi_anon = '173' in status_codes
full_anon = '174' in status_codes
modif = False
+ info_col = {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)}
if show_unavailable or hide_unavailable or non_priv or logging_off\
or non_anon or semi_anon or full_anon:
tab.add_message(
- '\x19%(info_col)s}Info: A configuration change not privacy-related occurred.'
- % {
- 'info_col': dump_tuple(
- get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ 'Info: A configuration change not privacy-related occurred.'
+ ),
+ )
modif = True
if show_unavailable:
tab.add_message(
- '\x19%(info_col)s}Info: The unavailable members are now shown.'
- % {
- 'info_col': dump_tuple(
- get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ 'Info: The unavailable members are now shown.'
+ ),
+ )
elif hide_unavailable:
tab.add_message(
- '\x19%(info_col)s}Info: The unavailable members are now hidden.'
- % {
- 'info_col': dump_tuple(
- get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ 'Info: The unavailable members are now hidden.',
+ ),
+ )
if non_anon:
tab.add_message(
- '\x191}Warning:\x19%(info_col)s} The room is now not anonymous. (public JID)'
- % {
- 'info_col': dump_tuple(
- get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ '\x191}Warning:\x19%(info_col)s} The room is now not anonymous. (public JID)' % info_col
+ ),
+ )
elif semi_anon:
tab.add_message(
- '\x19%(info_col)s}Info: The room is now semi-anonymous. (moderators-only JID)'
- % {
- 'info_col': dump_tuple(
- get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ 'Info: The room is now semi-anonymous. (moderators-only JID)',
+ ),
+ )
elif full_anon:
tab.add_message(
- '\x19%(info_col)s}Info: The room is now fully anonymous.' %
- {
- 'info_col': dump_tuple(
- get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ 'Info: The room is now fully anonymous.',
+ ),
+ )
if logging_on:
tab.add_message(
- '\x191}Warning: \x19%(info_col)s}This room is publicly logged'
- % {
- 'info_col': dump_tuple(
- get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ '\x191}Warning: \x19%(info_col)s}This room is publicly logged' % info_col
+ ),
+ )
elif logging_off:
tab.add_message(
- '\x19%(info_col)s}Info: This room is not logged anymore.' %
- {
- 'info_col': dump_tuple(
- get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ 'Info: This room is not logged anymore.',
+ ),
+ )
if modif:
self.core.refresh_window()
- def on_groupchat_subject(self, message):
+ async def on_groupchat_subject(self, message: Message):
"""
Triggered when the topic is changed.
"""
@@ -1347,16 +1081,19 @@ class HandlerCore:
room_from = message.get_mucroom()
tab = self.core.tabs.by_name_and_class(room_from, tabs.MucTab)
subject = message['subject']
+ time = message['delay']['stamp']
if subject is None or not tab:
return
if subject != tab.topic:
# Do not display the message if the subject did not change or if we
# receive an empty topic when joining the room.
+ theme = get_theme()
fmt = {
- 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
- 'text_col': dump_tuple(get_theme().COLOR_NORMAL_TEXT),
+ 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT),
+ 'text_col': dump_tuple(theme.COLOR_NORMAL_TEXT),
'subject': subject,
'user': '',
+ 'str_time': time,
}
if nick_from:
user = tab.get_user_by_name(nick_from)
@@ -1375,23 +1112,25 @@ class HandlerCore:
if nick_from:
tab.add_message(
- "%(user)s set the subject to: \x19%(text_col)s}%(subject)s"
- % fmt,
- time=None,
- typ=2)
+ PersistentInfoMessage(
+ "%(user)s set the subject to: \x19%(text_col)s}%(subject)s" % fmt,
+ time=time,
+ ),
+ )
else:
tab.add_message(
- "\x19%(info_col)s}The subject is: \x19%(text_col)s}%(subject)s"
- % fmt,
- time=None,
- typ=2)
+ PersistentInfoMessage(
+ "The subject is: \x19%(text_col)s}%(subject)s" % fmt,
+ time=time,
+ ),
+ )
tab.topic = subject
tab.topic_from = nick_from
if self.core.tabs.by_name_and_class(
room_from, tabs.MucTab) is self.core.tabs.current_tab:
self.core.refresh_window()
- def on_receipt(self, message):
+ async def on_receipt(self, message):
"""
When a delivery receipt is received (XEP-0184)
"""
@@ -1413,60 +1152,62 @@ class HandlerCore:
except AckError:
log.debug('Error while receiving an ack', exc_info=True)
- def on_data_form(self, message):
+ async def on_data_form(self, message: Message):
"""
When a data form is received
"""
self.core.information(str(message))
- def on_attention(self, message):
+ async def on_attention(self, message: Message):
"""
Attention probe received.
"""
jid_from = message['from']
self.core.information('%s requests your attention!' % jid_from, 'Info')
- for tab in self.core.tabs:
- if tab.name == jid_from:
- tab.state = 'attention'
- self.core.refresh_tab_win()
- return
- for tab in self.core.tabs:
- if tab.name == jid_from.bare:
- tab.state = 'attention'
- self.core.refresh_tab_win()
- return
- self.core.information('%s tab not found.' % jid_from, 'Error')
+ tab = (
+ self.core.tabs.by_name_and_class(
+ jid_from.full, tabs.ChatTab
+ ) or self.core.tabs.by_name_and_class(
+ jid_from.bare, tabs.ChatTab
+ )
+ )
+ if tab and tab is not self.core.tabs.current_tab:
+ tab.state = "attention"
+ self.core.refresh_tab_win()
- def outgoing_stanza(self, stanza):
+ def outgoing_stanza(self, stanza: StanzaBase):
"""
We are sending a new stanza, write it in the xml buffer if needed.
"""
if self.core.xml_tab:
+ stanza_str = str(stanza)
if PYGMENTS:
- xhtml_text = highlight(str(stanza), LEXER, FORMATTER)
+ xhtml_text = highlight(stanza_str, LEXER, FORMATTER)
poezio_colored = xhtml.xhtml_to_poezio_colors(
xhtml_text, force=True).rstrip('\x19o').strip()
else:
- poezio_colored = str(stanza)
- self.core.add_message_to_text_buffer(
- self.core.xml_buffer,
- poezio_colored,
- nickname=get_theme().CHAR_XML_OUT)
+ poezio_colored = stanza_str
+ self.core.xml_buffer.add_message(
+ XMLLog(txt=poezio_colored, incoming=False),
+ )
try:
if self.core.xml_tab.match_stanza(
- ElementBase(ET.fromstring(stanza))):
- self.core.add_message_to_text_buffer(
- self.core.xml_tab.filtered_buffer,
- poezio_colored,
- nickname=get_theme().CHAR_XML_OUT)
+ ElementBase(ET.fromstring(stanza_str))):
+ self.core.xml_tab.filtered_buffer.add_message(
+ XMLLog(txt=poezio_colored, incoming=False),
+ )
except:
+ # Most of the time what gets logged is whitespace pings. Skip.
+ # And also skip tab updates.
+ if stanza_str.strip() == '':
+ return None
log.debug('', exc_info=True)
if isinstance(self.core.tabs.current_tab, tabs.XMLTab):
self.core.tabs.current_tab.refresh()
self.core.doupdate()
- def incoming_stanza(self, stanza):
+ def incoming_stanza(self, stanza: StanzaBase):
"""
We are receiving a new stanza, write it in the xml buffer if needed.
"""
@@ -1477,16 +1218,14 @@ class HandlerCore:
xhtml_text, force=True).rstrip('\x19o').strip()
else:
poezio_colored = str(stanza)
- self.core.add_message_to_text_buffer(
- self.core.xml_buffer,
- poezio_colored,
- nickname=get_theme().CHAR_XML_IN)
+ self.core.xml_buffer.add_message(
+ XMLLog(txt=poezio_colored, incoming=True),
+ )
try:
if self.core.xml_tab.match_stanza(stanza):
- self.core.add_message_to_text_buffer(
- self.core.xml_tab.filtered_buffer,
- poezio_colored,
- nickname=get_theme().CHAR_XML_IN)
+ self.core.xml_tab.filtered_buffer.add_message(
+ XMLLog(txt=poezio_colored, incoming=True),
+ )
except:
log.debug('', exc_info=True)
if isinstance(self.core.tabs.current_tab, tabs.XMLTab):
@@ -1525,19 +1264,24 @@ class HandlerCore:
self.core.add_tab(confirm_tab, True)
self.core.doupdate()
+ # handle resize
+ prev_value = signal.signal(signal.SIGWINCH, self.core.sigwinch_handler)
while not confirm_tab.done:
- sel = select.select([sys.stdin], [], [], 5)[0]
-
- if sel:
- self.core.on_input_readable()
+ try:
+ sel = select.select([sys.stdin], [], [], 0.5)[0]
+ if sel:
+ self.core.on_input_readable()
+ except:
+ continue
+ signal.signal(signal.SIGWINCH, prev_value)
def validate_ssl(self, pem):
"""
Check the server certificate using the slixmpp ssl_cert event
"""
- if config.get('ignore_certificate'):
+ if config.getbool('ignore_certificate'):
return
- cert = config.get('certificate')
+ cert = config.getstr('certificate')
# update the cert representation when it uses the old one
if cert and ':' not in cert:
cert = ':'.join(
@@ -1646,7 +1390,7 @@ class HandlerCore:
def adhoc_error(self, iq, adhoc_session):
self.core.xmpp.plugin['xep_0050'].terminate_command(adhoc_session)
- error_message = self.core.get_error_message(iq)
+ error_message = get_error_message(iq)
self.core.information(
"An error occurred while executing the command: %s" %
(error_message), 'Error')
@@ -1679,7 +1423,7 @@ def _composing_tab_state(tab, state):
else:
return # should not happen
- show = config.get('show_composing_tabs')
+ show = config.getstr('show_composing_tabs').lower()
show = show in values
if tab.state != 'composing' and state == 'composing':
diff --git a/poezio/core/structs.py b/poezio/core/structs.py
index 72c9628a..31d31339 100644
--- a/poezio/core/structs.py
+++ b/poezio/core/structs.py
@@ -1,45 +1,20 @@
"""
Module defining structures useful to the core class and related methods
"""
+from __future__ import annotations
+from dataclasses import dataclass
+from typing import Any, Callable, List, TYPE_CHECKING, Optional
+
+if TYPE_CHECKING:
+ from poezio import windows
__all__ = [
- 'ERROR_AND_STATUS_CODES', 'DEPRECATED_ERRORS', 'POSSIBLE_SHOW', 'Status',
- 'Command', 'Completion'
+ 'Command',
+ 'Completion',
+ 'POSSIBLE_SHOW',
+ 'Status',
]
-# http://xmpp.org/extensions/xep-0045.html#errorstatus
-ERROR_AND_STATUS_CODES = {
- '401': 'A password is required',
- '403': 'Permission denied',
- '404': 'The room doesn’t exist',
- '405': 'Your are not allowed to create a new room',
- '406': 'A reserved nick must be used',
- '407': 'You are not in the member list',
- '409': 'This nickname is already in use or has been reserved',
- '503': 'The maximum number of users has been reached',
-}
-
-# http://xmpp.org/extensions/xep-0086.html
-DEPRECATED_ERRORS = {
- '302': 'Redirect',
- '400': 'Bad request',
- '401': 'Not authorized',
- '402': 'Payment required',
- '403': 'Forbidden',
- '404': 'Not found',
- '405': 'Not allowed',
- '406': 'Not acceptable',
- '407': 'Registration required',
- '408': 'Request timeout',
- '409': 'Conflict',
- '500': 'Internal server error',
- '501': 'Feature not implemented',
- '502': 'Remote server error',
- '503': 'Service unavailable',
- '504': 'Remote server timeout',
- '510': 'Disconnected',
-}
-
POSSIBLE_SHOW = {
'available': None,
'chat': 'chat',
@@ -51,23 +26,11 @@ POSSIBLE_SHOW = {
}
+@dataclass
class Status:
__slots__ = ('show', 'message')
-
- def __init__(self, show, message):
- self.show = show
- self.message = message
-
-
-class Command:
- __slots__ = ('func', 'desc', 'comp', 'short_desc', 'usage')
-
- def __init__(self, func, desc, comp, short_desc, usage):
- self.func = func
- self.desc = desc
- self.comp = comp
- self.short_desc = short_desc
- self.usage = usage
+ show: str
+ message: str
class Completion:
@@ -76,7 +39,13 @@ class Completion:
"""
__slots__ = ['func', 'args', 'kwargs', 'comp_list']
- def __init__(self, func, comp_list, *args, **kwargs):
+ def __init__(
+ self,
+ func: Callable[..., Any],
+ comp_list: List[str],
+ *args: Any,
+ **kwargs: Any
+ ) -> None:
self.func = func
self.comp_list = comp_list
self.args = args
@@ -84,3 +53,13 @@ class Completion:
def run(self):
return self.func(self.comp_list, *self.args, **self.kwargs)
+
+
+@dataclass
+class Command:
+ __slots__ = ('func', 'desc', 'comp', 'short_desc', 'usage')
+ func: Callable[..., Any]
+ desc: str
+ comp: Optional[Callable[['windows.Input'], Completion]]
+ short_desc: str
+ usage: str
diff --git a/poezio/core/tabs.py b/poezio/core/tabs.py
index 3ced7a7e..6d0589ba 100644
--- a/poezio/core/tabs.py
+++ b/poezio/core/tabs.py
@@ -24,11 +24,14 @@ have become [0|1|2|3], with the tab "4" renumbered to "3" if gap tabs are
disabled.
"""
-from typing import List, Dict, Type, Optional, Union
+from typing import List, Dict, Type, Optional, Union, Tuple, TypeVar, cast
from collections import defaultdict
+from slixmpp import JID
from poezio import tabs
from poezio.events import EventHandler
+T = TypeVar('T', bound=tabs.Tab)
+
class Tabs:
"""
@@ -38,28 +41,29 @@ class Tabs:
'_current_index',
'_current_tab',
'_tabs',
+ '_tab_jids',
'_tab_types',
'_tab_names',
'_previous_tab',
'_events',
]
- def __init__(self, events: EventHandler) -> None:
+ def __init__(self, events: EventHandler, initial_tab: tabs.Tab) -> None:
"""
Initialize the Tab List. Even though the list is initially
empty, all methods are only valid once append() has been called
once. Otherwise, mayhem is expected.
"""
# cursor
- self._current_index = 0 # type: int
- self._current_tab = None # type: Optional[tabs.Tab]
+ self._current_index: int = 0
+ self._current_tab: tabs.Tab = initial_tab
- self._previous_tab = None # type: Optional[tabs.Tab]
- self._tabs = [] # type: List[tabs.Tab]
- self._tab_types = defaultdict(
- list) # type: Dict[Type[tabs.Tab], List[tabs.Tab]]
- self._tab_names = dict() # type: Dict[str, tabs.Tab]
- self._events = events # type: EventHandler
+ self._previous_tab: Optional[tabs.Tab] = None
+ self._tabs: List[tabs.Tab] = []
+ self._tab_jids: Dict[JID, tabs.Tab] = dict()
+ self._tab_types: Dict[Type[tabs.Tab], List[tabs.Tab]] = defaultdict(list)
+ self._tab_names: Dict[str, tabs.Tab] = dict()
+ self._events: EventHandler = events
def __len__(self):
return len(self._tabs)
@@ -89,7 +93,7 @@ class Tabs:
return False
@property
- def current_tab(self) -> Optional[tabs.Tab]:
+ def current_tab(self) -> tabs.Tab:
"""Current tab"""
return self._current_tab
@@ -111,13 +115,17 @@ class Tabs:
"""Return the tab list"""
return self._tabs
+ def by_jid(self, jid: JID) -> Optional[tabs.Tab]:
+ """Get a tab with a specific jid"""
+ return self._tab_jids.get(jid)
+
def by_name(self, name: str) -> Optional[tabs.Tab]:
"""Get a tab with a specific name"""
return self._tab_names.get(name)
- def by_class(self, cls: Type[tabs.Tab]) -> List[tabs.Tab]:
+ def by_class(self, cls: Type[T]) -> List[T]:
"""Get all the tabs of a class"""
- return self._tab_types.get(cls, [])
+ return cast(List[T], self._tab_types.get(cls, []))
def find_match(self, name: str) -> Optional[tabs.Tab]:
"""Get a tab using extended matching (tab.matching_name())"""
@@ -132,21 +140,60 @@ class Tabs:
return self._tabs[i]
return None
- def by_name_and_class(self, name: str,
- cls: Type[tabs.Tab]) -> Optional[tabs.Tab]:
+ def find_by_unique_prefix(self, prefix: str) -> Tuple[bool, Optional[tabs.Tab]]:
+ """
+ Get a tab by its unique name prefix, ignoring case.
+
+ :return: A tuple indicating the presence of any match, as well as the
+ uniquely matched tab (if any).
+
+ The first element, a boolean, in the returned tuple indicates whether
+ at least one tab matched.
+
+ The second element (a Tab) in the returned tuple is the uniquely
+ matched tab, if any. If multiple or no tabs match the prefix, the
+ second element in the tuple is :data:`None`.
+ """
+
+ # TODO: should this maybe use something smarter than .lower()?
+ # something something stringprep?
+ prefix = prefix.lower()
+ candidate = None
+ any_matched = False
+ for tab in self._tabs:
+ if not tab.name.lower().startswith(prefix):
+ continue
+ any_matched = True
+ if candidate is not None:
+ # multiple tabs match -> return None
+ return True, None
+ candidate = tab
+
+ return any_matched, candidate
+
+ def by_name_and_class(self, name: Union[str, JID],
+ cls: Type[T]) -> Optional[T]:
"""Get a tab with its name and class"""
+ if isinstance(name, JID):
+ str_name = name.full
+ else:
+ str_name = name
+ str
cls_tabs = self._tab_types.get(cls, [])
for tab in cls_tabs:
- if tab.name == name:
- return tab
+ if tab.name == str_name:
+ return cast(T, tab)
return None
def _rebuild(self):
+ self._tab_jids = dict()
self._tab_types = defaultdict(list)
self._tab_names = dict()
for tab in self._tabs:
for cls in _get_tab_types(tab):
self._tab_types[cls].append(tab)
+ if hasattr(tab, 'jid'):
+ self._tab_jids[tab.jid] = tab # type: ignore
self._tab_names[tab.name] = tab
self._update_numbers()
@@ -206,6 +253,8 @@ class Tabs:
self._tabs.append(tab)
for cls in _get_tab_types(tab):
self._tab_types[cls].append(tab)
+ if hasattr(tab, 'jid'):
+ self._tab_jids[tab.jid] = tab # type: ignore
self._tab_names[tab.name] = tab
def delete(self, tab: tabs.Tab, gap=False):
@@ -214,7 +263,7 @@ class Tabs:
return
if gap:
- self._tabs[tab.nb] = tabs.GapTab(None)
+ self._tabs[tab.nb] = tabs.GapTab()
else:
self._tabs.remove(tab)
@@ -222,6 +271,8 @@ class Tabs:
for cls in _get_tab_types(tab):
self._tab_types[cls].remove(tab)
+ if hasattr(tab, 'jid'):
+ del self._tab_jids[tab.jid] # type: ignore
del self._tab_names[tab.name]
if gap:
@@ -233,6 +284,7 @@ class Tabs:
self._previous_tab = None
if is_current:
self.restore_previous_tab()
+ self._previous_tab = None
self._validate_current_index()
def restore_previous_tab(self):
@@ -247,7 +299,7 @@ class Tabs:
def _validate_current_index(self):
if not 0 <= self._current_index < len(
self._tabs) or not self.current_tab:
- self.prev()
+ self.prev() # pylint: disable=not-callable
def _collect_trailing_gaptabs(self):
"""Remove trailing gap tabs if any"""
@@ -300,16 +352,16 @@ class Tabs:
if new_pos < len(self._tabs):
old_tab = self._tabs[old_pos]
self._tabs[new_pos], self._tabs[
- old_pos] = old_tab, tabs.GapTab(self)
+ old_pos] = old_tab, tabs.GapTab()
else:
self._tabs.append(self._tabs[old_pos])
- self._tabs[old_pos] = tabs.GapTab(self)
+ self._tabs[old_pos] = tabs.GapTab()
else:
if new_pos > old_pos:
self._tabs.insert(new_pos, tab)
- self._tabs[old_pos] = tabs.GapTab(self)
+ self._tabs[old_pos] = tabs.GapTab()
elif new_pos < old_pos:
- self._tabs[old_pos] = tabs.GapTab(self)
+ self._tabs[old_pos] = tabs.GapTab()
self._tabs.insert(new_pos, tab)
else:
return False
diff --git a/poezio/daemon.py b/poezio/daemon.py
index c8225a07..7a67a12d 100755
--- a/poezio/daemon.py
+++ b/poezio/daemon.py
@@ -4,7 +4,7 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
This file is a standalone program that reads commands on
stdin and executes them (each line should be a command).
diff --git a/poezio/decorators.py b/poezio/decorators.py
index bf1c2ebe..9342161f 100644
--- a/poezio/decorators.py
+++ b/poezio/decorators.py
@@ -1,54 +1,106 @@
"""
Module containing various decorators
"""
-from typing import Any, Callable, List, Optional
+
+from __future__ import annotations
+from asyncio import iscoroutinefunction
+
+from typing import (
+ cast,
+ Any,
+ Callable,
+ Dict,
+ List,
+ Optional,
+ TypeVar,
+ TYPE_CHECKING,
+)
from poezio import common
+if TYPE_CHECKING:
+ from poezio.core.core import Core
+
+
+T = TypeVar('T', bound=Callable[..., Any])
+
+
+BeforeFunc = Optional[Callable[[List[Any], Dict[str, Any]], Any]]
+AfterFunc = Optional[Callable[[Any, List[Any], Dict[str, Any]], Any]]
+
+
+def wrap_generic(func: Callable, before: BeforeFunc = None, after: AfterFunc = None):
+ """
+ Generic wrapper which can both wrap coroutines and normal functions.
+ """
+ def wrap(*args, **kwargs):
+ args = list(args)
+ if before is not None:
+ result = before(args, kwargs)
+ if result is not None:
+ return result
+ result = func(*args, **kwargs)
+ if after is not None:
+ result = after(result, args, kwargs)
+ return result
+
+ async def awrap(*args, **kwargs):
+ args = list(args)
+ if before is not None:
+ result = before(args, kwargs)
+ if result is not None:
+ return result
+ result = await func(*args, **kwargs)
+ if after is not None:
+ result = after(result, args, kwargs)
+ return result
+ if iscoroutinefunction(func):
+ return awrap
+ return wrap
class RefreshWrapper:
- def __init__(self):
+ core: Optional[Core]
+
+ def __init__(self) -> None:
self.core = None
- def conditional(self, func: Callable) -> Callable:
+ def conditional(self, func: T) -> T:
"""
Decorator to refresh the UI if the wrapped function
returns True
"""
+ def after(result: Any, args, kwargs) -> Any:
+ if self.core is not None and result:
+ self.core.refresh_window() # pylint: disable=no-member
+ return result
- def wrap(*args, **kwargs):
- ret = func(*args, **kwargs)
- if self.core and ret:
- self.core.refresh_window()
- return ret
+ wrap = wrap_generic(func, after=after)
- return wrap
+ return cast(T, wrap)
- def always(self, func: Callable) -> Callable:
+ def always(self, func: T) -> T:
"""
Decorator that refreshs the UI no matter what after the function
"""
+ def after(result: Any, args, kwargs) -> Any:
+ if self.core is not None:
+ self.core.refresh_window() # pylint: disable=no-member
+ return result
- def wrap(*args, **kwargs):
- ret = func(*args, **kwargs)
- if self.core:
- self.core.refresh_window()
- return ret
-
- return wrap
+ wrap = wrap_generic(func, after=after)
+ return cast(T, wrap)
- def update(self, func: Callable) -> Callable:
+ def update(self, func: T) -> T:
"""
Decorator that only updates the screen
"""
- def wrap(*args, **kwargs):
- ret = func(*args, **kwargs)
- if self.core:
- self.core.doupdate()
- return ret
-
- return wrap
+ def after(result: Any, args, kwargs) -> Any:
+ if self.core is not None:
+ self.core.doupdate() # pylint: disable=no-member
+ return result
+ wrap = wrap_generic(func, after=after)
+ return cast(T, wrap)
refresh_wrapper = RefreshWrapper()
@@ -61,48 +113,45 @@ class CommandArgParser:
"""
@staticmethod
- def raw(func: Callable) -> Callable:
+ def raw(func: T) -> T:
"""Just call the function with a single string, which is the original string
untouched
"""
-
- def wrap(self, args, *a, **kw):
- return func(self, args, *a, **kw)
-
- return wrap
+ return func
@staticmethod
- def ignored(func: Callable) -> Callable:
+ def ignored(func: T) -> T:
"""
- Call the function without any argument
+ Call the function without textual arguments
"""
+ def before(args: List[Any], kwargs: Dict[Any, Any]) -> None:
+ if len(args) >= 2:
+ del args[1]
- def wrap(self, args=None, *a, **kw):
- return func(self, *a, **kw)
-
- return wrap
+ wrap = wrap_generic(func, before=before)
+ return cast(T, wrap)
@staticmethod
def quoted(mandatory: int,
- optional=0,
+ optional: int = 0,
defaults: Optional[List[Any]] = None,
- ignore_trailing_arguments=False):
+ ignore_trailing_arguments: bool = False) -> Callable[[T], T]:
"""The function receives a list with a number of arguments that is between
the numbers `mandatory` and `optional`.
If the string doesn’t contain at least `mandatory` arguments, we return
- None because the given arguments are invalid.
+ None because the given arguments are invalid.
If there are any remaining arguments after `mandatory` and `optional`
arguments have been found (and “ignore_trailing_arguments" is not True),
- we happen them to the last argument of the list.
+ we append them to the last argument of the list.
- An argument is a string (with or without whitespaces) between to quotes
+ An argument is a string (with or without whitespaces) between two quotes
("), or a whitespace separated word (if not inside quotes).
The argument `defaults` is a list of strings that are used when an
optional argument is missing. For example if we accept one optional
- argument, zero is available but we have one value in the `defaults`
+ argument and none is provided, but we have one value in the `defaults`
list, we use that string inplace. The `defaults` list can only
replace missing optional arguments, not mandatory ones. And it
should not contain more than `mandatory` values. Also you cannot
@@ -131,15 +180,17 @@ class CommandArgParser:
"""
default_args_outer = defaults or []
- def first(func: Callable):
- def second(self, args: str, *a, **kw):
+ def first(func: T) -> T:
+ def before(args: List, kwargs: Dict[str, Any]) -> Any:
default_args = default_args_outer
- if args and args.strip():
- split_args = common.shell_split(args)
+ cmdargs = args[1]
+ if cmdargs and cmdargs.strip():
+ split_args = common.shell_split(cmdargs)
else:
split_args = []
if len(split_args) < mandatory:
- return func(self, None, *a, **kw)
+ args[1] = None
+ return
res, split_args = split_args[:mandatory], split_args[
mandatory:]
if optional == -1:
@@ -154,11 +205,25 @@ class CommandArgParser:
res += default_args
if split_args and res and not ignore_trailing_arguments:
res[-1] += " " + " ".join(split_args)
- return func(self, res, *a, **kw)
+ args[1] = res
+ return
+ wrap = wrap_generic(func, before=before)
+ return cast(T, wrap)
+ return first
- return second
+command_args_parser = CommandArgParser()
- return first
+def deny_anonymous(func: T) -> T:
+ """Decorator to disable commands when using an anonymous account."""
-command_args_parser = CommandArgParser()
+ def before(args: Any, kwargs: Any) -> Any:
+ core = args[0].core
+ if core.xmpp.anon:
+ core.information(
+ 'This command is not available for anonymous accounts.',
+ 'Info'
+ )
+ return False
+ wrap = wrap_generic(func, before=before)
+ return cast(T, wrap)
diff --git a/poezio/events.py b/poezio/events.py
index 3bfe5156..0ba97d56 100644
--- a/poezio/events.py
+++ b/poezio/events.py
@@ -2,15 +2,20 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Defines the EventHandler class.
The list of available events is here:
http://poezio.eu/doc/en/plugins.html#_poezio_events
"""
+import logging
+from collections import OrderedDict
+from inspect import iscoroutinefunction
from typing import Callable, Dict, List
+log = logging.getLogger(__name__)
+
class EventHandler:
"""
@@ -21,52 +26,73 @@ class EventHandler:
"""
def __init__(self):
- self.events = {
- 'highlight': [],
- 'muc_say': [],
- 'muc_say_after': [],
- 'conversation_say': [],
- 'conversation_say_after': [],
- 'private_say': [],
- 'private_say_after': [],
- 'conversation_msg': [],
- 'private_msg': [],
- 'muc_msg': [],
- 'conversation_chatstate': [],
- 'muc_chatstate': [],
- 'private_chatstate': [],
- 'normal_presence': [],
- 'muc_presence': [],
- 'muc_join': [],
- 'joining_muc': [],
- 'changing_nick': [],
- 'muc_kick': [],
- 'muc_nickchange': [],
- 'muc_ban': [],
- 'send_normal_presence': [],
- 'ignored_private': [],
- 'tab_change': [],
- } # type: Dict[str, List[Callable]]
+ events = [
+ 'highlight',
+ 'muc_say',
+ 'muc_say_after',
+ 'conversation_say',
+ 'conversation_say_after',
+ 'private_say',
+ 'private_say_after',
+ 'conversation_msg',
+ 'private_msg',
+ 'muc_msg',
+ 'conversation_chatstate',
+ 'muc_chatstate',
+ 'private_chatstate',
+ 'normal_presence',
+ 'muc_presence',
+ 'muc_join',
+ 'joining_muc',
+ 'changing_nick',
+ 'muc_kick',
+ 'muc_nickchange',
+ 'muc_ban',
+ 'send_normal_presence',
+ 'ignored_private',
+ 'tab_change',
+ ]
+ self.events: Dict[str, OrderedDict[int, List[Callable]]] = {}
+ for event in events:
+ self.events[event] = OrderedDict()
def add_event_handler(self, name: str, callback: Callable,
- position=0) -> bool:
+ priority: int = 50) -> bool:
"""
Add a callback to a given event.
Note that if that event name doesn’t exist, it just returns False.
If it was successfully added, it returns True
- position: 0 means insert at the beginning, -1 means end
+ priority is a integer between 0 and 100. 0 is the highest priority and
+ will be called first. 100 is the lowest.
"""
+
if name not in self.events:
return False
callbacks = self.events[name]
- if position >= 0:
- callbacks.insert(position, callback)
- else:
- callbacks.append(callback)
+
+ # Clamp priority
+ priority = max(0, min(priority, 100))
+
+ entry = callbacks.setdefault(priority, [])
+ entry.append(callback)
return True
+ async def trigger_async(self, name: str, *args, **kwargs):
+ """
+ Call all the callbacks associated to the given event name.
+ """
+ callbacks = self.events.get(name, None)
+ if callbacks is None:
+ return
+ for priority in callbacks.values():
+ for callback in priority:
+ if iscoroutinefunction(callback):
+ await callback(*args, **kwargs)
+ else:
+ callback(*args, **kwargs)
+
def trigger(self, name: str, *args, **kwargs):
"""
Call all the callbacks associated to the given event name.
@@ -74,8 +100,13 @@ class EventHandler:
callbacks = self.events.get(name, None)
if callbacks is None:
return
- for callback in callbacks:
- callback(*args, **kwargs)
+ for priority in callbacks.values():
+ for callback in priority:
+ if not iscoroutinefunction(callback):
+ callback(*args, **kwargs)
+ else:
+ log.error(f'async event handler {callback} '
+ 'called in sync trigger!')
def del_event_handler(self, name: str, callback: Callable):
"""
@@ -83,9 +114,13 @@ class EventHandler:
"""
if not name:
for callbacks in self.events.values():
- while callback in callbacks:
- callbacks.remove(callback)
+ for priority in callbacks.values():
+ for entry in priority[:]:
+ if entry == callback:
+ priority.remove(callback)
else:
callbacks = self.events[name]
- if callback in callbacks:
- callbacks.remove(callback)
+ for priority in callbacks.values():
+ for entry in priority[:]:
+ if entry == callback:
+ priority.remove(callback)
diff --git a/poezio/fixes.py b/poezio/fixes.py
index f8de7b14..c2db4332 100644
--- a/poezio/fixes.py
+++ b/poezio/fixes.py
@@ -5,44 +5,15 @@ upstream.
TODO: Check that they are fixed and remove those hacks
"""
-from slixmpp.stanza import Message
-from slixmpp.xmlstream import ET
+from slixmpp import Message
+from slixmpp.plugins.xep_0184 import XEP_0184
import logging
log = logging.getLogger(__name__)
-def has_identity(xmpp, jid, identity, on_true=None, on_false=None):
- def _cb(iq):
- ident = lambda x: x[0]
- res = identity in map(ident, iq['disco_info']['identities'])
- if res and on_true is not None:
- on_true()
- if not res and on_false is not None:
- on_false()
-
- xmpp.plugin['xep_0030'].get_info(jid=jid, callback=_cb)
-
-
-def get_room_form(xmpp, room, callback):
- def _cb(result):
- if result["type"] == "error":
- return callback(None)
- xform = result.xml.find(
- '{http://jabber.org/protocol/muc#owner}query/{jabber:x:data}x')
- if xform is None:
- return callback(None)
- form = xmpp.plugin['xep_0004'].build_form(xform)
- return callback(form)
-
- iq = xmpp.make_iq_get(ito=room)
- query = ET.Element('{http://jabber.org/protocol/muc#owner}query')
- iq.append(query)
- iq.send(callback=_cb)
-
-
-def _filter_add_receipt_request(self, stanza):
+def _filter_add_receipt_request(self: XEP_0184, stanza):
"""
Auto add receipt requests to outgoing messages, if:
diff --git a/poezio/hsluv.py b/poezio/hsluv.py
new file mode 100644
index 00000000..7dce5061
--- /dev/null
+++ b/poezio/hsluv.py
@@ -0,0 +1,360 @@
+# This file was taken from https://github.com/hsluv/hsluv-python
+#
+# Copyright (c) 2015 Alexei Boronine
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+""" This module is generated by transpiling Haxe into Python and cleaning
+the resulting code by hand, e.g. removing unused Haxe classes. To try it
+yourself, clone https://github.com/hsluv/hsluv and run:
+
+ haxe -cp haxe/src hsluv.Hsluv -python hsluv.py
+"""
+
+import math
+
+
+
+__version__ = '0.0.2'
+
+m = [[3.240969941904521, -1.537383177570093, -0.498610760293],
+ [-0.96924363628087, 1.87596750150772, 0.041555057407175],
+ [0.055630079696993, -0.20397695888897, 1.056971514242878]]
+minv = [[0.41239079926595, 0.35758433938387, 0.18048078840183],
+ [0.21263900587151, 0.71516867876775, 0.072192315360733],
+ [0.019330818715591, 0.11919477979462, 0.95053215224966]]
+refY = 1.0
+refU = 0.19783000664283
+refV = 0.46831999493879
+kappa = 903.2962962
+epsilon = 0.0088564516
+hex_chars = "0123456789abcdef"
+
+
+def _distance_line_from_origin(line):
+ v = math.pow(line['slope'], 2) + 1
+ return math.fabs(line['intercept']) / math.sqrt(v)
+
+
+def _length_of_ray_until_intersect(theta, line):
+ return line['intercept'] / (math.sin(theta) - line['slope'] * math.cos(theta))
+
+
+def _get_bounds(l):
+ result = []
+ sub1 = math.pow(l + 16, 3) / 1560896
+ if sub1 > epsilon:
+ sub2 = sub1
+ else:
+ sub2 = l / kappa
+ _g = 0
+ while _g < 3:
+ c = _g
+ _g = _g + 1
+ m1 = m[c][0]
+ m2 = m[c][1]
+ m3 = m[c][2]
+ _g1 = 0
+ while _g1 < 2:
+ t = _g1
+ _g1 = _g1 + 1
+ top1 = (284517 * m1 - 94839 * m3) * sub2
+ top2 = (838422 * m3 + 769860 * m2 + 731718 * m1) * l * sub2 - (769860 * t) * l
+ bottom = (632260 * m3 - 126452 * m2) * sub2 + 126452 * t
+ result.append({'slope': top1 / bottom, 'intercept': top2 / bottom})
+ return result
+
+
+def _max_safe_chroma_for_l(l):
+ bounds = _get_bounds(l)
+ _hx_min = 1.7976931348623157e+308
+ _g = 0
+ while _g < 2:
+ i = _g
+ _g = _g + 1
+ length = _distance_line_from_origin(bounds[i])
+ if math.isnan(_hx_min):
+ _hx_min = _hx_min
+ elif math.isnan(length):
+ _hx_min = length
+ else:
+ _hx_min = min(_hx_min, length)
+ return _hx_min
+
+
+def _max_chroma_for_lh(l, h):
+ hrad = h / 360 * math.pi * 2
+ bounds = _get_bounds(l)
+ _hx_min = 1.7976931348623157e+308
+ _g = 0
+ while _g < len(bounds):
+ bound = bounds[_g]
+ _g = (_g + 1)
+ length = _length_of_ray_until_intersect(hrad, bound)
+ if length >= 0:
+ if math.isnan(_hx_min):
+ _hx_min = _hx_min
+ elif math.isnan(length):
+ _hx_min = length
+ else:
+ _hx_min = min(_hx_min, length)
+ return _hx_min
+
+
+def _dot_product(a, b):
+ sum = 0
+ _g1 = 0
+ _g = len(a)
+ while _g1 < _g:
+ i = _g1
+ _g1 = _g1 + 1
+ sum += a[i] * b[i]
+ return sum
+
+
+def _from_linear(c):
+ if c <= 0.0031308:
+ return 12.92 * c
+ else:
+ return 1.055 * math.pow(c, 0.416666666666666685) - 0.055
+
+
+def _to_linear(c):
+ if c > 0.04045:
+ return math.pow((c + 0.055) / 1.055, 2.4)
+ else:
+ return c / 12.92
+
+
+def xyz_to_rgb(_hx_tuple):
+ return [
+ _from_linear(_dot_product(m[0], _hx_tuple)),
+ _from_linear(_dot_product(m[1], _hx_tuple)),
+ _from_linear(_dot_product(m[2], _hx_tuple))]
+
+
+def rgb_to_xyz(_hx_tuple):
+ rgbl = [_to_linear(_hx_tuple[0]),
+ _to_linear(_hx_tuple[1]),
+ _to_linear(_hx_tuple[2])]
+ return [_dot_product(minv[0], rgbl),
+ _dot_product(minv[1], rgbl),
+ _dot_product(minv[2], rgbl)]
+
+
+def _y_to_l(y):
+ if y <= epsilon:
+ return y / refY * kappa
+ else:
+ return 116 * math.pow(y / refY, 0.333333333333333315) - 16
+
+
+def _l_to_y(l):
+ if l <= 8:
+ return refY * l / kappa
+ else:
+ return refY * math.pow((l + 16) / 116, 3)
+
+
+def xyz_to_luv(_hx_tuple):
+ x = float(_hx_tuple[0])
+ y = float(_hx_tuple[1])
+ z = float(_hx_tuple[2])
+ divider = x + 15 * y + 3 * z
+ var_u = 4 * x
+ var_v = 9 * y
+ if divider != 0:
+ var_u = var_u / divider
+ var_v = var_v / divider
+ else:
+ var_u = float("nan")
+ var_v = float("nan")
+ l = _y_to_l(y)
+ if l == 0:
+ return [0, 0, 0]
+ u = 13 * l * (var_u - refU)
+ v = 13 * l * (var_v - refV)
+ return [l, u, v]
+
+
+def luv_to_xyz(_hx_tuple):
+ l = float(_hx_tuple[0])
+ u = float(_hx_tuple[1])
+ v = float(_hx_tuple[2])
+ if l == 0:
+ return [0, 0, 0]
+ var_u = u / (13 * l) + refU
+ var_v = v / (13 * l) + refV
+ y = _l_to_y(l)
+ x = 0 - ((9 * y * var_u) / (((var_u - 4) * var_v) - var_u * var_v))
+ z = (((9 * y) - (15 * var_v * y)) - (var_v * x)) / (3 * var_v)
+ return [x, y, z]
+
+
+def luv_to_lch(_hx_tuple):
+ l = float(_hx_tuple[0])
+ u = float(_hx_tuple[1])
+ v = float(_hx_tuple[2])
+ _v = (u * u) + (v * v)
+ if _v < 0:
+ c = float("nan")
+ else:
+ c = math.sqrt(_v)
+ if c < 0.00000001:
+ h = 0
+ else:
+ hrad = math.atan2(v, u)
+ h = hrad * 180.0 / 3.1415926535897932
+ if h < 0:
+ h = 360 + h
+ return [l, c, h]
+
+
+def lch_to_luv(_hx_tuple):
+ l = float(_hx_tuple[0])
+ c = float(_hx_tuple[1])
+ h = float(_hx_tuple[2])
+ hrad = h / 360.0 * 2 * math.pi
+ u = math.cos(hrad) * c
+ v = math.sin(hrad) * c
+ return [l, u, v]
+
+
+def hsluv_to_lch(_hx_tuple):
+ h = float(_hx_tuple[0])
+ s = float(_hx_tuple[1])
+ l = float(_hx_tuple[2])
+ if l > 99.9999999:
+ return [100, 0, h]
+ if l < 0.00000001:
+ return [0, 0, h]
+ _hx_max = _max_chroma_for_lh(l, h)
+ c = _hx_max / 100 * s
+ return [l, c, h]
+
+
+def lch_to_hsluv(_hx_tuple):
+ l = float(_hx_tuple[0])
+ c = float(_hx_tuple[1])
+ h = float(_hx_tuple[2])
+ if l > 99.9999999:
+ return [h, 0, 100]
+ if l < 0.00000001:
+ return [h, 0, 0]
+ _hx_max = _max_chroma_for_lh(l, h)
+ s = c / _hx_max * 100
+ return [h, s, l]
+
+
+def hpluv_to_lch(_hx_tuple):
+ h = float(_hx_tuple[0])
+ s = float(_hx_tuple[1])
+ l = float(_hx_tuple[2])
+ if l > 99.9999999:
+ return [100, 0, h]
+ if l < 0.00000001:
+ return [0, 0, h]
+ _hx_max = _max_safe_chroma_for_l(l)
+ c = _hx_max / 100 * s
+ return [l, c, h]
+
+
+def lch_to_hpluv(_hx_tuple):
+ l = float(_hx_tuple[0])
+ c = float(_hx_tuple[1])
+ h = float(_hx_tuple[2])
+ if l > 99.9999999:
+ return [h, 0, 100]
+ if l < 0.00000001:
+ return [h, 0, 0]
+ _hx_max = _max_safe_chroma_for_l(l)
+ s = c / _hx_max * 100
+ return [h, s, l]
+
+
+def rgb_to_hex(_hx_tuple):
+ h = "#"
+ _g = 0
+ while _g < 3:
+ i = _g
+ _g = _g + 1
+ chan = float(_hx_tuple[i])
+ c = math.floor(chan * 255 + 0.5)
+ digit2 = int(c % 16)
+ digit1 = int((c - digit2) / 16)
+
+ h += hex_chars[digit1] + hex_chars[digit2]
+ return h
+
+
+def hex_to_rgb(hex):
+ hex = hex.lower()
+ ret = []
+ _g = 0
+ while _g < 3:
+ i = _g
+ _g = _g + 1
+ index = i * 2 + 1
+ _hx_str = hex[index]
+ digit1 = hex_chars.find(_hx_str)
+ index1 = i * 2 + 2
+ str1 = hex[index1]
+ digit2 = hex_chars.find(str1)
+ n = digit1 * 16 + digit2
+ ret.append(n / 255.0)
+ return ret
+
+
+def lch_to_rgb(_hx_tuple):
+ return xyz_to_rgb(luv_to_xyz(lch_to_luv(_hx_tuple)))
+
+
+def rgb_to_lch(_hx_tuple):
+ return luv_to_lch(xyz_to_luv(rgb_to_xyz(_hx_tuple)))
+
+
+def hsluv_to_rgb(_hx_tuple):
+ return lch_to_rgb(hsluv_to_lch(_hx_tuple))
+
+
+def rgb_to_hsluv(_hx_tuple):
+ return lch_to_hsluv(rgb_to_lch(_hx_tuple))
+
+
+def hpluv_to_rgb(_hx_tuple):
+ return lch_to_rgb(hpluv_to_lch(_hx_tuple))
+
+
+def rgb_to_hpluv(_hx_tuple):
+ return lch_to_hpluv(rgb_to_lch(_hx_tuple))
+
+
+def hsluv_to_hex(_hx_tuple):
+ return rgb_to_hex(hsluv_to_rgb(_hx_tuple))
+
+
+def hpluv_to_hex(_hx_tuple):
+ return rgb_to_hex(hpluv_to_rgb(_hx_tuple))
+
+
+def hex_to_hsluv(s):
+ return rgb_to_hsluv(hex_to_rgb(s))
+
+
+def hex_to_hpluv(s):
+ return rgb_to_hpluv(hex_to_rgb(s))
diff --git a/poezio/keyboard.py b/poezio/keyboard.py
index 3d8e8d5c..1e75b2a2 100755
--- a/poezio/keyboard.py
+++ b/poezio/keyboard.py
@@ -4,7 +4,7 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Functions to interact with the keyboard
Mainly, read keys entered and return a string (most
@@ -26,7 +26,7 @@ log = logging.getLogger(__name__)
# shortcuts or inserting text in the current output. The callback
# is always reset to None afterwards (to resume the normal
# processing of keys)
-continuation_keys_callback = None # type: Optional[Callable]
+continuation_keys_callback: Optional[Callable] = None
def get_next_byte(s) -> Tuple[Optional[int], Optional[bytes]]:
@@ -46,7 +46,7 @@ def get_next_byte(s) -> Tuple[Optional[int], Optional[bytes]]:
def get_char_list(s) -> List[str]:
- ret_list = [] # type: List[str]
+ ret_list: List[str] = []
while True:
try:
key = s.get_wch()
diff --git a/poezio/log_loader.py b/poezio/log_loader.py
new file mode 100644
index 00000000..2e3b27c2
--- /dev/null
+++ b/poezio/log_loader.py
@@ -0,0 +1,395 @@
+"""
+This modules contains a class that loads messages into a ChatTab, either from
+MAM or the local logs, and a class that loads MUC history into the local
+logs.
+
+
+How the log loading works will depend on the poezio configuration:
+
+- if use_log is True, no logs will be fetched dynamically
+- if use_log is False, all logs will be fetched from MAM (if available)
+- if mam_sync and use_log are True, most chat tabs (all of them except the
+ static conversation tab) will try to sync the local
+ logs with the MAM history when opening them, or when joining a room.
+- all log loading/writing workflows are paused until the MAM sync is complete
+ (so that the local log loading can be up-to-date with the MAM history)
+- when use_log is False, mam_sync has no effect
+"""
+from __future__ import annotations
+import asyncio
+import logging
+from datetime import datetime, timedelta, timezone
+from typing import List, Optional
+from poezio import tabs
+from poezio.logger import (
+ build_log_message,
+ iterate_messages_reverse,
+ last_message_in_archive,
+ Logger,
+ LogDict,
+)
+from poezio.mam import (
+ fetch_history,
+ NoMAMSupportException,
+ MAMQueryException,
+ DiscoInfoException,
+ make_line,
+)
+from poezio.common import to_utc
+from poezio.ui.types import EndOfArchive, Message, BaseMessage
+from poezio.text_buffer import HistoryGap
+from slixmpp import JID
+
+
+# Max number of messages to insert when filling a gap
+HARD_LIMIT = 999
+
+
+log = logging.getLogger(__name__)
+
+
+def make_line_local(tab: tabs.ChatTab, msg: LogDict) -> Message:
+ """Create a UI message from a local log read.
+
+ :param tab: Tab in which that message will be displayed
+ :param msg: Log data
+ :returns: The UI message
+ """
+ if isinstance(tab, tabs.MucTab):
+ jid = JID(tab.jid)
+ jid.resource = msg.get('nickname') or ''
+ else:
+ jid = JID(tab.jid)
+ msg['time'] = msg['time'].astimezone(tz=timezone.utc)
+ return make_line(tab, msg['txt'], msg['time'], jid, '', msg['nickname'])
+
+
+class LogLoader:
+ """
+ An ephemeral class that loads history in a tab.
+
+ Loading from local logs is blocked until history has been fetched from
+ MAM to fill the local archive.
+ """
+ logger: Logger
+ tab: tabs.ChatTab
+ mam_only: bool
+
+ def __init__(self, logger: Logger, tab: tabs.ChatTab,
+ local_logs: bool = True,
+ done_event: Optional[asyncio.Event] = None):
+ self.mam_only = not local_logs
+ self.logger = logger
+ self.tab = tab
+ self.done_event = done_event
+
+ def _done(self) -> None:
+ """Signal end if possible"""
+ if self.done_event is not None:
+ self.done_event.set()
+
+ async def tab_open(self) -> None:
+ """Called on a tab opening or a MUC join"""
+ amount = 2 * self.tab.text_win.height
+ gap = self.tab._text_buffer.find_last_gap_muc()
+ messages = []
+ if gap is not None:
+ if self.mam_only:
+ messages = await self.mam_fill_gap(gap, amount)
+ else:
+ messages = await self.local_fill_gap(gap, amount)
+ else:
+ if self.mam_only:
+ messages = await self.mam_tab_open(amount)
+ else:
+ messages = await self.local_tab_open(amount)
+
+ log.debug(
+ 'Fetched %s messages for %s',
+ len(messages), self.tab.jid
+ )
+ if messages:
+ self.tab._text_buffer.add_history_messages(messages)
+ self.tab.core.refresh_window()
+ self._done()
+
+ async def mam_tab_open(self, nb: int) -> List[BaseMessage]:
+ """Fetch messages in MAM when opening a new tab.
+
+ :param nb: number of max messages to fetch.
+ :returns: list of ui messages to add
+ """
+ tab = self.tab
+ end = datetime.now()
+ for message in tab._text_buffer.messages:
+ time_ok = to_utc(message.time) < to_utc(end)
+ if isinstance(message, Message) and time_ok:
+ end = message.time
+ break
+ end = end - timedelta(microseconds=1)
+ try:
+ return await fetch_history(tab, end=end, amount=nb)
+ except (NoMAMSupportException, MAMQueryException, DiscoInfoException):
+ return []
+ finally:
+ tab.query_status = False
+
+ def _get_time_limit(self) -> datetime:
+ """Get the date 10 weeks ago from now."""
+ return datetime.now() - timedelta(weeks=10)
+
+ async def local_tab_open(self, nb: int) -> List[BaseMessage]:
+ """Fetch messages locally when opening a new tab.
+
+ :param nb: number of max messages to fetch.
+ :returns: list of ui messages to add
+ """
+ await self.wait_mam()
+ limit = self._get_time_limit()
+ results: List[BaseMessage] = []
+ filepath = self.logger.get_file_path(self.tab.jid)
+ count = 0
+ for msg in iterate_messages_reverse(filepath):
+ typ_ = msg.pop('type')
+ if typ_ == 'message':
+ results.append(make_line_local(self.tab, msg))
+ elif msg['time'] < limit and 'set the subject' not in msg['txt']:
+ break
+ if len(results) >= nb:
+ break
+ count += 1
+ if count % 20 == 0:
+ await asyncio.sleep(0)
+ return results[::-1]
+
+ async def mam_fill_gap(self, gap: HistoryGap, amount: Optional[int] = None) -> List[BaseMessage]:
+ """Fill a message gap in an existing tab using MAM.
+
+ :param gap: Object describing the history gap
+ :returns: list of ui messages to add
+ """
+ tab = self.tab
+ if amount is None:
+ amount = HARD_LIMIT
+
+ start = gap.last_timestamp_before_leave
+ end = gap.first_timestamp_after_join
+ if start:
+ start = start + timedelta(seconds=1)
+ if end:
+ end = end - timedelta(seconds=1)
+ try:
+ return await fetch_history(
+ tab,
+ start=start,
+ end=end,
+ amount=amount,
+ )
+ except (NoMAMSupportException, MAMQueryException, DiscoInfoException):
+ return []
+ finally:
+ tab.query_status = False
+
+ async def local_fill_gap(self, gap: HistoryGap, amount: Optional[int] = None) -> List[BaseMessage]:
+ """Fill a message gap in an existing tab using the local logs.
+ Mostly useless when not used with the MAMFiller.
+
+ :param gap: Object describing the history gap
+ :returns: list of ui messages to add
+ """
+ if amount is None:
+ amount = HARD_LIMIT
+ await self.wait_mam()
+ limit = self._get_time_limit()
+ start = gap.last_timestamp_before_leave
+ end = gap.first_timestamp_after_join
+ count = 0
+
+ results: List[BaseMessage] = []
+ filepath = self.logger.get_file_path(self.tab.jid)
+ for msg in iterate_messages_reverse(filepath):
+ typ_ = msg.pop('type')
+ if start and msg['time'] < start:
+ break
+ if typ_ == 'message' and (not end or msg['time'] < end):
+ results.append(make_line_local(self.tab, msg))
+ elif msg['time'] < limit and 'set the subject' not in msg['txt']:
+ break
+ if len(results) >= amount:
+ break
+ count += 1
+ if count % 20 == 0:
+ await asyncio.sleep(0)
+ return results[::-1]
+
+ async def scroll_requested(self):
+ """When a scroll up is requested in a chat tab.
+
+ Try to load more history if there are no more messages in the buffer.
+ """
+ tab = self.tab
+ tw = tab.text_win
+
+ # If position in the tab is < two screen pages, then fetch MAM, so that
+ # wa keep some prefetched margin. A first page should also be
+ # prefetched on join if not already available.
+ total, pos, height = len(tw.built_lines), tw.pos, tw.height
+ rest = (total - pos) // height
+
+ if rest > 1:
+ return None
+
+ if self.mam_only:
+ messages = await self.mam_scroll_requested(height)
+ else:
+ messages = await self.local_scroll_requested(height)
+ if messages:
+ tab._text_buffer.add_history_messages(messages)
+ tab.core.refresh_window()
+ self._done()
+
+ async def local_scroll_requested(self, nb: int) -> List[BaseMessage]:
+ """Fetch messages locally on scroll up.
+
+ :param nb: Number of messages to fetch
+ :returns: list of ui messages to add
+ """
+ await self.wait_mam()
+ tab = self.tab
+ count = 0
+
+ first_message = tab._text_buffer.find_first_message()
+ first_message_time = None
+ if first_message:
+ first_message_time = first_message.time - timedelta(microseconds=1)
+
+ results: List[BaseMessage] = []
+ filepath = self.logger.get_file_path(self.tab.jid)
+ for msg in iterate_messages_reverse(filepath):
+ typ_ = msg.pop('type')
+ if first_message_time is None or msg['time'] < first_message_time:
+ if typ_ == 'message':
+ results.append(make_line_local(self.tab, msg))
+ if len(results) >= nb:
+ break
+ count += 1
+ if count % 20 == 0:
+ await asyncio.sleep(0)
+ return results[::-1]
+
+ async def mam_scroll_requested(self, nb: int) -> List[BaseMessage]:
+ """Fetch messages from MAM on scroll up.
+
+ :param nb: Number of messages to fetch
+ :returns: list of ui messages to add
+ """
+ tab = self.tab
+ try:
+ messages = await fetch_history(tab, amount=nb)
+ last_message_exists = False
+ if tab._text_buffer.messages:
+ last_message = tab._text_buffer.messages[0]
+ last_message_exists = True
+ if (not messages and
+ last_message_exists
+ and not isinstance(last_message, EndOfArchive)):
+ time = tab._text_buffer.messages[0].time
+ messages = [EndOfArchive('End of archive reached', time=time)]
+ return messages
+ except NoMAMSupportException:
+ return []
+ except (MAMQueryException, DiscoInfoException):
+ tab.core.information(
+ f'An error occured when fetching MAM for {tab.jid}',
+ 'Error'
+ )
+ return []
+ finally:
+ tab.query_status = False
+
+ async def wait_mam(self) -> None:
+ """Wait for the MAM history sync before reading the local logs.
+
+ Does nothing apart from blocking.
+ """
+ if self.tab.mam_filler is None:
+ return
+ await self.tab.mam_filler.done.wait()
+
+
+class MAMFiller:
+ """Class that loads messages from MAM history into the local logs.
+ """
+ tab: tabs.ChatTab
+ logger: Logger
+ future: asyncio.Future
+ done: asyncio.Event
+ limit: int
+
+ def __init__(self, logger: Logger, tab: tabs.ChatTab, limit: int = 2000):
+ self.tab = tab
+ self.logger = logger
+ logger.fd_busy(tab.jid)
+ self.future = asyncio.create_task(self.fetch_routine())
+ self.done = asyncio.Event()
+ self.limit = limit
+ self.result = 0
+
+ def cancel(self) -> None:
+ """Cancel the routine and signal the end."""
+ self.future.cancel()
+ self.end()
+
+ async def fetch_routine(self) -> None:
+ """Load logs into the local archive, if possible."""
+ filepath = self.logger.get_file_path(self.tab.jid)
+ log.debug('Fetching logs for %s', self.tab.jid)
+ try:
+ last_msg = last_message_in_archive(filepath)
+ last_msg_time = None
+ if last_msg:
+ last_msg_time = last_msg['time'] + timedelta(seconds=1)
+ try:
+ messages = await fetch_history(
+ self.tab,
+ start=last_msg_time,
+ amount=self.limit,
+ )
+ log.debug(
+ 'Fetched %s messages to fill local logs for %s',
+ len(messages), self.tab.jid,
+ )
+ self.result = len(messages)
+ except NoMAMSupportException:
+ log.debug('The entity %s does not support MAM', self.tab.jid)
+ return
+ except (DiscoInfoException, MAMQueryException):
+ log.debug(
+ 'Failed fetching logs for %s',
+ self.tab.jid, exc_info=True
+ )
+ return
+
+ def build_message(msg) -> str:
+ return build_log_message(
+ msg.nickname,
+ msg.txt,
+ msg.time,
+ prefix='MR',
+ )
+
+ logs = ''.join(map(build_message, messages))
+ self.logger.log_raw(self.tab.jid, logs, force=True)
+ finally:
+ self.end()
+
+ def end(self) -> None:
+ """End a MAM fill (error or sucess). Remove references and signal on
+ the Event().
+ """
+ try:
+ self.logger.fd_available(self.tab.jid)
+ except Exception:
+ log.error('Error when restoring log fd:', exc_info=True)
+ self.tab.mam_filler = None
+ self.done.set()
diff --git a/poezio/logger.py b/poezio/logger.py
index c8ec66d9..29eaad32 100644
--- a/poezio/logger.py
+++ b/poezio/logger.py
@@ -3,7 +3,7 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
The logger module that handles logging of the poezio
conversations and roster changes
@@ -11,20 +11,21 @@ conversations and roster changes
import mmap
import re
-from typing import List, Dict, Optional, IO, Any
+from typing import List, Dict, Optional, IO, Any, Union, Generator
from datetime import datetime
+from pathlib import Path
from poezio import common
from poezio.config import config
from poezio.xhtml import clean_text
-from poezio.theming import dump_tuple, get_theme
+from poezio.ui.types import Message, BaseMessage, LoggableTrait
+from slixmpp import JID
+from poezio.types import TypedDict
import logging
log = logging.getLogger(__name__)
-from poezio.config import LOG_DIR as log_dir
-
MESSAGE_LOG_RE = re.compile(r'^MR (\d{4})(\d{2})(\d{2})T'
r'(\d{2}):(\d{2}):(\d{2})Z '
r'(\d+) <([^ ]+)>  (.*)$')
@@ -34,8 +35,13 @@ INFO_LOG_RE = re.compile(r'^MI (\d{4})(\d{2})(\d{2})T'
class LogItem:
- def __init__(self, year, month, day, hour, minute, second, nb_lines,
- message):
+ time: datetime
+ nb_lines: int
+ text: str
+
+ def __init__(self, year: str, month: str, day: str, hour: str, minute: str,
+ second: str, nb_lines: str,
+ message: str):
self.time = datetime(
int(year), int(month), int(day), int(hour), int(minute),
int(second))
@@ -49,21 +55,40 @@ class LogInfo(LogItem):
class LogMessage(LogItem):
- def __init__(self, year, month, day, hour, minute, seconds, nb_lines, nick,
- message):
+ nick: str
+
+ def __init__(self, year: str, month: str, day: str, hour: str, minute: str,
+ seconds: str, nb_lines: str, nick: str,
+ message: str):
LogItem.__init__(self, year, month, day, hour, minute, seconds,
nb_lines, message)
self.nick = nick
-def parse_log_line(msg: str) -> Optional[LogItem]:
- match = re.match(MESSAGE_LOG_RE, msg)
+LogDict = TypedDict(
+ 'LogDict',
+ {
+ 'type': str, 'txt': str, 'time': datetime,
+ 'history': bool, 'nickname': str
+ },
+ total=False,
+)
+
+
+def parse_log_line(msg: str, jid: str = '') -> Optional[LogItem]:
+ """Parse a log line.
+
+ :param msg: The message ligne
+ :param jid: jid (for error logging)
+ :returns: The LogItem or None on error
+ """
+ match = MESSAGE_LOG_RE.match(msg)
if match:
return LogMessage(*match.groups())
- match = re.match(INFO_LOG_RE, msg)
+ match = INFO_LOG_RE.match(msg)
if match:
return LogInfo(*match.groups())
- log.debug('Error while parsing "%s"', msg)
+ log.debug('Error while parsing %s’s logs: “%s”', jid, msg)
return None
@@ -72,139 +97,175 @@ class Logger:
Appends things to files. Error/information/warning logs
and also log the conversations to logfiles
"""
+ _roster_logfile: Optional[IO[str]]
+ log_dir: Path
+ _fds: Dict[str, IO[str]]
+ _busy_fds: Dict[str, bool]
def __init__(self):
- self._roster_logfile = None # Optional[IO[Any]]
+ self.log_dir = Path()
+ self._roster_logfile = None
# a dict of 'groupchatname': file-object (opened)
- self._fds = {} # type: Dict[str, IO[Any]]
+ self._fds = {}
+ self._busy_fds = {}
+ self._buffered_fds = {}
def __del__(self):
+ """Close all fds on exit"""
for opened_file in self._fds.values():
if opened_file:
try:
opened_file.close()
- except: # Can't close? too bad
+ except Exception: # Can't close? too bad
pass
+ try:
+ self._roster_logfile.close()
+ except Exception:
+ pass
- def close(self, jid) -> None:
- jid = str(jid).replace('/', '\\')
- if jid in self._fds:
- self._fds[jid].close()
+ def get_file_path(self, jid: Union[str, JID]) -> Path:
+ """Return the log path for a specific jid"""
+ jidstr = str(jid).replace('/', '\\')
+ return self.log_dir / jidstr
+
+ def fd_busy(self, jid: Union[str, JID]) -> None:
+ """Signal to the logger that this logfile is busy elsewhere.
+ And that the messages should be queued to be logged later.
+
+ :param jid: file name
+ """
+ jidstr = str(jid).replace('/', '\\')
+ self._busy_fds[jidstr] = True
+ if jidstr not in self._buffered_fds:
+ self._buffered_fds[jidstr] = []
+
+ def fd_available(self, jid: Union[str, JID]) -> None:
+ """Signal to the logger that this logfile is no longer busy.
+ And write messages to the end.
+
+ :param jid: file name
+ """
+ jidstr = str(jid).replace('/', '\\')
+ if jidstr in self._busy_fds:
+ del self._busy_fds[jidstr]
+ if jidstr in self._buffered_fds:
+ msgs = ''.join(self._buffered_fds.pop(jidstr))
+ if jidstr in self._fds:
+ self._fds[jidstr].close()
+ del self._fds[jidstr]
+ self.log_raw(jid, msgs)
+
+ def close(self, jid: str) -> None:
+ """Close the log file for a JID."""
+ jidstr = str(jid).replace('/', '\\')
+ if jidstr in self._fds:
+ self._fds[jidstr].close()
log.debug('Log file for %s closed.', jid)
- del self._fds[jid]
- return None
+ del self._fds[jidstr]
def reload_all(self) -> None:
"""Close and reload all the file handles (on SIGHUP)"""
- for opened_file in self._fds.values():
+ not_closed = set()
+ for key, opened_file in self._fds.items():
if opened_file:
- opened_file.close()
+ try:
+ opened_file.close()
+ except Exception:
+ not_closed.add(key)
+ if self._roster_logfile:
+ try:
+ self._roster_logfile.close()
+ except Exception:
+ not_closed.add('roster')
log.debug('All log file handles closed')
+ if not_closed:
+ log.error('Unable to close log files for: %s', not_closed)
for room in self._fds:
self._check_and_create_log_dir(room)
log.debug('Log handle for %s re-created', room)
- return None
- def _check_and_create_log_dir(self, room: str,
- open_fd: bool = True) -> Optional[IO[Any]]:
+ def _check_and_create_log_dir(self, jid: Union[str, JID],
+ open_fd: bool = True) -> Optional[IO[str]]:
"""
Check that the directory where we want to log the messages
exists. if not, create it
+
+ :param jid: JID of the file to open after creating the dir
+ :param open_fd: if the file should be opened after creating the dir
+ :returns: the opened fd or None
"""
- if not config.get_by_tabname('use_log', room):
+ if not config.get_by_tabname('use_log', JID(jid)):
return None
+ # POSIX filesystems don't support / in filename, so we replace it with a backslash
+ jid = str(jid).replace('/', '\\')
try:
- log_dir.mkdir(parents=True, exist_ok=True)
- except OSError as e:
+ self.log_dir.mkdir(parents=True, exist_ok=True)
+ except OSError:
log.error('Unable to create the log dir', exc_info=True)
- except:
+ except Exception:
log.error('Unable to create the log dir', exc_info=True)
return None
if not open_fd:
return None
- filename = log_dir / room
+ filename = self.get_file_path(jid)
try:
fd = filename.open('a', encoding='utf-8')
- self._fds[room] = fd
+ self._fds[jid] = fd
return fd
except IOError:
log.error(
'Unable to open the log file (%s)', filename, exc_info=True)
return None
- def get_logs(self, jid: str,
- nb: int = 10) -> Optional[List[Dict[str, Any]]]:
- """
- Get the nb last messages from the log history for the given jid.
- Note that a message may be more than one line in these files, so
- this function is a little bit more complicated than “read the last
- nb lines”.
- """
- if config.get_by_tabname('load_log', jid) <= 0:
- return None
-
- if not config.get_by_tabname('use_log', jid):
- return None
-
- if nb <= 0:
- return None
-
- self._check_and_create_log_dir(jid, open_fd=False)
-
- filename = log_dir / jid
- try:
- fd = filename.open('rb')
- except FileNotFoundError:
- log.info('Non-existing log file (%s)', filename, exc_info=True)
- return None
- except OSError:
- log.error(
- 'Unable to open the log file (%s)', filename, exc_info=True)
- return None
- if not fd:
- return None
-
- # read the needed data from the file, we just search nb messages by
- # searching "\nM" nb times from the end of the file. We use mmap to
- # do that efficiently, instead of seek()s and read()s which are costly.
- with fd:
- try:
- lines = _get_lines_from_fd(fd, nb=nb)
- except Exception: # file probably empty
- log.error(
- 'Unable to mmap the log file for (%s)',
- filename,
- exc_info=True)
- return None
- return parse_log_lines(lines)
-
def log_message(self,
jid: str,
- nick: str,
- msg: str,
- date: Optional[datetime] = None,
- typ: int = 1) -> bool:
+ msg: Union[BaseMessage, Message]) -> bool:
"""
- log the message in the appropriate jid's file
- type:
- 0 = Don’t log
- 1 = Message
- 2 = Status/whatever
+ Log the message in the appropriate file
+
+ :param jid: JID of the entity for which to log the message
+ :param msg: Message to log
+ :returns: True if no error was encountered
"""
- if not config.get_by_tabname('use_log', jid):
+ if not config.get_by_tabname('use_log', JID(jid)):
return True
- logged_msg = build_log_message(nick, msg, date=date, typ=typ)
+ if not isinstance(msg, LoggableTrait):
+ return True
+ date = msg.time
+ txt = msg.txt
+ nick = ''
+ typ = 'MI'
+ if isinstance(msg, Message):
+ nick = msg.nickname or ''
+ if msg.me:
+ txt = f'/me {txt}'
+ typ = 'MR'
+ logged_msg = build_log_message(nick, txt, date=date, prefix=typ)
if not logged_msg:
return True
- if jid in self._fds.keys():
- fd = self._fds[jid]
+ return self.log_raw(jid, logged_msg)
+
+ def log_raw(self, jid: Union[str, JID], logged_msg: str, force: bool = False) -> bool:
+ """Log a raw string.
+
+ :param jid: filename
+ :param logged_msg: string to log
+ :param force: Bypass the buffered fd check
+ :returns: True if no error was encountered
+ """
+ jidstr = str(jid).replace('/', '\\')
+ if jidstr in self._fds.keys():
+ fd = self._fds[jidstr]
else:
option_fd = self._check_and_create_log_dir(jid)
if option_fd is None:
return True
fd = option_fd
- filename = log_dir / jid
+ filename = self.get_file_path(jid)
try:
+ if not force and self._busy_fds.get(jidstr):
+ self._buffered_fds[jidstr].append(logged_msg)
+ return True
fd.write(logged_msg)
except OSError:
log.error(
@@ -226,11 +287,15 @@ class Logger:
def log_roster_change(self, jid: str, message: str) -> bool:
"""
Log a roster change
+
+ :param jid: jid to log the change for
+ :param message: message to log
+ :returns: True if no error happened
"""
- if not config.get_by_tabname('use_log', jid):
+ if not config.get_by_tabname('use_log', JID(jid)):
return True
self._check_and_create_log_dir('', open_fd=False)
- filename = log_dir / 'roster.log'
+ filename = self.log_dir / 'roster.log'
if not self._roster_logfile:
try:
self._roster_logfile = filename.open('a', encoding='utf-8')
@@ -251,7 +316,7 @@ class Logger:
for line in lines:
self._roster_logfile.write(' %s\n' % line)
self._roster_logfile.flush()
- except:
+ except Exception:
log.error(
'Unable to write in the log file (%s)',
filename,
@@ -263,21 +328,19 @@ class Logger:
def build_log_message(nick: str,
msg: str,
date: Optional[datetime] = None,
- typ: int = 1) -> str:
+ prefix: str = 'MI') -> str:
"""
Create a log message from a nick, a message, optionally a date and type
- message types:
- 0 = Don’t log
- 1 = Message
- 2 = Status/whatever
- """
- if not typ:
- return ''
+ :param nick: nickname to log
+ :param msg: text of the message
+ :param date: date of the message
+ :param prefix: MI (info) or MR (message)
+ :returns: The log line(s)
+ """
msg = clean_text(msg)
time = common.get_utc_time() if date is None else common.get_utc_time(date)
str_time = time.strftime('%Y%m%dT%H:%M:%SZ')
- prefix = 'MR' if typ == 1 else 'MI'
lines = msg.split('\n')
first_line = lines.pop(0)
nb_lines = str(len(lines)).zfill(3)
@@ -290,28 +353,62 @@ def build_log_message(nick: str,
return logged_msg + ''.join(' %s\n' % line for line in lines)
-def _get_lines_from_fd(fd: IO[Any], nb: int = 10) -> List[str]:
+def last_message_in_archive(filepath: Path) -> Optional[LogDict]:
+ """Get the last message from the local archive.
+
+ :param filepath: the log file path
"""
- Get the last log lines from a fileno
+ last_msg = None
+ for msg in iterate_messages_reverse(filepath):
+ if msg['type'] == 'message':
+ last_msg = msg
+ break
+ return last_msg
+
+
+def iterate_messages_reverse(filepath: Path) -> Generator[LogDict, None, None]:
+ """Get the latest messages from the log file, one at a time.
+
+ :param fd: the file descriptor
"""
- with mmap.mmap(fd.fileno(), 0, prot=mmap.PROT_READ) as m:
- # start of messages begin with MI or MR, after a \n
- pos = m.rfind(b"\nM") + 1
- # number of message found so far
- count = 0
- while pos != 0 and count < nb - 1:
- count += 1
- pos = m.rfind(b"\nM", 0, pos) + 1
- lines = m[pos:].decode(errors='replace').splitlines()
- return lines
-
-
-def parse_log_lines(lines: List[str]) -> List[Dict[str, Any]]:
+ try:
+ with open(filepath, 'rb') as fd:
+ with mmap.mmap(fd.fileno(), 0, prot=mmap.PROT_READ) as m:
+ # start of messages begin with MI or MR, after a \n
+ pos = m.rfind(b"\nM") + 1
+ if pos != -1:
+ lines = parse_log_lines(
+ m[pos:-1].decode(errors='replace').splitlines()
+ )
+ elif m[0:1] == b'M':
+ # Handle the case of a single message present in the log
+ # file, hence no newline.
+ lines = parse_log_lines(
+ m[:].decode(errors='replace').splitlines()
+ )
+ if lines:
+ yield lines[0]
+ while pos > 0:
+ old_pos = pos
+ pos = m.rfind(b"\nM", 0, pos) + 1
+ lines = parse_log_lines(
+ m[pos:old_pos].decode(errors='replace').splitlines()
+ )
+ if lines:
+ yield lines[0]
+ except (OSError, ValueError):
+ pass
+
+
+def parse_log_lines(lines: List[str], jid: str = '') -> List[LogDict]:
"""
Parse raw log lines into poezio log objects
+
+ :param lines: Message lines
+ :param jid: jid (for error logging)
+ :return: a list of dicts containing message info
"""
messages = []
- color = '\x19%s}' % dump_tuple(get_theme().COLOR_LOG_MSG)
# now convert that data into actual Message objects
idx = 0
@@ -320,22 +417,24 @@ def parse_log_lines(lines: List[str]) -> List[Dict[str, Any]]:
idx += 1
log.debug('fail?')
continue
- log_item = parse_log_line(lines[idx])
+ log_item = parse_log_line(lines[idx], jid)
idx += 1
if not isinstance(log_item, LogItem):
log.debug('wrong log format? %s', log_item)
continue
message_lines = []
- message = {
+ message = LogDict({
'history': True,
- 'time': common.get_local_time(log_item.time)
- }
+ 'time': common.get_local_time(log_item.time),
+ 'type': 'message',
+ })
size = log_item.nb_lines
if isinstance(log_item, LogInfo):
- message_lines.append(color + log_item.text)
+ message_lines.append(log_item.text)
+ message['type'] = 'info'
elif isinstance(log_item, LogMessage):
message['nickname'] = log_item.nick
- message_lines.append(color + log_item.text)
+ message_lines.append(log_item.text)
while size != 0 and idx < len(lines):
message_lines.append(lines[idx][1:])
size -= 1
@@ -345,10 +444,4 @@ def parse_log_lines(lines: List[str]) -> List[Dict[str, Any]]:
return messages
-def create_logger() -> None:
- "Create the global logger object"
- global logger
- logger = Logger()
-
-
-logger = None # type: Optional[Logger]
+logger = Logger()
diff --git a/poezio/mam.py b/poezio/mam.py
new file mode 100644
index 00000000..7cb1d369
--- /dev/null
+++ b/poezio/mam.py
@@ -0,0 +1,211 @@
+"""
+ Query and control an archive of messages stored on a server using
+ XEP-0313: Message Archive Management(MAM).
+"""
+
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timedelta, timezone
+from hashlib import md5
+from typing import (
+ Any,
+ AsyncIterable,
+ Dict,
+ List,
+ Optional,
+)
+
+from slixmpp import JID, Message as SMessage
+from slixmpp.exceptions import IqError, IqTimeout
+from poezio.theming import get_theme
+from poezio import tabs
+from poezio import colors
+from poezio.common import to_utc
+from poezio.ui.types import (
+ BaseMessage,
+ Message,
+)
+
+
+log = logging.getLogger(__name__)
+
+class DiscoInfoException(Exception): pass
+class MAMQueryException(Exception): pass
+class NoMAMSupportException(Exception): pass
+
+
+def make_line(
+ tab: tabs.ChatTab,
+ text: str,
+ time: datetime,
+ jid: JID,
+ identifier: str = '',
+ nick: str = ''
+ ) -> Message:
+ """Adds a textual entry in the TextBuffer"""
+
+ # Convert to local timezone
+ time = time.replace(tzinfo=timezone.utc).astimezone(tz=None)
+ time = time.replace(tzinfo=None)
+
+ if isinstance(tab, tabs.MucTab):
+ nick = jid.resource
+ user = tab.get_user_by_name(nick)
+ if user:
+ color = user.color
+ else:
+ theme = get_theme()
+ if theme.ccg_palette:
+ fg_color = colors.ccg_text_to_color(theme.ccg_palette, nick)
+ color = fg_color, -1
+ else:
+ mod = len(theme.LIST_COLOR_NICKNAMES)
+ nick_pos = int(md5(nick.encode('utf-8')).hexdigest(), 16) % mod
+ color = theme.LIST_COLOR_NICKNAMES[nick_pos]
+ else:
+ if jid.bare == tab.core.xmpp.boundjid.bare:
+ if not nick:
+ nick = tab.core.own_nick
+ color = get_theme().COLOR_OWN_NICK
+ else:
+ color = get_theme().COLOR_REMOTE_USER
+ if not nick:
+ nick = tab.get_nick()
+ return Message(
+ txt=text,
+ identifier=identifier,
+ time=time,
+ nickname=nick,
+ nick_color=color,
+ history=True,
+ user=None,
+ )
+
+async def get_mam_iterator(
+ core,
+ groupchat: bool,
+ remote_jid: JID,
+ amount: int,
+ reverse: bool = True,
+ start: Optional[str] = None,
+ end: Optional[str] = None,
+ before: Optional[str] = None,
+ ) -> AsyncIterable[SMessage]:
+ """Get an async iterator for this mam query"""
+ try:
+ query_jid = remote_jid if groupchat else JID(core.xmpp.boundjid.bare)
+ iq = await core.xmpp.plugin['xep_0030'].get_info(jid=query_jid)
+ except (IqError, IqTimeout):
+ raise DiscoInfoException()
+ if 'urn:xmpp:mam:2' not in iq['disco_info'].get_features():
+ raise NoMAMSupportException()
+
+ args: Dict[str, Any] = {
+ 'iterator': True,
+ 'reverse': reverse,
+ }
+
+ if groupchat:
+ args['jid'] = remote_jid
+ else:
+ args['with_jid'] = remote_jid
+
+ if amount > 0:
+ args['rsm'] = {'max': amount}
+ args['start'] = start
+ args['end'] = end
+ return core.xmpp['xep_0313'].retrieve(**args)
+
+
+def _parse_message(msg: SMessage) -> Dict:
+ """Parse info inside a MAM forwarded message"""
+ forwarded = msg['mam_result']['forwarded']
+ message = forwarded['stanza']
+ return {
+ 'time': forwarded['delay']['stamp'],
+ 'jid': message['from'],
+ 'text': message['body'],
+ 'identifier': message['origin-id']
+ }
+
+
+def _ignore_private_message(stanza: SMessage, filter_jid: Optional[JID]) -> bool:
+ """Returns True if a MUC-PM should be ignored, as prosody returns
+ all PMs within the same room.
+ """
+ if filter_jid is None:
+ return False
+ sent = stanza['from'].bare != filter_jid.bare
+ if sent and stanza['to'].full != filter_jid.full:
+ return True
+ elif not sent and stanza['from'].full != filter_jid.full:
+ return True
+ return False
+
+
+async def retrieve_messages(tab: tabs.ChatTab,
+ results: AsyncIterable[SMessage],
+ amount: int = 100) -> List[BaseMessage]:
+ """Run the MAM query and put messages in order"""
+ msg_count = 0
+ msgs = []
+ to_add = []
+ tab_is_private = isinstance(tab, tabs.PrivateTab)
+ filter_jid = None
+ if tab_is_private:
+ filter_jid = tab.jid
+ try:
+ async for rsm in results:
+ for msg in rsm['mam']['results']:
+ stanza = msg['mam_result']['forwarded']['stanza']
+ if stanza.xml.find('{%s}%s' % ('jabber:client', 'body')) is not None:
+ if _ignore_private_message(stanza, filter_jid):
+ continue
+ args = _parse_message(msg)
+ msgs.append(make_line(tab, **args))
+ for msg in reversed(msgs):
+ to_add.append(msg)
+ msg_count += 1
+ if msg_count == amount:
+ to_add.reverse()
+ return to_add
+ msgs = []
+ to_add.reverse()
+ return to_add
+ except (IqError, IqTimeout) as exc:
+ log.debug('Unable to complete MAM query: %s', exc, exc_info=True)
+ raise MAMQueryException('Query interrupted')
+
+
+async def fetch_history(tab: tabs.ChatTab,
+ start: Optional[datetime] = None,
+ end: Optional[datetime] = None,
+ amount: int = 100) -> List[BaseMessage]:
+ remote_jid = tab.jid
+ if not end:
+ for msg in tab._text_buffer.messages:
+ if isinstance(msg, Message):
+ end = msg.time
+ end -= timedelta(microseconds=1)
+ break
+ if end is None:
+ end = datetime.now()
+ end = to_utc(end)
+ end_str = datetime.strftime(end, '%Y-%m-%dT%H:%M:%SZ')
+
+ start_str = None
+ if start is not None:
+ start = to_utc(start)
+ start_str = datetime.strftime(start, '%Y-%m-%dT%H:%M:%SZ')
+
+ mam_iterator = await get_mam_iterator(
+ core=tab.core,
+ groupchat=isinstance(tab, tabs.MucTab),
+ remote_jid=remote_jid,
+ amount=amount,
+ end=end_str,
+ start=start_str,
+ reverse=True,
+ )
+ return await retrieve_messages(tab, mam_iterator, amount)
diff --git a/poezio/multiuserchat.py b/poezio/multiuserchat.py
index 73a802b2..3278e1bd 100644
--- a/poezio/multiuserchat.py
+++ b/poezio/multiuserchat.py
@@ -3,76 +3,51 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Implementation of the XEP-0045: Multi-User Chat.
Add some facilities that are not available on the XEP_0045
slix plugin
"""
-from xml.etree import cElementTree as ET
+from __future__ import annotations
-from poezio.common import safeJID
-from slixmpp.exceptions import IqError, IqTimeout
-import logging
-log = logging.getLogger(__name__)
-
-NS_MUC_ADMIN = 'http://jabber.org/protocol/muc#admin'
-NS_MUC_OWNER = 'http://jabber.org/protocol/muc#owner'
-
-
-def destroy_room(xmpp, room, reason='', altroom=''):
- """
- destroy a room
- """
- room = safeJID(room)
- if not room:
- return False
- iq = xmpp.make_iq_set()
- iq['to'] = room
- query = ET.Element('{%s}query' % NS_MUC_OWNER)
- destroy = ET.Element('{%s}destroy' % NS_MUC_OWNER)
- if altroom:
- destroy.attrib['jid'] = altroom
- if reason:
- xreason = ET.Element('{%s}reason' % NS_MUC_OWNER)
- xreason.text = reason
- destroy.append(xreason)
- query.append(destroy)
- iq.append(query)
-
- def callback(iq):
- if not iq or iq['type'] == 'error':
- xmpp.core.information('Unable to destroy room %s' % room, 'Info')
- else:
- xmpp.core.information('Room %s destroyed' % room, 'Info')
+import asyncio
+from xml.etree import ElementTree as ET
+from typing import (
+ Optional,
+ Union,
+ TYPE_CHECKING,
+)
- iq.send(callback=callback)
- return True
+from slixmpp import (
+ JID,
+ ClientXMPP,
+ Iq,
+ Presence,
+)
-
-def send_private_message(xmpp, jid, line):
- """
- Send a private message
- """
- jid = safeJID(jid)
- xmpp.send_message(mto=jid, mbody=line, mtype='chat')
+import logging
+log = logging.getLogger(__name__)
-def send_groupchat_message(xmpp, jid, line):
- """
- Send a message to the groupchat
- """
- jid = safeJID(jid)
- xmpp.send_message(mto=jid, mbody=line, mtype='groupchat')
+if TYPE_CHECKING:
+ from poezio.core.core import Core
+ from poezio.tabs import MucTab
-def change_show(xmpp, jid, own_nick, show, status):
+def change_show(
+ xmpp: ClientXMPP,
+ jid: JID,
+ own_nick: str,
+ show: str,
+ status: Optional[str]
+) -> None:
"""
Change our 'Show'
"""
- jid = safeJID(jid)
- pres = xmpp.make_presence(pto='%s/%s' % (jid, own_nick))
+ jid = JID(jid)
+ pres: Presence = xmpp.make_presence(pto='%s/%s' % (jid, own_nick))
if show: # if show is None, don't put a <show /> tag. It means "available"
pres['type'] = show
if status:
@@ -80,60 +55,75 @@ def change_show(xmpp, jid, own_nick, show, status):
pres.send()
-def change_subject(xmpp, jid, subject):
- """
- Change the room subject
- """
- jid = safeJID(jid)
- msg = xmpp.make_message(jid)
- msg['type'] = 'groupchat'
- msg['subject'] = subject
- msg.send()
-
-
-def change_nick(core, jid, nick, status=None, show=None):
+def change_nick(
+ core: Core,
+ jid: Union[JID, str],
+ nick: str,
+ status: Optional[str] = None,
+ show: Optional[str] = None
+) -> None:
"""
Change our own nick in a room
"""
xmpp = core.xmpp
- presence = xmpp.make_presence(
- pshow=show, pstatus=status, pto=safeJID('%s/%s' % (jid, nick)))
+ presence: Presence = xmpp.make_presence(
+ pshow=show, pstatus=status, pto=JID('%s/%s' % (jid, nick)))
core.events.trigger('changing_nick', presence)
presence.send()
-def join_groupchat(core,
- jid,
- nick,
- passwd='',
- status=None,
- show=None,
- seconds=None):
+def join_groupchat(
+ core: Core,
+ jid: JID,
+ nick: str,
+ passwd: str = '',
+ status: Optional[str] = None,
+ show: Optional[str] = None,
+ seconds: Optional[int] = None,
+ tab: Optional['MucTab'] = None
+) -> None:
xmpp = core.xmpp
- stanza = xmpp.make_presence(
+ stanza: Presence = xmpp.make_presence(
pto='%s/%s' % (jid, nick), pstatus=status, pshow=show)
x = ET.Element('{http://jabber.org/protocol/muc}x')
if passwd:
passelement = ET.Element('password')
passelement.text = passwd
x.append(passelement)
- if seconds is not None:
- history = ET.Element('{http://jabber.org/protocol/muc}history')
- history.attrib['seconds'] = str(seconds)
- x.append(history)
- stanza.append(x)
- core.events.trigger('joining_muc', stanza)
- to = stanza["to"]
- stanza.send()
- xmpp.plugin['xep_0045'].rooms[jid] = {}
- xmpp.plugin['xep_0045'].our_nicks[jid] = to.resource
-
-
-def leave_groupchat(xmpp, jid, own_nick, msg):
+
+ def on_disco(iq: Iq) -> None:
+ if ('urn:xmpp:mam:2' in iq['disco_info'].get_features()
+ or (tab and tab._text_buffer.last_message)):
+ history = ET.Element('{http://jabber.org/protocol/muc}history')
+ history.attrib['seconds'] = str(0)
+ x.append(history)
+ else:
+ if seconds is not None:
+ history = ET.Element('{http://jabber.org/protocol/muc}history')
+ history.attrib['seconds'] = str(seconds)
+ x.append(history)
+ stanza.append(x)
+ core.events.trigger('joining_muc', stanza)
+ to = stanza["to"]
+ stanza.send()
+ xmpp.plugin['xep_0045'].rooms[jid] = {}
+ xmpp.plugin['xep_0045'].our_nicks[jid] = to.resource
+
+ asyncio.create_task(
+ xmpp.plugin['xep_0030'].get_info(jid=jid, callback=on_disco)
+ )
+
+
+def leave_groupchat(
+ xmpp: ClientXMPP,
+ jid: JID,
+ own_nick: str,
+ msg: str
+) -> None:
"""
Leave the groupchat
"""
- jid = safeJID(jid)
+ jid = JID(jid)
try:
xmpp.plugin['xep_0045'].leave_muc(jid, own_nick, msg)
except KeyError:
@@ -141,91 +131,3 @@ def leave_groupchat(xmpp, jid, own_nick, msg):
"muc.leave_groupchat: could not leave the room %s",
jid,
exc_info=True)
-
-
-def set_user_role(xmpp, jid, nick, reason, role, callback=None):
- """
- (try to) Set the role of a MUC user
- (role = 'none': eject user)
- """
- jid = safeJID(jid)
- iq = xmpp.make_iq_set()
- query = ET.Element('{%s}query' % NS_MUC_ADMIN)
- item = ET.Element('{%s}item' % NS_MUC_ADMIN, {'nick': nick, 'role': role})
- if reason:
- reason_el = ET.Element('{%s}reason' % NS_MUC_ADMIN)
- reason_el.text = reason
- item.append(reason_el)
- query.append(item)
- iq.append(query)
- iq['to'] = jid
- if callback:
- return iq.send(callback=callback)
- try:
- return iq.send()
- except (IqError, IqTimeout) as e:
- return e.iq
-
-
-def set_user_affiliation(xmpp,
- muc_jid,
- affiliation,
- nick=None,
- jid=None,
- reason=None,
- callback=None):
- """
- (try to) Set the affiliation of a MUC user
- """
- muc_jid = safeJID(muc_jid)
- query = ET.Element('{http://jabber.org/protocol/muc#admin}query')
- if nick:
- item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {
- 'affiliation': affiliation,
- 'nick': nick
- })
- else:
- item = ET.Element('{http://jabber.org/protocol/muc#admin}item', {
- 'affiliation': affiliation,
- 'jid': str(jid)
- })
-
- if reason:
- reason_item = ET.Element(
- '{http://jabber.org/protocol/muc#admin}reason')
- reason_item.text = reason
- item.append(reason_item)
-
- query.append(item)
- iq = xmpp.make_iq_set(query)
- iq['to'] = muc_jid
- if callback:
- return iq.send(callback=callback)
- try:
- return xmpp.plugin['xep_0045'].set_affiliation(
- str(muc_jid),
- str(jid) if jid else None, nick, affiliation)
- except:
- log.debug('Error setting the affiliation: %s', exc_info=True)
- return False
-
-
-def cancel_config(xmpp, room):
- query = ET.Element('{http://jabber.org/protocol/muc#owner}query')
- x = ET.Element('{jabber:x:data}x', type='cancel')
- query.append(x)
- iq = xmpp.make_iq_set(query)
- iq['to'] = room
- iq.send()
-
-
-def configure_room(xmpp, room, form):
- if form is None:
- return
- iq = xmpp.make_iq_set()
- iq['to'] = room
- query = ET.Element('{http://jabber.org/protocol/muc#owner}query')
- form['type'] = 'submit'
- query.append(form.xml)
- iq.append(query)
- iq.send()
diff --git a/poezio/pep.py b/poezio/pep.py
deleted file mode 100644
index 52cc4cd5..00000000
--- a/poezio/pep.py
+++ /dev/null
@@ -1,207 +0,0 @@
-"""
-Collection of mappings for PEP moods/activities
-extracted directly from the XEP
-"""
-
-from typing import Dict
-
-MOODS = {
- 'afraid': 'Afraid',
- 'amazed': 'Amazed',
- 'angry': 'Angry',
- 'amorous': 'Amorous',
- 'annoyed': 'Annoyed',
- 'anxious': 'Anxious',
- 'aroused': 'Aroused',
- 'ashamed': 'Ashamed',
- 'bored': 'Bored',
- 'brave': 'Brave',
- 'calm': 'Calm',
- 'cautious': 'Cautious',
- 'cold': 'Cold',
- 'confident': 'Confident',
- 'confused': 'Confused',
- 'contemplative': 'Contemplative',
- 'contented': 'Contented',
- 'cranky': 'Cranky',
- 'crazy': 'Crazy',
- 'creative': 'Creative',
- 'curious': 'Curious',
- 'dejected': 'Dejected',
- 'depressed': 'Depressed',
- 'disappointed': 'Disappointed',
- 'disgusted': 'Disgusted',
- 'dismayed': 'Dismayed',
- 'distracted': 'Distracted',
- 'embarrassed': 'Embarrassed',
- 'envious': 'Envious',
- 'excited': 'Excited',
- 'flirtatious': 'Flirtatious',
- 'frustrated': 'Frustrated',
- 'grumpy': 'Grumpy',
- 'guilty': 'Guilty',
- 'happy': 'Happy',
- 'hopeful': 'Hopeful',
- 'hot': 'Hot',
- 'humbled': 'Humbled',
- 'humiliated': 'Humiliated',
- 'hungry': 'Hungry',
- 'hurt': 'Hurt',
- 'impressed': 'Impressed',
- 'in_awe': 'In awe',
- 'in_love': 'In love',
- 'indignant': 'Indignant',
- 'interested': 'Interested',
- 'intoxicated': 'Intoxicated',
- 'invincible': 'Invincible',
- 'jealous': 'Jealous',
- 'lonely': 'Lonely',
- 'lucky': 'Lucky',
- 'mean': 'Mean',
- 'moody': 'Moody',
- 'nervous': 'Nervous',
- 'neutral': 'Neutral',
- 'offended': 'Offended',
- 'outraged': 'Outraged',
- 'playful': 'Playful',
- 'proud': 'Proud',
- 'relaxed': 'Relaxed',
- 'relieved': 'Relieved',
- 'remorseful': 'Remorseful',
- 'restless': 'Restless',
- 'sad': 'Sad',
- 'sarcastic': 'Sarcastic',
- 'serious': 'Serious',
- 'shocked': 'Shocked',
- 'shy': 'Shy',
- 'sick': 'Sick',
- 'sleepy': 'Sleepy',
- 'spontaneous': 'Spontaneous',
- 'stressed': 'Stressed',
- 'strong': 'Strong',
- 'surprised': 'Surprised',
- 'thankful': 'Thankful',
- 'thirsty': 'Thirsty',
- 'tired': 'Tired',
- 'undefined': 'Undefined',
- 'weak': 'Weak',
- 'worried': 'Worried'
-} # type: Dict[str, str]
-
-ACTIVITIES = {
- 'doing_chores': {
- 'category': 'Doing_chores',
- 'buying_groceries': 'Buying groceries',
- 'cleaning': 'Cleaning',
- 'cooking': 'Cooking',
- 'doing_maintenance': 'Doing maintenance',
- 'doing_the_dishes': 'Doing the dishes',
- 'doing_the_laundry': 'Doing the laundry',
- 'gardening': 'Gardening',
- 'running_an_errand': 'Running an errand',
- 'walking_the_dog': 'Walking the dog',
- 'other': 'Other',
- },
- 'drinking': {
- 'category': 'Drinking',
- 'having_a_beer': 'Having a beer',
- 'having_coffee': 'Having coffee',
- 'having_tea': 'Having tea',
- 'other': 'Other',
- },
- 'eating': {
- 'category': 'Eating',
- 'having_breakfast': 'Having breakfast',
- 'having_a_snack': 'Having a snack',
- 'having_dinner': 'Having dinner',
- 'having_lunch': 'Having lunch',
- 'other': 'Other',
- },
- 'exercising': {
- 'category': 'Exercising',
- 'cycling': 'Cycling',
- 'dancing': 'Dancing',
- 'hiking': 'Hiking',
- 'jogging': 'Jogging',
- 'playing_sports': 'Playing sports',
- 'running': 'Running',
- 'skiing': 'Skiing',
- 'swimming': 'Swimming',
- 'working_out': 'Working out',
- 'other': 'Other',
- },
- 'grooming': {
- 'category': 'Grooming',
- 'at_the_spa': 'At the spa',
- 'brushing_teeth': 'Brushing teeth',
- 'getting_a_haircut': 'Getting a haircut',
- 'shaving': 'Shaving',
- 'taking_a_bath': 'Taking a bath',
- 'taking_a_shower': 'Taking a shower',
- 'other': 'Other',
- },
- 'having_appointment': {
- 'category': 'Having appointment',
- 'other': 'Other',
- },
- 'inactive': {
- 'category': 'Inactive',
- 'day_off': 'Day_off',
- 'hanging_out': 'Hanging out',
- 'hiding': 'Hiding',
- 'on_vacation': 'On vacation',
- 'praying': 'Praying',
- 'scheduled_holiday': 'Scheduled holiday',
- 'sleeping': 'Sleeping',
- 'thinking': 'Thinking',
- 'other': 'Other',
- },
- 'relaxing': {
- 'category': 'Relaxing',
- 'fishing': 'Fishing',
- 'gaming': 'Gaming',
- 'going_out': 'Going out',
- 'partying': 'Partying',
- 'reading': 'Reading',
- 'rehearsing': 'Rehearsing',
- 'shopping': 'Shopping',
- 'smoking': 'Smoking',
- 'socializing': 'Socializing',
- 'sunbathing': 'Sunbathing',
- 'watching_a_movie': 'Watching a movie',
- 'watching_tv': 'Watching tv',
- 'other': 'Other',
- },
- 'talking': {
- 'category': 'Talking',
- 'in_real_life': 'In real life',
- 'on_the_phone': 'On the phone',
- 'on_video_phone': 'On video phone',
- 'other': 'Other',
- },
- 'traveling': {
- 'category': 'Traveling',
- 'commuting': 'Commuting',
- 'driving': 'Driving',
- 'in_a_car': 'In a car',
- 'on_a_bus': 'On a bus',
- 'on_a_plane': 'On a plane',
- 'on_a_train': 'On a train',
- 'on_a_trip': 'On a trip',
- 'walking': 'Walking',
- 'cycling': 'Cycling',
- 'other': 'Other',
- },
- 'undefined': {
- 'category': 'Undefined',
- 'other': 'Other',
- },
- 'working': {
- 'category': 'Working',
- 'coding': 'Coding',
- 'in_a_meeting': 'In a meeting',
- 'writing': 'Writing',
- 'studying': 'Studying',
- 'other': 'Other',
- }
-} # type: Dict[str, Dict[str, str]]
diff --git a/poezio/plugin.py b/poezio/plugin.py
index 7e67d09c..f38e47e2 100644
--- a/poezio/plugin.py
+++ b/poezio/plugin.py
@@ -3,6 +3,9 @@ Define the PluginConfig and Plugin classes, plus the SafetyMetaclass.
These are used in the plugin system added in poezio 0.7.5
(see plugin_manager.py)
"""
+
+from typing import Any, Dict, Set, Optional
+from asyncio import iscoroutinefunction
from functools import partial
from configparser import RawConfigParser
from poezio.timed_events import TimedEvent, DelayedEvent
@@ -23,6 +26,7 @@ class PluginConfig(config.Config):
def __init__(self, filename, module_name, default=None):
config.Config.__init__(self, filename, default=default)
self.module_name = module_name
+ self.default_section = module_name
self.read()
def get(self, option, default=None, section=None):
@@ -42,7 +46,7 @@ class PluginConfig(config.Config):
def read(self):
"""Read the config file"""
- RawConfigParser.read(self, str(self.file_name))
+ RawConfigParser.read(self.configparser, str(self.file_name))
if not self.has_section(self.module_name):
self.add_section(self.module_name)
@@ -61,7 +65,7 @@ class PluginConfig(config.Config):
"""Write the config to the disk"""
try:
with self.file_name.open('w') as fp:
- RawConfigParser.write(self, fp)
+ RawConfigParser.write(self.configparser, fp)
return True
except IOError:
return False
@@ -74,9 +78,12 @@ class SafetyMetaclass(type):
@staticmethod
def safe_func(f):
def helper(*args, **kwargs):
+ passthrough = kwargs.pop('passthrough', False)
try:
return f(*args, **kwargs)
except:
+ if passthrough:
+ raise
if inspect.stack()[1][1] == inspect.getfile(f):
raise
elif SafetyMetaclass.core:
@@ -84,9 +91,25 @@ class SafetyMetaclass(type):
SafetyMetaclass.core.information(traceback.format_exc(),
'Error')
return None
-
+ async def async_helper(*args, **kwargs):
+ passthrough = kwargs.pop('passthrough', False)
+ try:
+ return await f(*args, **kwargs)
+ except:
+ if passthrough:
+ raise
+ if inspect.stack()[1][1] == inspect.getfile(f):
+ raise
+ elif SafetyMetaclass.core:
+ log.error('Error in a plugin', exc_info=True)
+ SafetyMetaclass.core.information(traceback.format_exc(),
+ 'Error')
+ return None
+ if iscoroutinefunction(f):
+ return async_helper
return helper
+
def __new__(meta, name, bases, class_dict):
for k, v in class_dict.items():
if inspect.isfunction(v):
@@ -379,28 +402,35 @@ class BasePlugin(object, metaclass=SafetyMetaclass):
Class that all plugins derive from.
"""
- default_config = None
+ # Internal use only
+ _unloading = False
+
+ default_config: Optional[Dict[Any, Any]] = None
+ dependencies: Set[str] = set()
+ # This dict will get populated when the plugin is initialized
+ refs: Dict[str, Any] = {}
- def __init__(self, plugin_api, core, plugins_conf_dir):
+ def __init__(self, name, plugin_api, core, plugins_conf_dir):
+ self.__name = name
self.core = core
# More hack; luckily we'll never have more than one core object
SafetyMetaclass.core = core
- conf = plugins_conf_dir / (self.__module__ + '.cfg')
+ conf = plugins_conf_dir / (self.__name + '.cfg')
try:
self.config = PluginConfig(
- conf, self.__module__, default=self.default_config)
+ conf, self.__name, default=self.default_config)
except Exception:
log.debug('Error while creating the plugin config', exc_info=True)
- self.config = PluginConfig(conf, self.__module__)
+ self.config = PluginConfig(conf, self.__name)
self._api = plugin_api[self.name]
self.init()
@property
- def name(self):
+ def name(self) -> str:
"""
Get the name (module name) of the plugin.
"""
- return self.__module__
+ return self.__name
@property
def api(self):
@@ -501,12 +531,12 @@ class BasePlugin(object, metaclass=SafetyMetaclass):
"""
return self.api.del_tab_command(tab_type, name)
- def add_event_handler(self, event_name, handler, position=0):
+ def add_event_handler(self, event_name, handler, *args, **kwargs):
"""
Add an event handler to the event event_name.
An optional position in the event handler list can be provided.
"""
- return self.api.add_event_handler(event_name, handler, position)
+ return self.api.add_event_handler(event_name, handler, *args, **kwargs)
def del_event_handler(self, event_name, handler):
"""
diff --git a/poezio/plugin_e2ee.py b/poezio/plugin_e2ee.py
new file mode 100644
index 00000000..49f7b067
--- /dev/null
+++ b/poezio/plugin_e2ee.py
@@ -0,0 +1,685 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# vim:fenc=utf-8 et ts=4 sts=4 sw=4
+#
+# Copyright © 2019 Maxime “pep” Buquet <pep@bouah.net>
+#
+# Distributed under terms of the GPL-3.0+ license. See COPYING file.
+
+"""
+ Interface for E2EE (End-to-end Encryption) plugins.
+"""
+
+from typing import (
+ Callable,
+ Dict,
+ List,
+ Optional,
+ Union,
+ Tuple,
+ Set,
+ Type,
+)
+
+from slixmpp import InvalidJID, JID, Message
+from slixmpp.xmlstream import StanzaBase
+from slixmpp.xmlstream.handler import CoroutineCallback
+from slixmpp.xmlstream.matcher import MatchXPath
+from poezio.tabs import (
+ ChatTab,
+ ConversationTab,
+ DynamicConversationTab,
+ MucTab,
+ PrivateTab,
+ RosterInfoTab,
+ StaticConversationTab,
+)
+from poezio.plugin import BasePlugin
+from poezio.theming import Theme, get_theme, dump_tuple
+from poezio.config import config
+from poezio.decorators import command_args_parser
+
+import asyncio
+from asyncio import iscoroutinefunction
+
+import logging
+log = logging.getLogger(__name__)
+
+
+ChatTabs = Union[
+ MucTab,
+ DynamicConversationTab,
+ StaticConversationTab,
+ PrivateTab,
+]
+
+EME_NS = 'urn:xmpp:eme:0'
+EME_TAG = 'encryption'
+
+JCLIENT_NS = 'jabber:client'
+HINTS_NS = 'urn:xmpp:hints'
+
+class NothingToEncrypt(Exception):
+ """
+ Exception to raise inside the _encrypt filter on stanzas that do not need
+ to be processed.
+ """
+
+
+class E2EEPlugin(BasePlugin):
+ """Interface for E2EE plugins.
+
+ This is a wrapper built on top of BasePlugin. It provides a base for
+ End-to-end Encryption mechanisms in poezio.
+
+ Plugin developers are excepted to implement the `decrypt` and
+ `encrypt` function, provide an encryption name (and/or short name),
+ and an eme namespace.
+
+ Once loaded, the plugin will attempt to decrypt any message that
+ contains an EME message that matches the one set.
+
+ The plugin will also register a command (using the short name) to
+ enable encryption per tab. It is only possible to have one encryption
+ mechanism per tab, even if multiple e2ee plugins are loaded.
+
+ The encryption status will be displayed in the status bar, using the
+ plugin short name, alongside the JID, nickname etc.
+ """
+
+ #: Specifies that the encryption mechanism does more than encrypting
+ #: `<body/>`.
+ stanza_encryption = False
+
+ #: Whitelist applied to messages when `stanza_encryption` is `False`.
+ tag_whitelist = [
+ (JCLIENT_NS, 'body'),
+ (EME_NS, EME_TAG),
+ (HINTS_NS, 'store'),
+ (HINTS_NS, 'no-copy'),
+ (HINTS_NS, 'no-store'),
+ (HINTS_NS, 'no-permanent-store'),
+ # TODO: Add other encryption mechanisms tags here
+ ]
+
+ #: Replaces body with `eme <https://xmpp.org/extensions/xep-0380.html>`_
+ #: if set. Should be suitable for most plugins except those using
+ #: `<body/>` directly as their encryption container, like OTR, or the
+ #: example base64 plugin in poezio.
+ replace_body_with_eme = True
+
+ #: Encryption name, used in command descriptions, and logs. At least one
+ #: of `encryption_name` and `encryption_short_name` must be set.
+ encryption_name: Optional[str] = None
+
+ #: Encryption short name, used as command name, and also to display
+ #: encryption status in a tab. At least one of `encryption_name` and
+ #: `encryption_short_name` must be set.
+ encryption_short_name: Optional[str] = None
+
+ #: Required. https://xmpp.org/extensions/xep-0380.html.
+ eme_ns: Optional[str] = None
+
+ #: Used to figure out what messages to attempt decryption for. Also used
+ #: in combination with `tag_whitelist` to avoid removing encrypted tags
+ #: before sending. If multiple tags are present, a handler will be
+ #: registered for each invididual tag/ns pair under <message/>, as opposed
+ #: to a single handler for all tags combined.
+ encrypted_tags: Optional[List[Tuple[str, str]]] = None
+
+ # Static map, to be able to limit to one encryption mechanism per tab at a
+ # time
+ _enabled_tabs: Dict[JID, Callable] = {}
+
+ # Tabs that support this encryption mechanism
+ supported_tab_types: Tuple[Type[ChatTab], ...] = tuple()
+
+ # States for each remote entity
+ trust_states: Dict[str, Set[str]] = {'accepted': set(), 'rejected': set()}
+
+ def init(self):
+ self._all_trust_states = self.trust_states['accepted'].union(
+ self.trust_states['rejected']
+ )
+ if self.encryption_name is None and self.encryption_short_name is None:
+ raise NotImplementedError
+
+ if self.eme_ns is None:
+ raise NotImplementedError
+
+ if self.encryption_name is None:
+ self.encryption_name = self.encryption_short_name
+ if self.encryption_short_name is None:
+ self.encryption_short_name = self.encryption_name
+
+ if not self.supported_tab_types:
+ raise NotImplementedError
+
+ # Ensure decryption is done before everything, so that other handlers
+ # don't have to know about the encryption mechanism.
+ self.api.add_event_handler('muc_msg', self._decrypt_wrapper, priority=0)
+ self.api.add_event_handler('conversation_msg', self._decrypt_wrapper, priority=0)
+ self.api.add_event_handler('private_msg', self._decrypt_wrapper, priority=0)
+
+ # Register a handler for each invididual tag/ns pair in encrypted_tags
+ # as well. as _msg handlers only include messages with a <body/>.
+ if self.encrypted_tags is not None:
+ default_ns = self.core.xmpp.default_ns
+ for i, (namespace, tag) in enumerate(self.encrypted_tags):
+ self.core.xmpp.register_handler(CoroutineCallback(f'EncryptedTag{i}',
+ MatchXPath(f'{{{default_ns}}}message/{{{namespace}}}{tag}'),
+ self._decrypt_encryptedtag,
+ ))
+
+ # Ensure encryption is done after everything, so that whatever can be
+ # encrypted is encrypted, and no plain element slips in.
+ # Using a stream filter might be a bit too much, but at least we're
+ # sure poezio is not sneaking anything past us.
+ self.core.xmpp.add_filter('out', self._encrypt_wrapper)
+
+ for tab_t in self.supported_tab_types:
+ self.api.add_tab_command(
+ tab_t,
+ self.encryption_short_name,
+ self._toggle_tab,
+ usage='',
+ short='Toggle {} encryption for tab.'.format(self.encryption_name),
+ help='Toggle automatic {} encryption for tab.'.format(self.encryption_name),
+ )
+
+ trust_msg = 'Set {name} state to {state} for this fingerprint on this JID.'
+ for state in self._all_trust_states:
+ for tab_t in self.supported_tab_types:
+ self.api.add_tab_command(
+ tab_t,
+ self.encryption_short_name + '_' + state,
+ lambda args: self.__command_set_state_local(args, state),
+ usage='<fingerprint>',
+ short=trust_msg.format(name=self.encryption_short_name, state=state),
+ help=trust_msg.format(name=self.encryption_short_name, state=state),
+ )
+ self.api.add_command(
+ self.encryption_short_name + '_' + state,
+ lambda args: self.__command_set_state_global(args, state),
+ usage='<JID> <fingerprint>',
+ short=trust_msg.format(name=self.encryption_short_name, state=state),
+ help=trust_msg.format(name=self.encryption_short_name, state=state),
+ )
+
+ self.api.add_command(
+ self.encryption_short_name + '_fingerprint',
+ self._command_show_fingerprints,
+ usage='[jid]',
+ short=f'Show {self.encryption_short_name} fingerprint(s) for a JID.',
+ help=f'Show {self.encryption_short_name} fingerprint(s) for a JID.',
+ )
+
+ ConversationTab.add_information_element(
+ self.encryption_short_name,
+ self._display_encryption_status,
+ )
+ MucTab.add_information_element(
+ self.encryption_short_name,
+ self._display_encryption_status,
+ )
+ PrivateTab.add_information_element(
+ self.encryption_short_name,
+ self._display_encryption_status,
+ )
+
+ self.__load_encrypted_states()
+
+ def __load_encrypted_states(self) -> None:
+ """Load previously stored encryption states for jids."""
+ for section in config.sections():
+ value = config.getstr('encryption', section=section)
+ if value and value == self.encryption_short_name:
+ section_jid = JID(section)
+ self._enabled_tabs[section_jid] = self.encrypt
+
+ def cleanup(self):
+ ConversationTab.remove_information_element(self.encryption_short_name)
+ MucTab.remove_information_element(self.encryption_short_name)
+ PrivateTab.remove_information_element(self.encryption_short_name)
+
+ def _display_encryption_status(self, jid_s: str) -> str:
+ """
+ Return information to display in the infobar if encryption is
+ enabled for the JID.
+ """
+
+ try:
+ jid = JID(jid_s)
+ except InvalidJID:
+ return ""
+
+ if self._encryption_enabled(jid) and self.encryption_short_name:
+ return " " + self.encryption_short_name
+ return ""
+
+ def _toggle_tab(self, _input: str) -> None:
+ tab = self.api.current_tab()
+ jid: JID = tab.jid
+
+ if self._encryption_enabled(jid):
+ del self._enabled_tabs[jid]
+ tab.e2e_encryption = None
+ config.remove_and_save('encryption', section=jid)
+ self.api.information(
+ f'{self.encryption_name} encryption disabled for {jid}',
+ 'Info',
+ )
+ elif self.encryption_short_name:
+ self._enabled_tabs[jid] = self.encrypt
+ tab.e2e_encryption = self.encryption_name
+ config.set_and_save('encryption', self.encryption_short_name, section=jid)
+ self.api.information(
+ f'{self.encryption_name} encryption enabled for {jid}',
+ 'Info',
+ )
+
+ @staticmethod
+ def format_fingerprint(fingerprint: str, own: bool, theme: Theme) -> str:
+ return fingerprint
+
+ async def _show_fingerprints(self, jid: JID) -> None:
+ """Display encryption fingerprints for a JID."""
+ theme = get_theme()
+ fprs = await self.get_fingerprints(jid)
+ if len(fprs) == 1:
+ fp, own = fprs[0]
+ fingerprint = self.format_fingerprint(fp, own, theme)
+ self.api.information(
+ f'Fingerprint for {jid}:\n{fingerprint}',
+ 'Info',
+ )
+ elif fprs:
+ fmt_fprs = map(lambda fp: self.format_fingerprint(fp[0], fp[1], theme), fprs)
+ self.api.information(
+ 'Fingerprints for %s:\n%s' % (jid, '\n\n'.join(fmt_fprs)),
+ 'Info',
+ )
+ else:
+ self.api.information(
+ f'{jid}: No fingerprints to display',
+ 'Info',
+ )
+
+ @command_args_parser.quoted(0, 1)
+ def _command_show_fingerprints(self, args: List[str]) -> None:
+ tab = self.api.current_tab()
+ if not args and isinstance(tab, self.supported_tab_types):
+ jid = tab.jid
+ if isinstance(tab, MucTab):
+ jid = self.core.xmpp.boundjid.bare
+ elif not args and isinstance(tab, RosterInfoTab):
+ # Allow running the command without arguments in roster tab
+ jid = self.core.xmpp.boundjid.bare
+ elif args:
+ jid = args[0]
+ else:
+ shortname = self.encryption_short_name
+ self.api.information(
+ f'{shortname}_fingerprint: Couldn\'t deduce JID from context',
+ 'Error',
+ )
+ return None
+ asyncio.create_task(self._show_fingerprints(JID(jid)))
+
+ @command_args_parser.quoted(2)
+ def __command_set_state_global(self, args, state='') -> None:
+ if not args:
+ self.api.information(
+ 'No fingerprint provided to the command..',
+ 'Error',
+ )
+ return
+ jid, fpr = args
+ if state not in self._all_trust_states:
+ shortname = self.encryption_short_name
+ self.api.information(
+ f'Unknown state for plugin {shortname}: {state}',
+ 'Error'
+ )
+ return
+ self.store_trust(jid, state, fpr)
+
+ @command_args_parser.quoted(1)
+ def __command_set_state_local(self, args, state='') -> None:
+ if isinstance(self.api.current_tab(), MucTab):
+ self.api.information(
+ 'You can only trust each participant of a MUC individually.',
+ 'Info',
+ )
+ return
+ jid = self.api.current_tab().jid
+ if not args:
+ self.api.information(
+ 'No fingerprint provided to the command..',
+ 'Error',
+ )
+ return
+ fpr = args[0]
+ if state not in self._all_trust_states:
+ shortname = self.encryption_short_name
+ self.api.information(
+ f'Unknown state for plugin {shortname}: {state}',
+ 'Error',
+ )
+ return
+ self.store_trust(jid, state, fpr)
+
+ def _encryption_enabled(self, jid: JID) -> bool:
+ return self._enabled_tabs.get(jid) == self.encrypt
+
+ async def _encrypt_wrapper(self, stanza: StanzaBase) -> Optional[StanzaBase]:
+ """
+ Wrapper around _encrypt() to handle errors and display the message after encryption.
+ """
+ try:
+ # pylint: disable=unexpected-keyword-arg
+ result = await self._encrypt(stanza, passthrough=True)
+ except NothingToEncrypt:
+ return stanza
+ except Exception as exc:
+ jid = stanza['from']
+ tab = self.core.tabs.by_name_and_class(jid, ChatTab)
+ msg = ' \n\x19%s}Could not decrypt message: %s' % (
+ dump_tuple(get_theme().COLOR_CHAR_NACK),
+ exc,
+ )
+ # XXX: check before commit. Do we not nack in MUCs?
+ if tab and not isinstance(tab, MucTab):
+ tab.nack_message(msg, stanza['id'], stanza['to'])
+ # TODO: display exceptions to the user properly
+ log.error('Exception in encrypt:', exc_info=True)
+ return None
+ return result
+
+ async def _decrypt_wrapper(self, stanza: Message, tab: Optional[ChatTabs]) -> None:
+ """
+ Wrapper around _decrypt() to handle errors and display the message after encryption.
+ """
+ try:
+ # pylint: disable=unexpected-keyword-arg
+ await self._decrypt(stanza, tab, passthrough=True)
+ except Exception as exc:
+ jid = stanza['to']
+ tab = self.core.tabs.by_name_and_class(jid, ChatTab)
+ msg = ' \n\x19%s}Could not send message: %s' % (
+ dump_tuple(get_theme().COLOR_CHAR_NACK),
+ exc,
+ )
+ # XXX: check before commit. Do we not nack in MUCs?
+ if tab and not isinstance(tab, MucTab):
+ tab.nack_message(msg, stanza['id'], stanza['from'])
+ # TODO: display exceptions to the user properly
+ log.error('Exception in decrypt:', exc_info=True)
+ return None
+ return None
+
+ async def _decrypt_encryptedtag(self, stanza: Message) -> None:
+ """
+ Handler to decrypt encrypted_tags elements that are matched separately
+ from other messages because the default 'message' handler that we use
+ only matches messages containing a <body/>.
+ """
+ # If the message contains a body, it will already be handled by the
+ # other handler. If not, pass it to the handler.
+ if stanza.xml.find(f'{{{self.core.xmpp.default_ns}}}body') is not None:
+ return None
+
+ mfrom = stanza['from']
+
+ # Find what tab this message corresponds to.
+ if stanza['type'] == 'groupchat': # MUC
+ tab = self.core.tabs.by_name_and_class(
+ name=mfrom.bare, cls=MucTab,
+ )
+ elif self.core.handler.is_known_muc_pm(stanza, mfrom): # MUC-PM
+ tab = self.core.tabs.by_name_and_class(
+ name=mfrom.full, cls=PrivateTab,
+ )
+ else: # 1:1
+ tab = self.core.get_conversation_by_jid(
+ jid=JID(mfrom.bare),
+ create=False,
+ fallback_barejid=True,
+ )
+ log.debug('Found tab %r for encrypted message', tab)
+ await self._decrypt_wrapper(stanza, tab)
+
+ async def _decrypt(self, message: Message, tab: Optional[ChatTabs], passthrough: bool = True) -> None:
+
+ has_eme: bool = False
+ if message.xml.find(f'{{{EME_NS}}}{EME_TAG}') is not None and \
+ message['eme']['namespace'] == self.eme_ns:
+ has_eme = True
+
+ has_encrypted_tag: bool = False
+ if not has_eme and self.encrypted_tags is not None:
+ tmp: bool = True
+ for (namespace, tag) in self.encrypted_tags:
+ tmp = tmp and message.xml.find(f'{{{namespace}}}{tag}') is not None
+ has_encrypted_tag = tmp
+
+ if not has_eme and not has_encrypted_tag:
+ return None
+
+ log.debug('Received %s message: %r', self.encryption_name, message['body'])
+
+ # Get the original JID of the sender. The JID might be None if it
+ # comes from a semi-anonymous MUC for example. Some plugins might be
+ # fine with this so let them handle it.
+ jid = message['from']
+
+ muctab: Optional[MucTab] = None
+ if isinstance(tab, PrivateTab):
+ muctab = tab.parent_muc
+ jid = None
+
+ if muctab is not None or isinstance(tab, MucTab):
+ if muctab is None:
+ muctab = tab # type: ignore
+ nick = message['from'].resource
+ user = muctab.get_user_by_name(nick) # type: ignore
+ if user is not None:
+ jid = user.jid or None
+
+ # Call the enabled encrypt method
+ func = self.decrypt
+ if iscoroutinefunction(func):
+ # pylint: disable=unexpected-keyword-arg
+ await func(message, jid, tab, passthrough=True) # type: ignore
+ else:
+ # pylint: disable=unexpected-keyword-arg
+ func(message, jid, tab) # type: ignore
+
+ log.debug('Decrypted %s message: %r', self.encryption_name, message['body'])
+ return None
+
+ async def _encrypt(self, stanza: StanzaBase, passthrough: bool = True) -> Optional[StanzaBase]:
+ # TODO: Let through messages that contain elements that don't need to
+ # be encrypted even in an encrypted context, such as MUC mediated
+ # invites, etc.
+ # What to do when they're mixed with other elements? It probably
+ # depends on the element. Maybe they can be mixed with
+ # `self.tag_whitelist` that are already assumed to be sent as plain by
+ # the E2EE plugin.
+ # They might not be accompanied by a <body/> most of the time, nor by
+ # an encrypted tag.
+
+ if not isinstance(stanza, Message) or stanza['type'] not in ('normal', 'chat', 'groupchat'):
+ raise NothingToEncrypt()
+ message = stanza
+
+
+ # Is this message already encrypted? Do we need to do all these
+ # checks? Such as an OMEMO heartbeat.
+ has_encrypted_tag: bool = False
+ if self.encrypted_tags is not None:
+ tmp: bool = True
+ for (namespace, tag) in self.encrypted_tags:
+ tmp = tmp and message.xml.find(f'{{{namespace}}}{tag}') is not None
+ has_encrypted_tag = tmp
+
+ if has_encrypted_tag:
+ log.debug('Message already contains encrypted tags.')
+ raise NothingToEncrypt()
+
+ # Find who to encrypt to. If in a groupchat this can be multiple JIDs.
+ # It is possible that we are not able to find a jid (e.g., semi-anon
+ # MUCs). Let the plugin decide what to do with this information.
+ jids: Optional[List[JID]] = [message['to']]
+ tab = self.core.tabs.by_jid(message['to'])
+ if tab is None and message['to'].resource:
+ # Redo the search with the bare JID
+ tab = self.core.tabs.by_jid(message['to'].bare)
+
+ if tab is None: # Possible message sent directly by the e2ee lib?
+ log.debug(
+ 'A message we do not have a tab for '
+ 'is being sent to \'%s\'. \n%r.',
+ message['to'],
+ message,
+ )
+
+ parent = None
+ if isinstance(tab, PrivateTab):
+ parent = tab.parent_muc
+ nick = tab.jid.resource
+ jids = None
+
+ for user in parent.users:
+ if user.nick == nick:
+ jids = user.jid or None
+ break
+
+ if isinstance(tab, MucTab):
+ jids = []
+ for user in tab.users:
+ # If the JID of a user is None, assume all others are None and
+ # we are in a (at least) semi-anon room. TODO: Really check if
+ # the room is semi-anon. Currently a moderator of a semi-anon
+ # room will possibly encrypt to everybody, leaking their
+ # public key/identity, and they wouldn't be able to decrypt it
+ # anyway if they don't know the moderator's JID.
+ # TODO: Change MUC to give easier access to this information.
+ if user.jid is None:
+ jids = None
+ break
+ # If we encrypt to all of these JIDs is up to the plugin, we
+ # just tell it who is in the room.
+ # XXX: user.jid shouldn't be empty. That's a MucTab/slixmpp
+ # bug.
+ if user.jid.bare:
+ jids.append(user.jid)
+
+ if tab and not self._encryption_enabled(tab.jid):
+ raise NothingToEncrypt()
+
+ log.debug('Sending %s message', self.encryption_name)
+
+ has_body = message.xml.find('{%s}%s' % (JCLIENT_NS, 'body')) is not None
+
+ if not self._encryption_enabled(tab.jid):
+ raise NothingToEncrypt()
+
+ # Drop all messages that don't contain a body if the plugin doesn't do
+ # Stanza Encryption
+ if not self.stanza_encryption and not has_body:
+ log.debug(
+ '%s plugin: Dropping message as it contains no body, and '
+ 'doesn\'t do stanza encryption',
+ self.encryption_name,
+ )
+ return None
+
+ # Call the enabled encrypt method
+ func = self._enabled_tabs[tab.jid]
+ if iscoroutinefunction(func):
+ # pylint: disable=unexpected-keyword-arg
+ await func(message, jids, tab, passthrough=True)
+ else:
+ # pylint: disable=unexpected-keyword-arg
+ func(message, jids, tab, passthrough=True)
+
+ if has_body:
+ # Only add EME tag if the message has a body.
+ # Per discussion in jdev@:
+ # The receiving client needs to know the message contains
+ # meaningful information or not to display notifications to the
+ # user, and not display anything when it's e.g., a chatstate.
+ # This does leak the fact that the encrypted payload contains a
+ # message.
+ message['eme']['namespace'] = self.eme_ns
+ message['eme']['name'] = self.encryption_name
+
+ if self.replace_body_with_eme:
+ self.core.xmpp['xep_0380'].replace_body_with_eme(message)
+
+ # Filter stanza with the whitelist. Plugins doing stanza encryption
+ # will have to include these in their encrypted container beforehand.
+ whitelist = self.tag_whitelist
+ if self.encrypted_tags is not None:
+ whitelist += self.encrypted_tags
+
+ tag_whitelist = {f'{{{ns}}}{tag}' for (ns, tag) in whitelist}
+
+ for elem in message.xml[:]:
+ if elem.tag not in tag_whitelist:
+ message.xml.remove(elem)
+
+ log.debug('Encrypted %s message', self.encryption_name)
+ return message
+
+ def store_trust(self, jid: JID, state: str, fingerprint: str) -> None:
+ """Store trust for a fingerprint and a jid."""
+ option_name = f'{self.encryption_short_name}:{fingerprint}'
+ config.silent_set(option=option_name, value=state, section=jid)
+
+ def fetch_trust(self, jid: JID, fingerprint: str) -> str:
+ """Fetch trust of a fingerprint and a jid."""
+ option_name = f'{self.encryption_short_name}:{fingerprint}'
+ return config.getstr(option=option_name, section=jid)
+
+ async def decrypt(self, message: Message, jid: Optional[JID], tab: Optional[ChatTab]):
+ """Decryption method
+
+ This is a method the plugin must implement. It is expected that this
+ method will edit the received message and return nothing.
+
+ :param message: Message to be decrypted.
+ :param jid: Real Jid of the sender if available. We might be
+ talking through a semi-anonymous MUC where real JIDs are
+ not available.
+ :param tab: Tab the message is coming from.
+
+ :returns: None
+ """
+
+ raise NotImplementedError
+
+ async def encrypt(self, message: Message, jids: Optional[List[JID]], tab: ChatTabs):
+ """Encryption method
+
+ This is a method the plugin must implement. It is expected that this
+ method will edit the received message and return nothing.
+
+ :param message: Message to be encrypted.
+ :param jids: Real Jids of all possible recipients.
+ :param tab: Tab the message is going to.
+
+ :returns: None
+ """
+
+ raise NotImplementedError
+
+ async def get_fingerprints(self, jid: JID) -> List[Tuple[str, bool]]:
+ """Show fingerprint(s) for this encryption method and JID.
+
+ To overload in plugins.
+
+ :returns: A list of fingerprints to display
+ """
+ return []
diff --git a/poezio/plugin_manager.py b/poezio/plugin_manager.py
index 89849747..17673a9e 100644
--- a/poezio/plugin_manager.py
+++ b/poezio/plugin_manager.py
@@ -5,10 +5,13 @@ the API together. Defines also a bunch of variables related to the
plugin env.
"""
+import logging
import os
-from os import path
+from typing import Dict, Set
+from importlib import import_module, machinery
from pathlib import Path
-import logging
+from os import path
+import pkg_resources
from poezio import tabs, xdg
from poezio.core.structs import Command, Completion
@@ -25,6 +28,8 @@ class PluginManager:
And keeps track of everything the plugin has done through the API.
"""
+ rdeps: Dict[str, Set[str]] = {}
+
def __init__(self, core):
self.core = core
# module name -> module object
@@ -44,7 +49,6 @@ class PluginManager:
self.tab_keys = {}
self.roster_elements = {}
- from importlib import machinery
self.finder = machinery.PathFinder()
self.initial_set_plugins_dir()
@@ -57,21 +61,56 @@ class PluginManager:
for plugin in set(self.plugins.keys()):
self.unload(plugin, notify=False)
- def load(self, name, notify=True):
+ def set_rdeps(self, name):
+ """
+ Runs through plugin dependencies to build the reverse dependencies table.
+ """
+
+ if name not in self.rdeps:
+ self.rdeps[name] = set()
+ for dep in self.plugins[name].dependencies:
+ if dep not in self.rdeps:
+ self.rdeps[dep] = {name}
+ else:
+ self.rdeps[dep].add(name)
+
+ def load(self, name: str, notify=True, unload_first=True):
"""
Load a plugin.
"""
+ if not unload_first and name in self.plugins:
+ return None
if name in self.plugins:
self.unload(name)
try:
module = None
loader = self.finder.find_module(name, self.load_path)
- if not loader:
+ if loader:
+ log.debug('Found candidate loader for plugin %s: %r', name, loader)
+ module = loader.load_module()
+ if module is None:
+ log.debug('Failed to load plugin %s from loader', name)
+ else:
+ try:
+ module = import_module('poezio_plugins.%s' % name)
+ except ModuleNotFoundError:
+ pass
+ for entry in pkg_resources.iter_entry_points('poezio_plugins'):
+ if entry.name == name:
+ log.debug('Found candidate entry for plugin %s: %r', name, entry)
+ try:
+ module = entry.load()
+ except Exception as exn:
+ log.debug('Failed to import plugin: %s\n%r', name,
+ exn, exc_info=True)
+ finally:
+ break
+ if not module:
self.core.information('Could not find plugin: %s' % name,
'Error')
return
- module = loader.load_module()
+ log.debug('Plugin %s loaded from "%s"', name, module.__file__)
except Exception as e:
log.debug("Could not load plugin %s", name, exc_info=True)
self.core.information("Could not load plugin %s: %s" % (name, e),
@@ -88,8 +127,22 @@ class PluginManager:
self.event_handlers[name] = []
try:
self.plugins[name] = None
- self.plugins[name] = module.Plugin(self.plugin_api, self.core,
+
+ for dep in module.Plugin.dependencies:
+ self.load(dep, unload_first=False)
+ if dep not in self.plugins:
+ log.debug(
+ 'Plugin %s couldn\'t load because of dependency %s',
+ name, dep
+ )
+ return None
+ # Add reference of the dep to the plugin's usage
+ module.Plugin.refs[dep] = self.plugins[dep]
+
+ self.plugins[name] = module.Plugin(name, self.plugin_api, self.core,
self.plugins_conf_dir)
+ self.set_rdeps(name)
+
except Exception as e:
log.error('Error while loading the plugin %s', name, exc_info=True)
if notify:
@@ -100,9 +153,22 @@ class PluginManager:
if notify:
self.core.information('Plugin %s loaded' % name, 'Info')
- def unload(self, name, notify=True):
+ def unload(self, name: str, notify=True):
+ """
+ Unloads plugin as well as plugins depending on it.
+ """
+
if name in self.plugins:
try:
+ if self.plugins[name] is not None:
+ self.plugins[name]._unloading = True # Prevents loops
+ for rdep in self.rdeps[name].copy():
+ if rdep in self.plugins and not self.plugins[rdep]._unloading:
+ self.unload(rdep)
+ if rdep in self.plugins:
+ log.debug('Failed to unload reverse dependency %s first.', rdep)
+ return None
+
for command in self.commands[name].keys():
del self.core.commands[command]
for key in self.keys[name].keys():
@@ -122,6 +188,7 @@ class PluginManager:
if self.plugins[name] is not None:
self.plugins[name].unload()
del self.plugins[name]
+ del self.rdeps[name]
del self.commands[name]
del self.keys[name]
del self.tab_commands[name]
@@ -253,7 +320,7 @@ class PluginManager:
if key in self.core.key_func:
del self.core.commands[key]
- def add_event_handler(self, module_name, event_name, handler, position=0):
+ def add_event_handler(self, module_name, event_name, handler, *args, **kwargs):
"""
Add an event handler. If event_name isn’t in the event list, assume
it is a slixmpp event.
@@ -261,7 +328,7 @@ class PluginManager:
eh = self.event_handlers[module_name]
eh.append((event_name, handler))
if event_name in self.core.events.events:
- self.core.events.add_event_handler(event_name, handler, position)
+ self.core.events.add_event_handler(event_name, handler, *args, **kwargs)
else:
self.core.xmpp.add_event_handler(event_name, handler)
@@ -326,7 +393,7 @@ class PluginManager:
"""
Create the plugins_conf_dir
"""
- plugins_conf_dir = config.get('plugins_conf_dir')
+ plugins_conf_dir = config.getstr('plugins_conf_dir')
self.plugins_conf_dir = Path(plugins_conf_dir).expanduser(
) if plugins_conf_dir else xdg.CONFIG_HOME / 'plugins'
self.check_create_plugins_conf_dir()
@@ -351,7 +418,7 @@ class PluginManager:
"""
Set the plugins_dir on start
"""
- plugins_dir = config.get('plugins_dir')
+ plugins_dir = config.getstr('plugins_dir')
self.plugins_dir = Path(plugins_dir).expanduser(
) if plugins_dir else xdg.DATA_HOME / 'plugins'
self.check_create_plugins_dir()
@@ -387,11 +454,3 @@ class PluginManager:
if os.access(str(self.plugins_dir), os.R_OK | os.X_OK):
self.load_path.append(str(self.plugins_dir))
-
- try:
- import poezio_plugins
- except:
- pass
- else:
- if poezio_plugins.__path__:
- self.load_path.append(list(poezio_plugins.__path__)[0])
diff --git a/poezio/poezio.py b/poezio/poezio.py
index 05c8ceed..b149abd4 100644
--- a/poezio/poezio.py
+++ b/poezio/poezio.py
@@ -3,7 +3,7 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Starting point of poezio. Launches both the Connection and Gui
"""
@@ -72,56 +72,55 @@ def main():
"""
Entry point.
"""
+
+ if os.geteuid() == 0:
+ sys.stdout.write("Please do not run poezio as root.\n")
+ sys.exit(0)
+
sys.stdout.write("\x1b]0;poezio\x07")
sys.stdout.flush()
+ from poezio.args import run_cmdline_args
+ options, firstrun = run_cmdline_args()
from poezio import config
- config.run_cmdline_args()
- config.create_global_config()
- config.setup_logging()
- config.post_logging_setup()
+ config.create_global_config(options.filename)
+ config.setup_logging(options.debug)
- from poezio.config import options
+ import logging
+ logging.raiseExceptions = False
if options.check_config:
config.check_config()
sys.exit(0)
- from poezio.asyncio import monkey_patch_asyncio_slixmpp
+ from poezio.asyncio_fix import monkey_patch_asyncio_slixmpp
monkey_patch_asyncio_slixmpp()
from poezio import theming
theming.update_themes_dir()
- from poezio import logger
- logger.create_logger()
+ from poezio.logger import logger
+ logger.log_dir = config.LOG_DIR
from poezio import roster
- roster.create_roster()
+ roster.roster.reset()
from poezio.core.core import Core
signal.signal(signal.SIGINT, signal.SIG_IGN) # ignore ctrl-c
- cocore = Core()
+ cocore = Core(options.custom_version, firstrun)
signal.signal(signal.SIGUSR1, cocore.sigusr_handler) # reload the config
signal.signal(signal.SIGHUP, cocore.exit_from_signal)
signal.signal(signal.SIGTERM, cocore.exit_from_signal)
- if options.debug:
- cocore.debug = True
cocore.start()
from slixmpp.exceptions import IqError, IqTimeout
- def swallow_iqerrors(loop, context):
- """Do not log unhandled iq errors and timeouts"""
- if not isinstance(context['exception'], (IqError, IqTimeout)):
- loop.default_exception_handler(context)
-
# Warning: asyncio must always be imported after the config. Otherwise
# the asyncio logger will not follow our configuration and won't write
# the tracebacks in the correct file, etc
import asyncio
loop = asyncio.get_event_loop()
- loop.set_exception_handler(swallow_iqerrors)
+ loop.set_exception_handler(cocore.loop_exception_handler)
loop.add_reader(sys.stdin, cocore.on_input_readable)
loop.add_signal_handler(signal.SIGWINCH, cocore.sigwinch_handler)
diff --git a/poezio/poezio_shlex.pyi b/poezio/poezio_shlex.pyi
new file mode 100644
index 00000000..affbe12b
--- /dev/null
+++ b/poezio/poezio_shlex.pyi
@@ -0,0 +1,45 @@
+from typing import List, Tuple, Any, TextIO, Union, Optional, Iterable, TypeVar
+import sys
+
+def split(s: str, comments: bool = ..., posix: bool = ...) -> List[str]: ...
+if sys.version_info >= (3, 8):
+ def join(split_command: Iterable[str]) -> str: ...
+def quote(s: str) -> str: ...
+
+_SLT = TypeVar('_SLT', bound=shlex)
+
+class shlex(Iterable[str]):
+ commenters: str
+ wordchars: str
+ whitespace: str
+ escape: str
+ quotes: str
+ escapedquotes: str
+ whitespace_split: bool
+ infile: str
+ instream: TextIO
+ source: str
+ debug: int
+ lineno: int
+ token: str
+ eof: str
+ if sys.version_info >= (3, 6):
+ punctuation_chars: str
+
+ if sys.version_info >= (3, 6):
+ def __init__(self, instream: Union[str, TextIO] = ..., infile: Optional[str] = ...,
+ posix: bool = ..., punctuation_chars: Union[bool, str] = ...) -> None: ...
+ else:
+ def __init__(self, instream: Union[str, TextIO] = ..., infile: Optional[str] = ...,
+ posix: bool = ...) -> None: ...
+ def get_token(self) -> Tuple[int, int, str]: ...
+ def push_token(self, tok: str) -> None: ...
+ def read_token(self) -> str: ...
+ def sourcehook(self, filename: str) -> Tuple[str, TextIO]: ...
+ # TODO argument types
+ def push_source(self, newstream: Any, newfile: Any = ...) -> None: ...
+ def pop_source(self) -> None: ...
+ def error_leader(self, infile: str = ...,
+ lineno: int = ...) -> None: ...
+ def __iter__(self: _SLT) -> _SLT: ...
+ def __next__(self) -> str: ...
diff --git a/poezio/poopt.py b/poezio/poopt.py
deleted file mode 100644
index 57bd28c8..00000000
--- a/poezio/poopt.py
+++ /dev/null
@@ -1,185 +0,0 @@
-# Copyright 2017 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
-#
-# This file is part of Poezio.
-#
-# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
-'''This is a template module just for instruction. And poopt.'''
-
-from typing import List, Tuple
-
-# CFFI codepath.
-from cffi import FFI
-
-ffi = FFI()
-ffi.cdef("""
- typedef long wchar_t;
- int wcwidth(wchar_t c);
-""")
-libc = ffi.dlopen(None)
-
-# Cython codepath.
-#cdef extern from "wchar.h":
-# ctypedef Py_UCS4 wchar_t
-# int wcwidth(wchar_t c)
-
-
-# Just checking if the return value is -1. In some (all?) implementations,
-# wcwidth("😆") returns -1 while it should return 2. In these cases, we
-# return 1 instead because this is by far the most probable real value.
-# Since the string is received from python, and the unicode character is
-# extracted with mbrtowc(), and supposing these two compononents are not
-# bugged, and since poezio’s code should never pass '\t', '\n' or their
-# friends, a return value of -1 from wcwidth() is considered to be a bug in
-# wcwidth() (until proven otherwise). xwcwidth() is here to work around
-# this bug.
-def xwcwidth(c: str) -> int:
- character = ord(c)
- res = libc.wcwidth(character)
- if res == -1 and c != '\x19':
- return 1
- return res
-
-
-# cut_text: takes a string and returns a tuple of int.
-#
-# Each two int tuple is a line, represented by the ending position it
-# (where it should be cut). Not that this position is calculed using the
-# position of the python string characters, not just the individual bytes.
-#
-# For example,
-# poopt_cut_text("vivent les réfrigérateurs", 6);
-# will return [(0, 6), (7, 10), (11, 17), (17, 22), (22, 24)], meaning that
-# the lines are
-# "vivent", "les", "réfrig", "érateu" and "rs"
-def cut_text(string: str, width: int) -> List[Tuple[int, int]]:
- '''cut_text(text, width)
-
- Return a list of two-tuple, the first int is the starting position of the line and the second is its end.'''
-
- # The list of tuples that we return
- retlist = []
-
- # The start position (in the python-string) of the next line
- #: unsigned int
- start_pos = 0
-
- # The position of the last space seen in the current line. This is used
- # to cut on spaces instead of cutting inside words, if possible (aka if
- # there is a space)
- #: int
- last_space = -1
- # The number of columns taken by chars between start_pos and last_space
- #: size_t
- cols_until_space = 0
-
- # Number of columns taken to display the current line so far
- #: size_t
- columns = 0
-
- #: wchar_t
- #wc = 0
-
- # The position, considering unicode chars (aka, the position in the
- # python string). This is used to determine the position in the python
- # string at which we should cut */
- #: unsigned int
- #spos = -1
-
- in_special_character = False
- for spos, wc in enumerate(string):
- # Special case to skip poezio special characters that are contained
- # in the python string, but should not be counted as chars because
- # they will not be displayed. Those are the formatting chars (to
- # insert colors or things like that in the string)
- if in_special_character:
- # Skip everything until the end of this format marker, but
- # without increasing the number of columns of the current
- # line. Because these chars are not printed.
- if wc in ('u', 'a', 'i', 'b', 'o', '}'):
- in_special_character = False
- continue
- if wc == '\x19':
- in_special_character = True
- continue
-
- # This is one condition to end the line: an explicit \n is found
- if wc == '\n':
- spos += 1
- retlist.append((start_pos, spos))
-
- # And then initiate a new line
- start_pos = spos
- last_space = -1
- columns = 0
- continue
-
- # Get the number of columns needed to display this character. May be 0, 1 or 2
- cols = xwcwidth(wc)
-
- # This is the second condition to end the line: we have consumed
- # enough columns to fill a whole line
- if columns + cols > width:
- # If possible, cut on a space
- if last_space != -1:
- retlist.append((start_pos, last_space))
- start_pos = last_space + 1
- last_space = -1
- columns -= (cols_until_space + 1)
- else:
- # Otherwise, cut in the middle of a word
- retlist.append((start_pos, spos))
- start_pos = spos
- columns = 0
- # We save the position of the last space seen in this line, and the
- # number of columns we have until now. This helps us keep track of
- # the columns to count when we will use that space as a cutting
- # point, later
- if wc == ' ':
- last_space = spos
- cols_until_space = columns
- # We advanced from one char, increment spos by one and add the
- # char's columns to the line's columns
- columns += cols
- # We are at the end of the string, append the last line, not finished
- retlist.append((start_pos, spos + 1))
- return retlist
-
-
-# wcswidth: An emulation of the POSIX wcswidth(3) function using xwcwidth.
-def wcswidth(string: str) -> int:
- '''wcswidth(s)
-
- The wcswidth() function returns the number of columns needed to represent the wide-character string pointed to by s. Raise UnicodeError if an invalid unicode value is passed'''
-
- columns = 0
- for wc in string:
- columns += xwcwidth(wc)
- return columns
-
-
-# cut_by_columns: takes a python string and a number of columns, returns a
-# python string truncated to take at most that many columns
-# For example cut_by_columns(n, "エメルカ") will return:
-# - n == 5 -> "エメ" (which takes only 4 columns since we can't cut the
-# next character in half)
-# - n == 2 -> "エ"
-# - n == 1 -> ""
-# - n == 42 -> "エメルカ"
-# - etc
-def cut_by_columns(string: str, limit: int) -> str:
- '''cut_by_columns(string, limit)
-
- returns a string truncated to take at most limit columns'''
-
- spos = 0
- columns = 0
- for wc in string:
- if columns == limit:
- break
- cols = xwcwidth(wc)
- if columns + cols > limit:
- break
- spos += 1
- columns += cols
- return string[:spos]
diff --git a/poezio/poopt.pyi b/poezio/poopt.pyi
new file mode 100644
index 00000000..3762c94a
--- /dev/null
+++ b/poezio/poopt.pyi
@@ -0,0 +1,7 @@
+
+from typing import List, Tuple
+
+def xwcwidth(c: str) -> int: ...
+def cut_text(string: str, width: int) -> List[Tuple[int, int]]: ...
+def wcswidth(string: str) -> int: ...
+def cut_by_columns(string: str, limit: int) -> str: ...
diff --git a/poezio/pooptmodule.c b/poezio/pooptmodule.c
index 427ac883..8574b225 100644
--- a/poezio/pooptmodule.c
+++ b/poezio/pooptmodule.c
@@ -3,7 +3,7 @@
/* This file is part of Poezio. */
/* Poezio is free software: you can redistribute it and/or modify */
-/* it under the terms of the zlib license. See the COPYING file. */
+/* it under the terms of the GPL-3.0+ license. See the COPYING file. */
/** The poopt python3 module
**/
diff --git a/poezio/py.typed b/poezio/py.typed
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/poezio/py.typed
diff --git a/poezio/roster.py b/poezio/roster.py
index bedf477b..a52ea23e 100644
--- a/poezio/roster.py
+++ b/poezio/roster.py
@@ -3,12 +3,13 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Defines the Roster and RosterGroup classes
"""
import logging
-log = logging.getLogger(__name__)
+
+from typing import List
from poezio.config import config
from poezio.contact import Contact
@@ -16,9 +17,10 @@ from poezio.roster_sorting import SORTING_METHODS, GROUP_SORTING_METHODS
from os import path as p
from datetime import datetime
-from poezio.common import safeJID
from slixmpp.exceptions import IqError, IqTimeout
+from slixmpp import JID, InvalidJID
+log = logging.getLogger(__name__)
class Roster:
"""
@@ -29,6 +31,22 @@ class Roster:
DEFAULT_FILTER = (lambda x, y: None, None)
def __init__(self):
+ self.__node = None
+
+ # A tuple(function, *args) function to filter contacts
+ # on search, for example
+ self.contact_filter = self.DEFAULT_FILTER
+ self.groups = {}
+ self.contacts = {}
+ self.length = 0
+ self.connected = 0
+ self.folded_groups = []
+
+ # Used for caching roster infos
+ self.last_built = datetime.now()
+ self.last_modified = datetime.now()
+
+ def reset(self):
"""
node: the RosterSingle from slixmpp
"""
@@ -38,7 +56,8 @@ class Roster:
# on search, for example
self.contact_filter = self.DEFAULT_FILTER
self.folded_groups = set(
- config.get('folded_roster_groups', section='var').split(':'))
+ config.getlist('folded_roster_groups', section='var')
+ )
self.groups = {}
self.contacts = {}
self.length = 0
@@ -52,12 +71,15 @@ class Roster:
self.last_modified = datetime.now()
@property
- def needs_rebuild(self):
+ def needs_rebuild(self) -> bool:
return self.last_modified >= self.last_built
def __getitem__(self, key):
"""Get a Contact from his bare JID"""
- key = safeJID(key).bare
+ try:
+ key = JID(key).bare
+ except InvalidJID:
+ return None
if key in self.contacts and self.contacts[key] is not None:
return self.contacts[key]
if key in self.jids():
@@ -71,7 +93,10 @@ class Roster:
def remove(self, jid):
"""Send a removal iq to the server"""
- jid = safeJID(jid).bare
+ try:
+ jid = JID(jid).bare
+ except InvalidJID:
+ return
if self.__node[jid]:
try:
self.__node[jid].send_presence(ptype='unavailable')
@@ -81,7 +106,10 @@ class Roster:
def __delitem__(self, jid):
"""Remove a contact from the roster view"""
- jid = safeJID(jid).bare
+ try:
+ jid = JID(jid).bare
+ except InvalidJID:
+ return
contact = self[jid]
if not contact:
return
@@ -99,10 +127,13 @@ class Roster:
def __contains__(self, key):
"""True if the bare jid is in the roster, false otherwise"""
- return safeJID(key).bare in self.jids()
+ try:
+ return JID(key).bare in self.jids()
+ except InvalidJID:
+ return False
@property
- def jid(self):
+ def jid(self) -> JID:
"""Our JID"""
return self.__node.jid
@@ -143,7 +174,7 @@ class Roster:
"""Subscribe to a jid"""
self.__node.subscribe(jid)
- def jids(self):
+ def jids(self) -> List[JID]:
"""List of the contact JIDS"""
l = []
for key in self.__node.keys():
@@ -335,11 +366,6 @@ class RosterGroup:
return len([1 for contact in self.contacts if len(contact)])
-def create_roster():
- "Create the global roster object"
- global roster
- roster = Roster()
-
# Shared roster object
-roster = None
+roster = Roster()
diff --git a/poezio/size_manager.py b/poezio/size_manager.py
index 3e80c357..c5312c9f 100644
--- a/poezio/size_manager.py
+++ b/poezio/size_manager.py
@@ -18,21 +18,25 @@ class SizeManager:
self._core = core
@property
- def tab_degrade_x(self):
+ def tab_degrade_x(self) -> bool:
+ if base_wins.TAB_WIN is None:
+ raise ValueError
_, x = base_wins.TAB_WIN.getmaxyx()
return x < THRESHOLD_WIDTH_DEGRADE
@property
- def tab_degrade_y(self):
+ def tab_degrade_y(self) -> bool:
+ if base_wins.TAB_WIN is None:
+ raise ValueError
y, x = base_wins.TAB_WIN.getmaxyx()
return y < THRESHOLD_HEIGHT_DEGRADE
@property
- def core_degrade_x(self):
+ def core_degrade_x(self) -> bool:
y, x = self._core.stdscr.getmaxyx()
return x < FULL_WIDTH_DEGRADE
@property
- def core_degrade_y(self):
+ def core_degrade_y(self) -> bool:
y, x = self._core.stdscr.getmaxyx()
return y < FULL_HEIGHT_DEGRADE
diff --git a/poezio/tabs/adhoc_commands_list.py b/poezio/tabs/adhoc_commands_list.py
index b62166b0..3b6bc1db 100644
--- a/poezio/tabs/adhoc_commands_list.py
+++ b/poezio/tabs/adhoc_commands_list.py
@@ -16,8 +16,8 @@ log = logging.getLogger(__name__)
class AdhocCommandsListTab(ListTab):
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self, core, jid):
ListTab.__init__(
diff --git a/poezio/tabs/basetabs.py b/poezio/tabs/basetabs.py
index 578668fc..793eae62 100644
--- a/poezio/tabs/basetabs.py
+++ b/poezio/tabs/basetabs.py
@@ -13,26 +13,57 @@ This module also defines ChatTabs, the parent class for all tabs
revolving around chats.
"""
+from __future__ import annotations
+
import logging
import string
-import time
+import asyncio
+from copy import copy
+from math import ceil, log10
from datetime import datetime
-from xml.etree import cElementTree as ET
-from typing import Any, Callable, Dict, List, Optional
-
-from slixmpp import JID, Message
-
+from xml.etree import ElementTree as ET
+from xml.sax import SAXParseException
+from typing import (
+ Any,
+ Callable,
+ cast,
+ Dict,
+ List,
+ Optional,
+ Union,
+ Tuple,
+ TYPE_CHECKING,
+)
+
+from poezio import (
+ poopt,
+ timed_events,
+ xhtml,
+ windows
+)
from poezio.core.structs import Command, Completion, Status
-from poezio import timed_events
-from poezio import windows
-from poezio import xhtml
-from poezio.common import safeJID
from poezio.config import config
-from poezio.decorators import refresh_wrapper
+from poezio.decorators import command_args_parser, refresh_wrapper
from poezio.logger import logger
+from poezio.log_loader import MAMFiller, LogLoader
from poezio.text_buffer import TextBuffer
from poezio.theming import get_theme, dump_tuple
-from poezio.decorators import command_args_parser
+from poezio.user import User
+from poezio.ui.funcs import truncate_nick
+from poezio.timed_events import DelayedEvent
+from poezio.ui.types import (
+ BaseMessage,
+ Message,
+ PersistentInfoMessage,
+ LoggableTrait,
+)
+
+from slixmpp import JID, InvalidJID, Message as SMessage
+
+if TYPE_CHECKING:
+ from _curses import _CursesWindow # pylint: disable=E0611
+ from poezio.size_manager import SizeManager
+ from poezio.core.core import Core
log = logging.getLogger(__name__)
@@ -90,29 +121,42 @@ SHOW_NAME = {
class Tab:
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
# Placeholder values, set on resize
- height = 1
- width = 1
-
- def __init__(self, core):
+ height: int = 1
+ width: int = 1
+ core: Core
+ input: Optional[windows.Input]
+ key_func: Dict[str, Callable[[], Any]]
+ commands: Dict[str, Command]
+ need_resize: bool
+ ui_config_changed: bool
+
+ def __init__(self, core: Core):
self.core = core
self.nb = 0
- if not hasattr(self, 'name'):
- self.name = self.__class__.__name__
+ if not hasattr(self, '_name'):
+ self._name = self.__class__.__name__
self.input = None
self.closed = False
self._state = 'normal'
self._prev_state = None
self.need_resize = False
+ self.ui_config_changed = False
self.key_func = {} # each tab should add their keys in there
# and use them in on_input
self.commands = {} # and their own commands
@property
- def size(self) -> int:
+ def name(self) -> str:
+ if hasattr(self, '_name'):
+ return self._name
+ return ''
+
+ @property
+ def size(self) -> SizeManager:
return self.core.size
@staticmethod
@@ -121,23 +165,27 @@ class Tab:
Returns 1 or 0, depending on if we are using the vertical tab list
or not.
"""
- if config.get('enable_vertical_tab_list'):
+ if config.getbool('enable_vertical_tab_list'):
return 0
return 1
@property
- def info_win(self):
+ def info_win(self) -> windows.TextWin:
return self.core.information_win
@property
- def color(self):
+ def color(self) -> Union[Tuple[int, int], Tuple[int, int, 'str']]:
return STATE_COLORS[self._state]()
@property
- def vertical_color(self):
+ def vertical_color(self) -> Union[Tuple[int, int], Tuple[int, int, 'str']]:
return VERTICAL_STATE_COLORS[self._state]()
@property
+ def priority(self) -> Union[int, float]:
+ return STATE_PRIORITY.get(self._state, -1)
+
+ @property
def state(self) -> str:
return self._state
@@ -175,7 +223,7 @@ class Tab:
self._state = 'normal'
@staticmethod
- def resize(scr):
+ def initial_resize(scr: _CursesWindow):
Tab.height, Tab.width = scr.getmaxyx()
windows.base_wins.TAB_WIN = scr
@@ -212,7 +260,7 @@ class Tab:
*,
desc='',
shortdesc='',
- completion: Optional[Callable] = None,
+ completion: Optional[Callable[[windows.Input], Completion]] = None,
usage=''):
"""
Add a command
@@ -241,7 +289,7 @@ class Tab:
['/%s' % (name) for name in sorted(self.commands)]
the_input.new_completion(words, 0)
# Do not try to cycle command completion if there was only
- # one possibily. The next tab will complete the argument.
+ # one possibility. The next tab will complete the argument.
# Otherwise we would need to add a useless space before being
# able to complete the arguments.
hit_copy = set(the_input.hit_list)
@@ -264,7 +312,6 @@ class Tab:
comp = command.comp(the_input)
if comp:
return comp.run()
- return comp
return False
def execute_command(self, provided_text: str) -> bool:
@@ -272,8 +319,10 @@ class Tab:
Execute the command in the input and return False if
the input didn't contain a command
"""
+ if self.input is None:
+ raise NotImplementedError
txt = provided_text or self.input.key_enter()
- if txt.startswith('/') and not txt.startswith('//') and\
+ if txt and txt.startswith('/') and not txt.startswith('//') and\
not txt.startswith('/me '):
command = txt.strip().split()[0][1:]
arg = txt[2 + len(command):] # jump the '/' and the ' '
@@ -301,13 +350,16 @@ class Tab:
if func:
if hasattr(self.input, "reset_completion"):
self.input.reset_completion()
- func(arg)
+ if asyncio.iscoroutinefunction(func):
+ asyncio.create_task(func(arg))
+ else:
+ func(arg)
return True
else:
return False
- def refresh_tab_win(self):
- if config.get('enable_vertical_tab_list'):
+ def refresh_tab_win(self) -> None:
+ if config.getbool('enable_vertical_tab_list'):
left_tab_win = self.core.left_tab_win
if left_tab_win and not self.size.core_degrade_x:
left_tab_win.refresh()
@@ -338,24 +390,18 @@ class Tab:
"""
return self.name
- def get_text_window(self) -> Optional[windows.TextWin]:
- """
- Returns the principal TextWin window, if there's one
- """
- return None
-
def on_input(self, key: str, raw: bool):
"""
raw indicates if the key should activate the associated command or not.
"""
pass
- def update_commands(self):
+ def update_commands(self) -> None:
for c in self.plugin_commands:
if c not in self.commands:
self.commands[c] = self.plugin_commands[c]
- def update_keys(self):
+ def update_keys(self) -> None:
for k in self.plugin_keys:
if k not in self.key_func:
self.key_func[k] = self.plugin_keys[k]
@@ -414,7 +460,7 @@ class Tab:
"""
pass
- def on_close(self):
+ def on_close(self) -> None:
"""
Called when the tab is to be closed
"""
@@ -422,7 +468,7 @@ class Tab:
self.input.on_delete()
self.closed = True
- def matching_names(self) -> List[str]:
+ def matching_names(self) -> List[Tuple[int, str]]:
"""
Returns a list of strings that are used to name a tab with the /win
command. For example you could switch to a tab that returns
@@ -436,6 +482,9 @@ class Tab:
class GapTab(Tab):
+ def __init__(self):
+ return
+
def __bool__(self):
return False
@@ -443,7 +492,7 @@ class GapTab(Tab):
return 0
@property
- def name(self):
+ def name(self) -> str:
return ''
def refresh(self):
@@ -458,23 +507,37 @@ class ChatTab(Tab):
Also, ^M is already bound to on_enter
And also, add the /say command
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
+ last_sent_message: Optional[SMessage]
message_type = 'chat'
+ timed_event_paused: Optional[DelayedEvent]
+ timed_event_not_paused: Optional[DelayedEvent]
+ mam_filler: Optional[MAMFiller]
+ e2e_encryption: Optional[str] = None
- def __init__(self, core, jid=''):
+ def __init__(self, core, jid: Union[JID, str]):
Tab.__init__(self, core)
- self.name = jid
- self.text_win = None
+
+ if not isinstance(jid, JID):
+ jid = JID(jid)
+ assert jid.domain
+ self._jid = jid
+ #: Is the tab currently requesting MAM data?
+ self.query_status = False
+ self._name = jid.full
+ self.text_win = windows.TextWin()
self.directed_presence = None
self._text_buffer = TextBuffer()
+ self._text_buffer.add_window(self.text_win)
+ self.mam_filler = None
self.chatstate = None # can be "active", "composing", "paused", "gone", "inactive"
# We keep a reference of the event that will set our chatstate to "paused", so that
# we can delete it or change it if we need to
self.timed_event_paused = None
self.timed_event_not_paused = None
# Keeps the last sent message to complete it easily in completion_correct, and to replace it.
- self.last_sent_message = {}
+ self.last_sent_message = None
self.key_func['M-v'] = self.move_separator
self.key_func['M-h'] = self.scroll_separator
self.key_func['M-/'] = self.last_words_completion
@@ -485,6 +548,12 @@ class ChatTab(Tab):
usage='<message>',
shortdesc='Send the message.')
self.register_command(
+ 'scrollback',
+ self.command_scrollback,
+ usage="end home clear status goto <+|-linecount>|<linenum>|<timestamp>",
+ shortdesc='Scrollback to the given line number, message, or clear the buffer.')
+ self.commands['sb'] = self.commands['scrollback']
+ self.register_command(
'xhtml',
self.command_xhtml,
usage='<custom xhtml>',
@@ -497,73 +566,79 @@ class ChatTab(Tab):
desc='Fix the last message with whatever you want.',
shortdesc='Correct the last message.',
completion=self.completion_correct)
- self.chat_state = None
+ self.chat_state: Optional[str] = None
self.update_commands()
self.update_keys()
- # Get the logs
- log_nb = config.get('load_log')
- logs = self.load_logs(log_nb)
+ @property
+ def name(self) -> str:
+ if self._name is not None:
+ return self._name
+ return self._jid.full
+
+ @name.setter
+ def name(self, value: Union[JID, str]) -> None:
+ if isinstance(value, JID):
+ self.jid = value
+ elif isinstance(value, str):
+ try:
+ value = JID(value)
+ if value.domain:
+ self._jid = value
+ except InvalidJID:
+ self._name = str(value)
+ else:
+ raise TypeError("Name %r must be of type JID or str." % value)
- if logs:
- for message in logs:
- self._text_buffer.add_message(**message)
+ @property
+ def log_name(self) -> str:
+ """Name used for the log filename"""
+ return self.jid.bare
@property
- def general_jid(self) -> JID:
- return NotImplementedError
+ def jid(self) -> JID:
+ return copy(self._jid)
- def load_logs(self, log_nb: int) -> Optional[List[Dict[str, Any]]]:
- logs = logger.get_logs(safeJID(self.name).bare, log_nb)
- return logs
+ @jid.setter
+ def jid(self, value: JID) -> None:
+ if not isinstance(value, JID):
+ raise TypeError("Jid %r must be of type JID." % value)
+ assert value.domain
+ self._jid = value
+
+ @property
+ def general_jid(self) -> JID:
+ raise NotImplementedError
- def log_message(self,
- txt: str,
- nickname: str,
- time: Optional[datetime] = None,
- typ=1):
+ def log_message(self, message: BaseMessage):
"""
Log the messages in the archives.
"""
- name = safeJID(self.name).bare
- if not logger.log_message(name, nickname, txt, date=time, typ=typ):
+ if not isinstance(message, LoggableTrait):
+ return
+ if not logger.log_message(self.log_name, message):
self.core.information('Unable to write in the log file', 'Error')
- def add_message(self,
- txt,
- time=None,
- nickname=None,
- forced_user=None,
- nick_color=None,
- identifier=None,
- jid=None,
- history=None,
- typ=1,
- highlight=False):
- self.log_message(txt, nickname, time=time, typ=typ)
- self._text_buffer.add_message(
- txt,
- time=time,
- nickname=nickname,
- highlight=highlight,
- nick_color=nick_color,
- history=history,
- user=forced_user,
- identifier=identifier,
- jid=jid)
+ def add_message(self, message: BaseMessage):
+ self.log_message(message)
+ self._text_buffer.add_message(message)
def modify_message(self,
- txt,
- old_id,
- new_id,
- user=None,
- jid=None,
- nickname=None):
- self.log_message(txt, nickname, typ=1)
+ txt: str,
+ old_id: str,
+ new_id: str,
+ time: Optional[datetime],
+ delayed: bool = False,
+ nickname: Optional[str] = None,
+ user: Optional[User] = None,
+ jid: Optional[JID] = None,
+ ) -> bool:
message = self._text_buffer.modify_message(
- txt, old_id, new_id, time=time, user=user, jid=jid)
+ txt, old_id, new_id, user=user, jid=jid, time=time
+ )
if message:
- self.text_win.modify_message(old_id, message)
+ self.log_message(message)
+ self.text_win.modify_message(message.identifier, message)
self.core.refresh_window()
return True
return False
@@ -584,16 +659,20 @@ class ChatTab(Tab):
for word in txt.split():
if len(word) >= 4 and word not in words:
words.append(word)
- words.extend([word for word in config.get('words').split(':') if word])
+ words.extend([word for word in config.getlist('words') if word])
self.input.auto_completion(words, ' ', quotify=False)
def on_enter(self):
+ if self.input is None:
+ raise NotImplementedError
txt = self.input.key_enter()
if txt:
if not self.execute_command(txt):
if txt.startswith('//'):
txt = txt[1:]
- self.command_say(xhtml.convert_simple_to_full_colors(txt))
+ asyncio.ensure_future(
+ self.command_say(xhtml.convert_simple_to_full_colors(txt))
+ )
self.cancel_paused_delay()
@command_args_parser.raw
@@ -605,26 +684,26 @@ class ChatTab(Tab):
if message:
message.send()
- def generate_xhtml_message(self, arg: str) -> Message:
+ def generate_xhtml_message(self, arg: str) -> Optional[SMessage]:
if not arg:
- return
+ return None
try:
body = xhtml.clean_text(
xhtml.xhtml_to_poezio_colors(arg, force=True))
ET.fromstring(arg)
- except:
+ except SAXParseException:
self.core.information('Could not send custom xhtml', 'Error')
- log.error('/xhtml: Unable to send custom xhtml', exc_info=True)
- return
+ log.error('/xhtml: Unable to send custom xhtml')
+ return None
- msg = self.core.xmpp.make_message(self.get_dest_jid())
+ msg: SMessage = self.core.xmpp.make_message(self.get_dest_jid())
msg['body'] = body
msg.enable('html')
msg['html']['body'] = arg
return msg
def get_dest_jid(self) -> JID:
- return self.name
+ return self.jid
@refresh_wrapper.always
def command_clear(self, ignored):
@@ -634,27 +713,31 @@ class ChatTab(Tab):
self._text_buffer.messages = []
self.text_win.rebuild_everything(self._text_buffer)
- def check_send_chat_state(self):
+ def check_send_chat_state(self) -> bool:
"If we should send a chat state"
return True
- def send_chat_state(self, state, always_send=False):
+ def send_chat_state(self, state: str, always_send: bool = False) -> None:
"""
Send an empty chatstate message
"""
+ from poezio.tabs import PrivateTab
+
if self.check_send_chat_state():
if state in ('active', 'inactive',
'gone') and self.inactive and not always_send:
return
if config.get_by_tabname('send_chat_states', self.general_jid):
- msg = self.core.xmpp.make_message(self.get_dest_jid())
+ msg: SMessage = self.core.xmpp.make_message(self.get_dest_jid())
msg['type'] = self.message_type
msg['chat_state'] = state
self.chat_state = state
+ msg['no-store'] = True
+ if isinstance(self, PrivateTab):
+ msg.enable('muc')
msg.send()
- return True
- def send_composing_chat_state(self, empty_after):
+ def send_composing_chat_state(self, empty_after: bool) -> None:
"""
Send the "active" or "composing" chatstate, depending
on the the current status of the input
@@ -690,7 +773,7 @@ class ChatTab(Tab):
self.core.add_timed_event(new_event)
self.timed_event_not_paused = new_event
- def cancel_paused_delay(self):
+ def cancel_paused_delay(self) -> None:
"""
Remove that event from the list and set it to None.
Called for example when the input is emptied, or when the message
@@ -699,11 +782,22 @@ class ChatTab(Tab):
if self.timed_event_paused is not None:
self.core.remove_timed_event(self.timed_event_paused)
self.timed_event_paused = None
- self.core.remove_timed_event(self.timed_event_not_paused)
- self.timed_event_not_paused = None
+ if self.timed_event_not_paused is not None:
+ self.core.remove_timed_event(self.timed_event_not_paused)
+ self.timed_event_not_paused = None
+
+ def set_last_sent_message(self, msg: SMessage, correct: bool = False) -> None:
+ """Ensure last_sent_message is set with the correct attributes"""
+ if correct:
+ # XXX: Is the copy needed. Is the object passed here reused
+ # afterwards? Who knows.
+ msg = cast(SMessage, copy(msg))
+ if self.last_sent_message is not None:
+ msg['id'] = self.last_sent_message['id']
+ self.last_sent_message = msg
@command_args_parser.raw
- def command_correct(self, line):
+ async def command_correct(self, line: str) -> None:
"""
/correct <fixed message>
"""
@@ -713,7 +807,7 @@ class ChatTab(Tab):
if not self.last_sent_message:
self.core.information('There is no message to correct.', 'Error')
return
- self.command_say(line, correct=True)
+ await self.command_say(line, correct=True)
def completion_correct(self, the_input):
if self.last_sent_message and the_input.get_argument_position() == 1:
@@ -726,26 +820,153 @@ class ChatTab(Tab):
@property
def inactive(self) -> bool:
"""Whether we should send inactive or active as a chatstate"""
- return self.core.status.show in ('xa', 'away') or\
- (hasattr(self, 'directed_presence') and not self.directed_presence)
+ return self.core.status.show in ('xa', 'away') or (
+ hasattr(self, 'directed_presence')
+ and self.directed_presence is not None
+ and self.directed_presence
+ )
- def move_separator(self):
+ def move_separator(self) -> None:
self.text_win.remove_line_separator()
self.text_win.add_line_separator(self._text_buffer)
self.text_win.refresh()
- self.input.refresh()
+ if self.input:
+ self.input.refresh()
def get_conversation_messages(self):
return self._text_buffer.messages
- def check_scrolled(self):
+ def check_scrolled(self) -> None:
if self.text_win.pos != 0:
self.state = 'scrolled'
@command_args_parser.raw
- def command_say(self, line, correct=False):
+ async def command_say(self, line: str, attention: bool = False, correct: bool = False):
pass
+ def goto_build_lines(self, new_date):
+ text_buffer = self._text_buffer
+ built_lines = []
+ message_count = 0
+ timestamp = config.getbool('show_timestamps')
+ nick_size = config.getint('max_nick_length')
+ theme = get_theme()
+ for message in text_buffer.messages:
+ # Build lines of a message
+ txt = message.txt
+ nick = truncate_nick(message.nickname, nick_size)
+ offset = 0
+ theme = get_theme()
+ if message.ack:
+ if message.ack > 0:
+ offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1
+ else:
+ offset += poopt.wcswidth(theme.CHAR_NACK) + 1
+ if nick:
+ offset += poopt.wcswidth(nick) + 2
+ if message.revisions > 0:
+ offset += ceil(log10(message.revisions + 1))
+ if message.me:
+ offset += 1
+ if timestamp:
+ if message.history:
+ offset += 1 + theme.LONG_TIME_FORMAT_LENGTH
+ lines = poopt.cut_text(txt, self.text_win.width - offset - 1)
+ for line in lines:
+ built_lines.append(line)
+ # Find the message with timestamp less than or equal to the queried
+ # timestamp and goto that location in the tab.
+ if message.time <= new_date:
+ message_count += 1
+ if len(self.text_win.built_lines) - self.text_win.height >= len(built_lines):
+ self.text_win.pos = len(self.text_win.built_lines) - self.text_win.height - len(built_lines) + 1
+ else:
+ self.text_win.pos = 0
+ if message_count == 0:
+ self.text_win.scroll_up(len(self.text_win.built_lines))
+ self.core.refresh_window()
+
+ @command_args_parser.quoted(0, 2)
+ def command_scrollback(self, args):
+ """
+ /sb clear
+ /sb home
+ /sb end
+ /sb goto <+|-linecount>|<linenum>|<timestamp>
+ The format of timestamp must be ‘[dd[.mm]-<days ago>] hh:mi[:ss]’
+ """
+ if args is None or len(args) == 0:
+ args = ['end']
+ if len(args) == 1:
+ if args[0] == 'end':
+ self.text_win.scroll_down(len(self.text_win.built_lines))
+ self.core.refresh_window()
+ return
+ elif args[0] == 'home':
+ self.text_win.scroll_up(len(self.text_win.built_lines))
+ self.core.refresh_window()
+ return
+ elif args[0] == 'clear':
+ self._text_buffer.messages = []
+ self.text_win.rebuild_everything(self._text_buffer)
+ self.core.refresh_window()
+ return
+ elif args[0] == 'status':
+ self.core.information('Total %s lines in this tab.' % len(self.text_win.built_lines), 'Info')
+ return
+ elif len(args) == 2 and args[0] == 'goto':
+ for fmt in ('%d %H:%M', '%d %H:%M:%S', '%d:%m %H:%M', '%d:%m %H:%M:%S', '%H:%M', '%H:%M:%S'):
+ try:
+ new_date = datetime.strptime(args[1], fmt)
+ if 'd' in fmt and 'm' in fmt:
+ new_date = new_date.replace(year=datetime.now().year)
+ elif 'd' in fmt:
+ new_date = new_date.replace(year=datetime.now().year, month=datetime.now().month)
+ else:
+ new_date = new_date.replace(year=datetime.now().year, month=datetime.now().month, day=datetime.now().day)
+ except ValueError:
+ pass
+ if args[1].startswith('-'):
+ # Check if the user is giving argument of type goto <-linecount> or goto [-<days ago>] hh:mi[:ss]
+ if ' ' in args[1]:
+ new_args = args[1].split(' ')
+ new_args[0] = new_args[0].strip('-')
+ new_date = datetime.now()
+ if new_args[0].isdigit():
+ new_date = new_date.replace(day=new_date.day - int(new_args[0]))
+ for fmt in ('%H:%M', '%H:%M:%S'):
+ try:
+ arg_date = datetime.strptime(new_args[1], fmt)
+ new_date = new_date.replace(hour=arg_date.hour, minute=arg_date.minute, second=arg_date.second)
+ except ValueError:
+ pass
+ else:
+ scroll_len = args[1].strip('-')
+ if scroll_len.isdigit():
+ self.text_win.scroll_down(int(scroll_len))
+ self.core.refresh_window()
+ return
+ elif args[1].startswith('+'):
+ scroll_len = args[1].strip('+')
+ if scroll_len.isdigit():
+ self.text_win.scroll_up(int(scroll_len))
+ self.core.refresh_window()
+ return
+ # Check for the argument of type goto <linenum>
+ elif args[1].isdigit():
+ if len(self.text_win.built_lines) - self.text_win.height >= int(args[1]):
+ self.text_win.pos = len(self.text_win.built_lines) - self.text_win.height - int(args[1])
+ self.core.refresh_window()
+ return
+ else:
+ self.text_win.pos = 0
+ self.core.refresh_window()
+ return
+ elif args[1] == '0':
+ args = ['home']
+ # new_date is the timestamp for which the user has queried.
+ self.goto_build_lines(new_date)
+
def on_line_up(self):
return self.text_win.scroll_up(1)
@@ -753,6 +974,11 @@ class ChatTab(Tab):
return self.text_win.scroll_down(1)
def on_scroll_up(self):
+ if not self.query_status:
+ from poezio.log_loader import LogLoader
+ asyncio.create_task(
+ LogLoader(logger, self, config.getbool('use_log')).scroll_requested()
+ )
return self.text_win.scroll_up(self.text_win.height - 1)
def on_scroll_down(self):
@@ -770,15 +996,15 @@ class ChatTab(Tab):
class OneToOneTab(ChatTab):
- def __init__(self, core, jid=''):
+ def __init__(self, core, jid, initial=None):
ChatTab.__init__(self, core, jid)
self.__status = Status("", "")
self.last_remote_message = datetime.now()
+ self._initial_log = asyncio.Event()
# Set to true once the first disco is done
self.__initial_disco = False
- self.check_features()
self.register_command(
'unquery', self.command_unquery, shortdesc='Close the tab.')
self.register_command(
@@ -790,6 +1016,30 @@ class OneToOneTab(ChatTab):
shortdesc='Request the attention.',
desc='Attention: Request the attention of the contact. Can also '
'send a message along with the attention.')
+ asyncio.create_task(self.init_logs(initial=initial))
+
+ async def init_logs(self, initial: Optional[SMessage] = None) -> None:
+ use_log = config.get_by_tabname('use_log', self.jid)
+ mam_sync = config.get_by_tabname('mam_sync', self.jid)
+ if use_log and mam_sync:
+ limit = config.get_by_tabname('mam_sync_limit', self.jid)
+ mam_filler = MAMFiller(logger, self, limit)
+ self.mam_filler = mam_filler
+
+ if initial is not None:
+ # If there is an initial message, throw it back into the
+ # text buffer if it cannot be fetched from mam
+ await mam_filler.done.wait()
+ if mam_filler.result == 0:
+ await self.handle_message(initial)
+ elif use_log and initial:
+ await self.handle_message(initial, display=False)
+ elif initial:
+ await self.handle_message(initial)
+ await LogLoader(logger, self, use_log, self._initial_log).tab_open()
+
+ async def handle_message(self, msg: SMessage, display: bool = True):
+ pass
def remote_user_color(self):
return dump_tuple(get_theme().COLOR_REMOTE_USER)
@@ -801,7 +1051,7 @@ class OneToOneTab(ChatTab):
return
self.__status = status
hide_status_change = config.get_by_tabname('hide_status_change',
- safeJID(self.name).bare)
+ self.jid.bare)
now = datetime.now()
dff = now - self.last_remote_message
if hide_status_change > -1 and dff.total_seconds() > hide_status_change:
@@ -816,9 +1066,11 @@ class OneToOneTab(ChatTab):
msg += 'status: %s, ' % status.message
if status.show in SHOW_NAME:
msg += 'show: %s, ' % SHOW_NAME[status.show]
- self.add_message(msg[:-2], typ=2)
+ self.add_message(
+ PersistentInfoMessage(txt=msg[:-2])
+ )
- def ack_message(self, msg_id, msg_jid):
+ def ack_message(self, msg_id: str, msg_jid: JID):
"""
Ack a message
"""
@@ -827,9 +1079,9 @@ class OneToOneTab(ChatTab):
self.text_win.modify_message(msg_id, new_msg)
self.core.refresh_window()
- def nack_message(self, error, msg_id, msg_jid):
+ def nack_message(self, error: str, msg_id: str, msg_jid: JID):
"""
- Ack a message
+ Non-ack a message (e.g. timeout)
"""
new_msg = self._text_buffer.nack_message(error, msg_id, msg_jid)
if new_msg:
@@ -848,26 +1100,21 @@ class OneToOneTab(ChatTab):
message.send()
body = xhtml.xhtml_to_poezio_colors(xhtml_data, force=True)
self._text_buffer.add_message(
- body,
- nickname=self.core.own_nick,
- nick_color=get_theme().COLOR_OWN_NICK,
- identifier=message['id'],
- jid=self.core.xmpp.boundjid)
+ Message(
+ body,
+ nickname=self.core.own_nick,
+ nick_color=get_theme().COLOR_OWN_NICK,
+ identifier=message['id'],
+ jid=self.core.xmpp.boundjid,
+ )
+ )
self.refresh()
- def check_features(self):
- "check the features supported by the other party"
- if safeJID(self.get_dest_jid()).resource:
- self.core.xmpp.plugin['xep_0030'].get_info(
- jid=self.get_dest_jid(),
- timeout=5,
- callback=self.features_checked)
-
@command_args_parser.raw
- def command_attention(self, message):
+ async def command_attention(self, message):
"""/attention [message]"""
- if message is not '':
- self.command_say(message, attention=True)
+ if message != '':
+ await self.command_say(message, attention=True)
else:
msg = self.core.xmpp.make_message(self.get_dest_jid())
msg['type'] = 'chat'
@@ -875,7 +1122,7 @@ class OneToOneTab(ChatTab):
msg.send()
@command_args_parser.raw
- def command_say(self, line, correct=False, attention=False):
+ async def command_say(self, line: str, attention: bool = False, correct: bool = False):
pass
@command_args_parser.ignored
@@ -899,7 +1146,3 @@ class OneToOneTab(ChatTab):
msg = msg % (self.name, feature, command_name)
self.core.information(msg, 'Info')
return True
-
- def features_checked(self, iq):
- "Features check callback"
- features = iq['disco_info'].get_features() or []
diff --git a/poezio/tabs/bookmarkstab.py b/poezio/tabs/bookmarkstab.py
index 816402a7..d21b5630 100644
--- a/poezio/tabs/bookmarkstab.py
+++ b/poezio/tabs/bookmarkstab.py
@@ -2,14 +2,18 @@
Defines the data-forms Tab
"""
+import asyncio
import logging
from typing import Dict, Callable, List
+from slixmpp.exceptions import IqError, IqTimeout
+
from poezio import windows
from poezio.bookmarks import Bookmark, BookmarkList
from poezio.core.structs import Command
from poezio.tabs import Tab
-from poezio.common import safeJID
+
+from slixmpp import JID, InvalidJID
log = logging.getLogger(__name__)
@@ -19,20 +23,19 @@ class BookmarksTab(Tab):
A tab displaying lines of bookmarks, each bookmark having
a 4 widgets to set the jid/password/autojoin/storage method
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self, core, bookmarks: BookmarkList):
Tab.__init__(self, core)
- self.name = "Bookmarks"
+ self._name = "Bookmarks"
self.bookmarks = bookmarks
- self.new_bookmarks = [] # type: List[Bookmark]
- self.removed_bookmarks = [] # type: List[Bookmark]
+ self.new_bookmarks: List[Bookmark] = []
+ self.removed_bookmarks: List[Bookmark] = []
self.header_win = windows.ColumnHeaderWin(
- ('name', 'room@server/nickname', 'password', 'autojoin',
- 'storage'))
- self.bookmarks_win = windows.BookmarksWin(
- self.bookmarks, self.height - 4, self.width, 1, 0)
+ ['name', 'room@server/nickname', 'password', 'autojoin',
+ 'storage'])
+ self.bookmarks_win = windows.BookmarksWin(self.bookmarks)
self.help_win = windows.HelpText('Ctrl+Y: save, Ctrl+G: cancel, '
'↑↓: change lines, tab: change '
'column, M-a: add bookmark, C-k'
@@ -50,7 +53,7 @@ class BookmarksTab(Tab):
def add_bookmark(self):
new_bookmark = Bookmark(
- safeJID('room@example.tld/nick'), method='local')
+ JID('room@example.tld/nick'), method='local')
self.new_bookmarks.append(new_bookmark)
self.bookmarks_win.add_bookmark(new_bookmark)
@@ -78,26 +81,31 @@ class BookmarksTab(Tab):
'Duplicate bookmarks in list (saving aborted)', 'Error')
return
for bm in self.new_bookmarks:
- if safeJID(bm.jid):
+ try:
+ JID(bm.jid)
if not self.bookmarks[bm.jid]:
self.bookmarks.append(bm)
- else:
+ except InvalidJID:
self.core.information(
'Invalid JID for bookmark: %s/%s' % (bm.jid, bm.nick),
'Error')
return
+
for bm in self.removed_bookmarks:
if bm in self.bookmarks:
self.bookmarks.remove(bm)
- def send_cb(success):
- if success:
- self.core.information('Bookmarks saved.', 'Info')
- else:
- self.core.information('Remote bookmarks not saved.', 'Error')
+ asyncio.create_task(
+ self.save_routine()
+ )
- self.bookmarks.save(self.core.xmpp, callback=send_cb)
+ async def save_routine(self):
+ try:
+ await self.bookmarks.save(self.core.xmpp)
+ self.core.information('Bookmarks saved', 'Info')
+ except (IqError, IqTimeout):
+ self.core.information('Remote bookmarks not saved.', 'Error')
self.core.close_tab(self)
return True
@@ -108,7 +116,7 @@ class BookmarksTab(Tab):
return res
self.bookmarks_win.refresh_current_input()
else:
- self.bookmarks_win.on_input(key)
+ self.bookmarks_win.on_input(key, raw=raw)
def resize(self):
self.need_resize = False
diff --git a/poezio/tabs/confirmtab.py b/poezio/tabs/confirmtab.py
index c13de4a6..d7488de7 100644
--- a/poezio/tabs/confirmtab.py
+++ b/poezio/tabs/confirmtab.py
@@ -13,8 +13,8 @@ log = logging.getLogger(__name__)
class ConfirmTab(Tab):
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self,
core,
@@ -34,7 +34,7 @@ class ConfirmTab(Tab):
"""
Tab.__init__(self, core)
self.state = 'highlight'
- self.name = name
+ self._name = name
self.default_help_message = windows.HelpText(
"Choose with arrow keys and press enter")
self.input = self.default_help_message
diff --git a/poezio/tabs/conversationtab.py b/poezio/tabs/conversationtab.py
index 94f1d719..de1f988a 100644
--- a/poezio/tabs/conversationtab.py
+++ b/poezio/tabs/conversationtab.py
@@ -11,45 +11,46 @@ There are two different instances of a ConversationTab:
the time.
"""
+import asyncio
import curses
import logging
+from datetime import datetime
from typing import Dict, Callable
+from slixmpp import JID, InvalidJID, Message as SMessage
+
from poezio.tabs.basetabs import OneToOneTab, Tab
from poezio import common
from poezio import windows
from poezio import xhtml
-from poezio.common import safeJID
-from poezio.config import config
+from poezio.config import config, get_image_cache
from poezio.core.structs import Command
from poezio.decorators import refresh_wrapper
from poezio.roster import roster
-from poezio.text_buffer import CorrectionError
from poezio.theming import get_theme, dump_tuple
from poezio.decorators import command_args_parser
+from poezio.ui.types import InfoMessage, Message
+from poezio.text_buffer import CorrectionError
log = logging.getLogger(__name__)
class ConversationTab(OneToOneTab):
"""
- The tab containg a normal conversation (not from a MUC)
+ The tab containing a normal conversation (not from a MUC)
Must not be instantiated, use Static or Dynamic version only.
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
- additional_information = {} # type: Dict[str, Callable[[str], str]]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
+ additional_information: Dict[str, Callable[[str], str]] = {}
message_type = 'chat'
- def __init__(self, core, jid):
- OneToOneTab.__init__(self, core, jid)
+ def __init__(self, core, jid, initial=None):
+ OneToOneTab.__init__(self, core, jid, initial=initial)
self.nick = None
self.nick_sent = False
self.state = 'normal'
- self.name = jid # a conversation tab is linked to one specific full jid OR bare jid
- self.text_win = windows.TextWin()
- self._text_buffer.add_window(self.text_win)
self.upper_bar = windows.ConversationStatusMessageWin()
self.input = windows.MessageInput()
# keys
@@ -73,13 +74,6 @@ class ConversationTab(OneToOneTab):
shortdesc='Get the activity.',
completion=self.core.completion.last_activity)
self.register_command(
- 'add',
- self.command_add,
- desc='Add the current JID to your roster, ask them to'
- ' allow you to see his presence, and allow them to'
- ' see your presence.',
- shortdesc='Add a user to your roster.')
- self.register_command(
'invite',
self.core.command.impromptu,
desc='Invite people into an impromptu room.',
@@ -89,13 +83,14 @@ class ConversationTab(OneToOneTab):
self.update_keys()
@property
- def general_jid(self):
- return safeJID(self.name).bare
+ def general_jid(self) -> JID:
+ return JID(self.jid.bare)
def get_info_header(self):
raise NotImplementedError
@staticmethod
+ @refresh_wrapper.always
def add_information_element(plugin_name, callback):
"""
Lets a plugin add its own information to the ConversationInfoWin
@@ -103,15 +98,95 @@ class ConversationTab(OneToOneTab):
ConversationTab.additional_information[plugin_name] = callback
@staticmethod
+ @refresh_wrapper.always
def remove_information_element(plugin_name):
del ConversationTab.additional_information[plugin_name]
def completion(self):
self.complete_commands(self.input)
+ async def handle_message(self, message: SMessage, display: bool = True):
+ """Handle a received message.
+
+ The message can come from us (carbon copy).
+ """
+
+ # Prevent messages coming from our own devices (1:1) to be reflected
+ if message['to'].bare == self.core.xmpp.boundjid.bare and \
+ message['from'].bare == self.core.xmpp.boundjid.bare:
+ _, index = self._text_buffer._find_message(message['id'])
+ if index != -1:
+ return
+
+ use_xhtml = config.get_by_tabname(
+ 'enable_xhtml_im',
+ message['from'].bare
+ )
+ tmp_dir = get_image_cache()
+
+ # normal message, we are the recipient
+ if message['to'].bare == self.core.xmpp.boundjid.bare:
+ conv_jid = message['from']
+ jid = conv_jid
+ color = get_theme().COLOR_REMOTE_USER
+ self.last_remote_message = datetime.now()
+ remote_nick = self.get_nick()
+ # we wrote the message (happens with carbons)
+ elif message['from'].bare == self.core.xmpp.boundjid.bare:
+ conv_jid = message['to']
+ jid = self.core.xmpp.boundjid
+ color = get_theme().COLOR_OWN_NICK
+ remote_nick = self.core.own_nick
+ # we are not part of that message, drop it
+ else:
+ return
+
+ await self.core.events.trigger_async('conversation_msg', message, self)
+
+ if not message['body']:
+ return
+ body = xhtml.get_body_from_message_stanza(
+ message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
+ delayed, date = common.find_delayed_tag(message)
+
+ replaced = False
+ if message.get_plugin('replace', check=True):
+ replaced_id = message['replace']['id']
+ if replaced_id and config.get_by_tabname('group_corrections',
+ conv_jid.bare):
+ try:
+ replaced = self.modify_message(
+ body,
+ replaced_id,
+ message['id'],
+ time=date,
+ jid=jid,
+ nickname=remote_nick)
+ except CorrectionError:
+ log.debug('Unable to correct the message: %s', message)
+ if not replaced:
+ msg = Message(
+ txt=body,
+ time=date,
+ nickname=remote_nick,
+ nick_color=color,
+ history=delayed,
+ identifier=message['id'],
+ jid=jid,
+ )
+ if display:
+ self.add_message(msg)
+ else:
+ self.log_message(msg)
+
+ @refresh_wrapper.always
@command_args_parser.raw
- def command_say(self, line, attention=False, correct=False):
- msg = self.core.xmpp.make_message(self.get_dest_jid())
+ async def command_say(self, line: str, attention: bool = False, correct: bool = False):
+ await self._initial_log.wait()
+ msg: SMessage = self.core.xmpp.make_message(
+ mto=self.get_dest_jid(),
+ mfrom=self.core.xmpp.boundjid
+ )
msg['type'] = 'chat'
msg['body'] = line
if not self.nick_sent:
@@ -123,24 +198,9 @@ class ConversationTab(OneToOneTab):
# be converted in xhtml.
self.core.events.trigger('conversation_say', msg, self)
if not msg['body']:
- self.cancel_paused_delay()
- self.text_win.refresh()
- self.input.refresh()
return
- replaced = False
if correct or msg['replace']['id']:
- msg['replace']['id'] = self.last_sent_message['id']
- if config.get_by_tabname('group_corrections', self.name):
- try:
- self.modify_message(
- msg['body'],
- self.last_sent_message['id'],
- msg['id'],
- jid=self.core.xmpp.boundjid,
- nickname=self.core.own_nick)
- replaced = True
- except CorrectionError:
- log.error('Unable to correct a message', exc_info=True)
+ msg['replace']['id'] = self.last_sent_message['id'] # type: ignore
else:
del msg['replace']
if msg['body'].find('\x19') != -1:
@@ -148,31 +208,21 @@ class ConversationTab(OneToOneTab):
msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body'])
msg['body'] = xhtml.clean_text(msg['body'])
if config.get_by_tabname('send_chat_states', self.general_jid):
- needed = 'inactive' if self.inactive else 'active'
- msg['chat_state'] = needed
+ if self.inactive:
+ self.send_chat_state('inactive', always_send=True)
+ else:
+ msg['chat_state'] = 'active'
if attention:
msg['attention'] = True
self.core.events.trigger('conversation_say_after', msg, self)
if not msg['body']:
- self.cancel_paused_delay()
- self.text_win.refresh()
- self.input.refresh()
return
- if not replaced:
- self.add_message(
- msg['body'],
- nickname=self.core.own_nick,
- nick_color=get_theme().COLOR_OWN_NICK,
- identifier=msg['id'],
- jid=self.core.xmpp.boundjid,
- typ=1)
-
- self.last_sent_message = msg
- msg._add_receipt = True
+ self.set_last_sent_message(msg, correct=correct)
+ msg._add_receipt = True # type: ignore
msg.send()
+ await self.core.handler.on_normal_message(msg)
+ # Our receipts slixmpp hack
self.cancel_paused_delay()
- self.text_win.refresh()
- self.input.refresh()
@command_args_parser.quoted(0, 1)
def command_last_activity(self, args):
@@ -196,7 +246,13 @@ class ConversationTab(OneToOneTab):
status = iq['last_activity']['status']
from_ = iq['from']
msg = '\x19%s}The last activity of %s was %s ago%s'
- if not safeJID(from_).user:
+ user = ''
+ try:
+ user = JID(from_).user
+ except InvalidJID:
+ pass
+
+ if not user:
msg = '\x19%s}The uptime of %s is %s.' % (
dump_tuple(get_theme().COLOR_INFORMATION_TEXT), from_,
common.parse_secs_to_str(seconds))
@@ -205,10 +261,10 @@ class ConversationTab(OneToOneTab):
dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
from_,
common.parse_secs_to_str(seconds),
- (' and his/her last status was %s' % status)
+ (' and their last status was %s' % status)
if status else '',
)
- self.add_message(msg)
+ self.add_message(InfoMessage(msg))
self.core.refresh_window()
self.core.xmpp.plugin['xep_0012'].get_last_activity(
@@ -218,7 +274,10 @@ class ConversationTab(OneToOneTab):
@command_args_parser.ignored
def command_info(self):
contact = roster[self.get_dest_jid()]
- jid = safeJID(self.get_dest_jid())
+ try:
+ jid = JID(self.get_dest_jid())
+ except InvalidJID:
+ jid = JID('')
if contact:
if jid.resource:
resource = contact[jid.full]
@@ -227,48 +286,29 @@ class ConversationTab(OneToOneTab):
else:
resource = None
if resource:
- status = (
- 'Status: %s' % resource.status) if resource.status else ''
- self._text_buffer.add_message(
- "\x19%(info_col)s}Show: %(show)s, %(status)s\x19o" % {
- 'show': resource.presence or 'available',
- 'status': status,
- 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- })
- return True
- else:
- self._text_buffer.add_message(
- "\x19%(info_col)s}No information available\x19o" %
- {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)})
+ status = (f', Status: {resource.status}') if resource.status else ''
+ show = f"Show: {resource.presence or 'available'}"
+ self.add_message(InfoMessage(f'{show}{status}'))
return True
+ self.add_message(
+ InfoMessage("No information available"),
+ )
+ return True
@command_args_parser.quoted(0, 1)
- def command_version(self, args):
+ async def command_version(self, args):
"""
/version [jid]
"""
if args:
- return self.core.command.version(args[0])
- jid = safeJID(self.name)
+ return await self.core.command.version(args[0])
+ jid = self.jid
if not jid.resource:
if jid in roster:
resource = roster[jid].get_highest_priority_resource()
jid = resource.jid if resource else jid
- self.core.xmpp.plugin['xep_0092'].get_version(
- jid, callback=self.core.handler.on_version_result)
-
- @command_args_parser.ignored
- def command_add(self):
- """
- Add the current JID to the roster, and automatically
- accept the reverse subscription
- """
- jid = self.general_jid
- if jid in roster and roster[jid].subscription in ('to', 'both'):
- return self.core.information('Already subscribed.', 'Roster')
- roster.add(jid)
- roster.modified()
- self.core.information('%s was added to the roster' % jid, 'Roster')
+ iq = await self.core.xmpp.plugin['xep_0092'].get_version(jid)
+ self.core.handler.on_version_result(iq)
def resize(self):
self.need_resize = False
@@ -285,8 +325,10 @@ class ConversationTab(OneToOneTab):
self.text_win.resize(
self.height - 2 - bar_height - info_win_height - tab_win_height,
- self.width, bar_height, 0)
- self.text_win.rebuild_everything(self._text_buffer)
+ self.width, bar_height, 0, self._text_buffer,
+ force=self.ui_config_changed
+ )
+ self.ui_config_changed = False
if display_bar:
self.upper_bar.resize(1, self.width, 0, 0)
self.get_info_header().resize(
@@ -321,14 +363,13 @@ class ConversationTab(OneToOneTab):
self.input.refresh()
def get_nick(self):
- jid = safeJID(self.name)
- contact = roster[jid.bare]
+ contact = roster[self.jid.bare]
if contact:
- return contact.name or jid.user
+ return contact.name or self.jid.user
else:
if self.nick:
return self.nick
- return jid.user
+ return self.jid.user or self.jid.domain
def on_input(self, key, raw):
if not raw and key in self.key_func:
@@ -343,7 +384,10 @@ class ConversationTab(OneToOneTab):
def on_lose_focus(self):
contact = roster[self.get_dest_jid()]
- jid = safeJID(self.get_dest_jid())
+ try:
+ jid = JID(self.get_dest_jid())
+ except InvalidJID:
+ jid = JID('')
if contact:
if jid.resource:
resource = contact[jid.full]
@@ -364,7 +408,10 @@ class ConversationTab(OneToOneTab):
def on_gain_focus(self):
contact = roster[self.get_dest_jid()]
- jid = safeJID(self.get_dest_jid())
+ try:
+ jid = JID(self.get_dest_jid())
+ except InvalidJID:
+ jid = JID('')
if contact:
if jid.resource:
resource = contact[jid.full]
@@ -391,9 +438,6 @@ class ConversationTab(OneToOneTab):
1, self.width, self.height - 2 - self.core.information_win_size -
Tab.tab_win_height(), 0)
- def get_text_window(self):
- return self.text_win
-
def on_close(self):
Tab.on_close(self)
if config.get_by_tabname('send_chat_states', self.general_jid):
@@ -401,7 +445,7 @@ class ConversationTab(OneToOneTab):
def matching_names(self):
res = []
- jid = safeJID(self.name)
+ jid = self.jid
res.append((2, jid.bare))
res.append((1, jid.user))
contact = roster[self.name]
@@ -417,13 +461,13 @@ class DynamicConversationTab(ConversationTab):
bad idea so it has been removed.
Only one DynamicConversationTab can be opened for a given jid.
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
- def __init__(self, core, jid, resource=None):
+ def __init__(self, core, jid, initial=None):
self.locked_resource = None
- self.name = safeJID(jid).bare
- ConversationTab.__init__(self, core, jid)
+ ConversationTab.__init__(self, core, jid, initial=initial)
+ self.jid.resource = None
self.info_header = windows.DynamicConversationInfoWin()
self.register_command(
'unlock', self.unlock_command, shortdesc='Deprecated, do nothing.')
@@ -447,7 +491,7 @@ class DynamicConversationTab(ConversationTab):
"""
Returns the bare jid.
"""
- return self.name
+ return self.jid.bare
def refresh(self):
"""
@@ -460,9 +504,9 @@ class DynamicConversationTab(ConversationTab):
self.text_win.refresh()
if display_bar:
- self.upper_bar.refresh(self.name, roster[self.name])
- displayed_jid = self.name
- self.get_info_header().refresh(displayed_jid, roster[self.name],
+ self.upper_bar.refresh(self.jid.bare, roster[self.jid.bare])
+ displayed_jid = self.jid.bare
+ self.get_info_header().refresh(displayed_jid, roster[self.jid.bare],
self.text_win, self.chatstate,
ConversationTab.additional_information)
if display_info_win:
@@ -475,8 +519,8 @@ class DynamicConversationTab(ConversationTab):
"""
Different from the parent class only for the info_header object.
"""
- displayed_jid = self.name
- self.get_info_header().refresh(displayed_jid, roster[self.name],
+ displayed_jid = self.jid.bare
+ self.get_info_header().refresh(displayed_jid, roster[self.jid.bare],
self.text_win, self.chatstate,
ConversationTab.additional_information)
self.input.refresh()
@@ -487,16 +531,20 @@ class StaticConversationTab(ConversationTab):
A conversation tab associated with one Full JID. It cannot be locked to
an different resource or unlocked.
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
- def __init__(self, core, jid):
- assert (safeJID(jid).resource)
- ConversationTab.__init__(self, core, jid)
+ def __init__(self, core, jid, initial=None):
+ ConversationTab.__init__(self, core, jid, initial=initial)
+ assert jid.resource
self.info_header = windows.ConversationInfoWin()
self.resize()
self.update_commands()
self.update_keys()
+ async def init_logs(self, initial=None) -> None:
+ # Disable local logs because…
+ pass
+
def get_info_header(self):
return self.info_header
diff --git a/poezio/tabs/data_forms.py b/poezio/tabs/data_forms.py
index 496863bc..8e13a84c 100644
--- a/poezio/tabs/data_forms.py
+++ b/poezio/tabs/data_forms.py
@@ -14,11 +14,11 @@ log = logging.getLogger(__name__)
class DataFormsTab(Tab):
"""
- A tab contaning various window type, displaying
+ A tab containing various window type, displaying
a form that the user needs to fill.
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self, core, form, on_cancel, on_send, kwargs):
Tab.__init__(self, core)
diff --git a/poezio/tabs/listtab.py b/poezio/tabs/listtab.py
index 07b3fe05..049f7076 100644
--- a/poezio/tabs/listtab.py
+++ b/poezio/tabs/listtab.py
@@ -1,5 +1,5 @@
"""
-A generic tab that displays a serie of items in a scrollable, searchable,
+A generic tab that displays a series of items in a scrollable, searchable,
sortable list. It should be inherited, to actually provide methods that
insert items in the list, and that lets the user interact with them.
"""
@@ -18,8 +18,8 @@ log = logging.getLogger(__name__)
class ListTab(Tab):
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self, core, name, help_message, header_text, cols):
"""Parameters:
@@ -34,7 +34,7 @@ class ListTab(Tab):
Tab.__init__(self, core)
self.state = 'normal'
self._error_message = ''
- self.name = name
+ self._name = name
columns = collections.OrderedDict()
for col, num in cols:
columns[col] = num
diff --git a/poezio/tabs/muclisttab.py b/poezio/tabs/muclisttab.py
index aac25787..53fce727 100644
--- a/poezio/tabs/muclisttab.py
+++ b/poezio/tabs/muclisttab.py
@@ -4,6 +4,7 @@ A MucListTab is a tab listing the rooms on a conference server.
It has no functionality except scrolling the list, and allowing the
user to join the rooms.
"""
+import asyncio
import logging
from typing import Dict, Callable
@@ -20,8 +21,8 @@ class MucListTab(ListTab):
A tab listing rooms from a specific server, displaying various information,
scrollable, and letting the user join them, etc
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self, core, server):
ListTab.__init__(self, core, server.full, "“j”: join room.",
@@ -60,6 +61,7 @@ class MucListTab(ListTab):
items = [(item[0].split('@')[0], item[0], item[2] or '', '')
for item in get_items()]
+ items = sorted(items, key=lambda item: item[0])
self.listview.set_lines(items)
self.info_header.message = 'Chatroom list on server %s' % self.name
if self.core.tabs.current_tab is self:
@@ -73,4 +75,4 @@ class MucListTab(ListTab):
row = self.listview.get_selected_row()
if not row:
return
- self.core.command.join(row[1])
+ asyncio.ensure_future(self.core.command.join(row[1]))
diff --git a/poezio/tabs/muctab.py b/poezio/tabs/muctab.py
index d533f817..e2d546c9 100644
--- a/poezio/tabs/muctab.py
+++ b/poezio/tabs/muctab.py
@@ -7,6 +7,9 @@ It keeps track of many things such as part/joins, maintains an
user list, and updates private tabs when necessary.
"""
+from __future__ import annotations
+
+import asyncio
import bisect
import curses
import logging
@@ -14,77 +17,114 @@ import os
import random
import re
import functools
+from copy import copy
+from dataclasses import dataclass
from datetime import datetime
-from typing import Dict, Callable, List, Optional, Union, Set
-
-from slixmpp import JID
+from typing import (
+ cast,
+ Any,
+ Dict,
+ Callable,
+ List,
+ Optional,
+ Tuple,
+ Union,
+ Set,
+ Type,
+ Pattern,
+ TYPE_CHECKING,
+)
+
+from slixmpp import InvalidJID, JID, Presence, Iq, Message as SMessage
+from slixmpp.exceptions import IqError, IqTimeout
from poezio.tabs import ChatTab, Tab, SHOW_NAME
from poezio import common
-from poezio import fixes
from poezio import multiuserchat as muc
from poezio import timed_events
from poezio import windows
from poezio import xhtml
-from poezio.common import safeJID
-from poezio.config import config
+from poezio.common import to_utc
+from poezio.config import config, get_image_cache
from poezio.core.structs import Command
from poezio.decorators import refresh_wrapper, command_args_parser
from poezio.logger import logger
+from poezio.log_loader import LogLoader, MAMFiller
from poezio.roster import roster
+from poezio.text_buffer import CorrectionError
from poezio.theming import get_theme, dump_tuple
from poezio.user import User
from poezio.core.structs import Completion, Status
+from poezio.ui.types import (
+ BaseMessage,
+ InfoMessage,
+ Message,
+ MucOwnJoinMessage,
+ MucOwnLeaveMessage,
+ PersistentInfoMessage,
+)
+
+if TYPE_CHECKING:
+ from poezio.core.core import Core
+ from slixmpp.plugins.xep_0004 import Form
log = logging.getLogger(__name__)
NS_MUC_USER = 'http://jabber.org/protocol/muc#user'
-STATUS_XPATH = '{%s}x/{%s}status' % (NS_MUC_USER, NS_MUC_USER)
COMPARE_USERS_LAST_TALKED = lambda x: x.last_talked
+@dataclass
+class MessageData:
+ message: SMessage
+ delayed: bool
+ date: Optional[datetime]
+ nick: str
+ user: Optional[User]
+ room_from: str
+ body: str
+ is_history: bool
+
+
class MucTab(ChatTab):
"""
The tab containing a multi-user-chat room.
- It contains an userlist, an input, a topic, an information and a chat zone
+ It contains a userlist, an input, a topic, an information and a chat zone
"""
message_type = 'groupchat'
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
- additional_information = {} # type: Dict[str, Callable[[str], str]]
- lagged = False
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable[..., Any]] = {}
+ additional_information: Dict[str, Callable[[str], str]] = {}
+ lagged: bool = False
- def __init__(self, core, jid, nick, password=None):
+ def __init__(self, core: Core, jid: JID, nick: str, password: Optional[str] = None) -> None:
ChatTab.__init__(self, core, jid)
self.joined = False
self._state = 'disconnected'
# our nick in the MUC
self.own_nick = nick
# self User object
- self.own_user = None # type: Optional[User]
- self.name = jid
+ self.own_user: Optional[User] = None
self.password = password
# buffered presences
- self.presence_buffer = []
+ self.presence_buffer: List[Presence] = []
# userlist
- self.users = [] # type: List[User]
+ self.users: List[User] = []
# private conversations
- self.privates = [] # type: List[Tab]
+ self.privates: List[Tab] = []
self.topic = ''
self.topic_from = ''
# Self ping event, so we can cancel it when we leave the room
- self.self_ping_event = None
+ self.self_ping_event: Optional[timed_events.DelayedEvent] = None
# UI stuff
self.topic_win = windows.Topic()
- self.text_win = windows.TextWin()
- self._text_buffer.add_window(self.text_win)
self.v_separator = windows.VerticalSeparator()
self.user_win = windows.UserList()
self.info_header = windows.MucInfoWin()
- self.input = windows.MessageInput()
+ self.input: windows.MessageInput = windows.MessageInput()
# List of ignored users
- self.ignores = [] # type: List[User]
+ self.ignores: List[User] = []
# keys
self.register_keys()
self.update_keys()
@@ -94,8 +134,8 @@ class MucTab(ChatTab):
self.resize()
@property
- def general_jid(self):
- return self.name
+ def general_jid(self) -> JID:
+ return self.jid
def check_send_chat_state(self) -> bool:
"If we should send a chat state"
@@ -109,6 +149,7 @@ class MucTab(ChatTab):
return None
@staticmethod
+ @refresh_wrapper.always
def add_information_element(plugin_name: str, callback: Callable[[str], str]) -> None:
"""
Lets a plugin add its own information to the MucInfoWin
@@ -116,54 +157,65 @@ class MucTab(ChatTab):
MucTab.additional_information[plugin_name] = callback
@staticmethod
+ @refresh_wrapper.always
def remove_information_element(plugin_name: str) -> None:
"""
Lets a plugin add its own information to the MucInfoWin
"""
del MucTab.additional_information[plugin_name]
- def cancel_config(self, form):
+ def cancel_config(self, form: Form) -> None:
"""
- The user do not want to send his/her config, send an iq cancel
+ The user do not want to send their config, send an iq cancel
"""
- muc.cancel_config(self.core.xmpp, self.name)
+ asyncio.create_task(self.core.xmpp['xep_0045'].cancel_config(self.jid))
self.core.close_tab()
- def send_config(self, form):
+ def send_config(self, form: Form) -> None:
"""
- The user sends his/her config to the server
+ The user sends their config to the server
"""
- muc.configure_room(self.core.xmpp, self.name, form)
+ asyncio.create_task(self.core.xmpp['xep_0045'].set_room_config(self.jid, form))
self.core.close_tab()
- def join(self):
+ def join(self) -> None:
"""
Join the room
"""
+ seconds: Optional[int]
status = self.core.get_status()
if self.last_connection:
- delta = datetime.now() - self.last_connection
+ delta = to_utc(datetime.now()) - to_utc(self.last_connection)
seconds = delta.seconds + delta.days * 24 * 3600
else:
+ last_message = self._text_buffer.find_last_message()
seconds = None
+ if last_message is not None:
+ seconds = (datetime.now() - last_message.time).seconds
+ use_log = config.get_by_tabname('mam_sync', self.general_jid)
+ mam_sync = config.get_by_tabname('mam_sync', self.general_jid)
+ if self.mam_filler is None and use_log and mam_sync:
+ limit = config.get_by_tabname('mam_sync_limit', self.jid)
+ self.mam_filler = MAMFiller(logger, self, limit)
muc.join_groupchat(
self.core,
- self.name,
+ self.jid,
self.own_nick,
- self.password,
+ self.password or '',
status=status.message,
show=status.show,
seconds=seconds)
- def leave_room(self, message: str):
+ def leave_room(self, message: str) -> None:
if self.joined:
- info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- char_quit = get_theme().CHAR_QUIT
- spec_col = dump_tuple(get_theme().COLOR_QUIT_CHAR)
+ theme = get_theme()
+ info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ char_quit = theme.CHAR_QUIT
+ spec_col = dump_tuple(theme.COLOR_QUIT_CHAR)
if config.get_by_tabname('display_user_color_in_join_part',
self.general_jid):
- color = dump_tuple(get_theme().COLOR_OWN_NICK)
+ color = dump_tuple(theme.COLOR_OWN_NICK)
else:
color = "3"
@@ -189,76 +241,103 @@ class MucTab(ChatTab):
'color_spec': spec_col,
'nick': self.own_nick,
}
-
- self.add_message(msg, typ=2)
+ self.add_message(MucOwnLeaveMessage(msg))
self.disconnect()
- muc.leave_groupchat(self.core.xmpp, self.name, self.own_nick,
+ muc.leave_groupchat(self.core.xmpp, self.jid, self.own_nick,
message)
- self.core.disable_private_tabs(self.name, reason=msg)
+ self.core.disable_private_tabs(self.jid.bare, reason=msg)
else:
- muc.leave_groupchat(self.core.xmpp, self.name, self.own_nick,
+ self.presence_buffer = []
+ self.users = []
+ muc.leave_groupchat(self.core.xmpp, self.jid, self.own_nick,
message)
- def change_affiliation(self,
- nick_or_jid: Union[str, JID],
- affiliation: str,
- reason=''):
+ async def change_affiliation(
+ self,
+ nick_or_jid: Union[str, JID],
+ affiliation: str,
+ reason: str = ''
+ ) -> None:
"""
Change the affiliation of a nick or JID
"""
-
- def callback(iq):
- if iq['type'] == 'error':
- self.core.information(
- "Could not set affiliation '%s' for '%s'." %
- (affiliation, nick_or_jid), "Warning")
-
if not self.joined:
return
valid_affiliations = ('outcast', 'none', 'member', 'admin', 'owner')
if affiliation not in valid_affiliations:
- return self.core.information(
+ self.core.information(
'The affiliation must be one of ' +
', '.join(valid_affiliations), 'Error')
- if nick_or_jid in [user.nick for user in self.users]:
- muc.set_user_affiliation(
- self.core.xmpp,
- self.name,
- affiliation,
- nick=nick_or_jid,
- callback=callback,
- reason=reason)
- else:
- muc.set_user_affiliation(
- self.core.xmpp,
- self.name,
- affiliation,
- jid=safeJID(nick_or_jid),
- callback=callback,
- reason=reason)
+ return
+ jid = None
+ nick = None
+ for user in self.users:
+ if user.nick == nick_or_jid:
+ jid = user.jid
+ nick = user.nick
+ break
+ if jid is None:
+ try:
+ jid = JID(nick_or_jid)
+ except InvalidJID:
+ self.core.information(
+ f'Invalid JID or missing occupant: {nick_or_jid}',
+ 'Error'
+ )
+ return
- def change_role(self, nick: str, role: str, reason=''):
+ try:
+ if affiliation != 'member':
+ nick = None
+ await self.core.xmpp['xep_0045'].set_affiliation(
+ self.jid,
+ jid=jid,
+ nick=nick,
+ affiliation=affiliation,
+ reason=reason
+ )
+ self.core.information(
+ f"Affiliation of {jid} set to {affiliation} successfully",
+ "Info"
+ )
+ except (IqError, IqTimeout) as exc:
+ self.core.information(
+ f"Could not set affiliation '{affiliation}' for '{jid}': {exc}",
+ "Warning",
+ )
+
+ async def change_role(self, nick: str, role: str, reason: str = '') -> None:
"""
Change the role of a nick
"""
- def callback(iq):
- if iq['type'] == 'error':
- self.core.information(
- "Could not set role '%s' for '%s'." % (role, nick),
- "Warning")
-
valid_roles = ('none', 'visitor', 'participant', 'moderator')
if not self.joined or role not in valid_roles:
- return self.core.information(
+ self.core.information(
'The role must be one of ' + ', '.join(valid_roles), 'Error')
+ return
+
+ try:
+ target_jid = copy(self.jid)
+ target_jid.resource = nick
+ except InvalidJID:
+ self.core.information('Invalid nick', 'Info')
+ return
- if not safeJID(self.name + '/' + nick):
- return self.core.information('Invalid nick', 'Info')
- muc.set_user_role(
- self.core.xmpp, self.name, nick, reason, role, callback=callback)
+ try:
+ await self.core.xmpp['xep_0045'].set_role(
+ self.jid, nick, role=role, reason=reason
+ )
+ self.core.information(
+ f'Role of {nick} changed to {role} successfully.'
+ 'Info'
+ )
+ except (IqError, IqTimeout) as e:
+ self.core.information(
+ "Could not set role '%s' for '%s': %s" % (role, nick, e),
+ "Warning")
@refresh_wrapper.conditional
def print_info(self, nick: str) -> bool:
@@ -289,20 +368,21 @@ class MucTab(ChatTab):
'role': user.role or 'None',
'status': '\n%s' % user.status if user.status else ''
}
- self.add_message(info, typ=0)
+ self.add_message(InfoMessage(info))
return True
- def change_topic(self, topic: str):
+ def change_topic(self, topic: str) -> None:
"""Change the current topic"""
- muc.change_subject(self.core.xmpp, self.name, topic)
+ self.core.xmpp.plugin['xep_0045'].set_subject(self.jid, topic)
@refresh_wrapper.always
- def show_topic(self):
+ def show_topic(self) -> None:
"""
Print the current topic
"""
- info_text = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- norm_text = dump_tuple(get_theme().COLOR_NORMAL_TEXT)
+ theme = get_theme()
+ info_text = dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ norm_text = dump_tuple(theme.COLOR_NORMAL_TEXT)
if self.topic_from:
user = self.get_user_by_name(self.topic_from)
if user:
@@ -314,42 +394,23 @@ class MucTab(ChatTab):
else:
user_string = ''
- self._text_buffer.add_message(
- "\x19%s}The subject of the room is: \x19%s}%s %s" %
- (info_text, norm_text, self.topic, user_string))
+ self.add_message(
+ InfoMessage(
+ "The subject of the room is: \x19%s}%s %s" %
+ (norm_text, self.topic, user_string),
+ ),
+ )
@refresh_wrapper.always
- def recolor(self, random_colors=False):
+ def recolor(self) -> None:
"""Recolor the current MUC users"""
- deterministic = config.get_by_tabname('deterministic_nick_colors',
- self.name)
- if deterministic:
- for user in self.users:
- if user is self.own_user:
- continue
- color = self.search_for_color(user.nick)
- if color != '':
- continue
- user.set_deterministic_color()
- return
- # Sort the user list by last talked, to avoid color conflicts
- # on active participants
- sorted_users = sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True)
- full_sorted_users = sorted_users[:]
- # search our own user, to remove it from the list
- # Also remove users whose color is fixed
- for user in full_sorted_users:
- color = self.search_for_color(user.nick)
+ for user in self.users:
if user is self.own_user:
- sorted_users.remove(user)
- elif color != '':
- sorted_users.remove(user)
- user.change_color(color, deterministic)
- colors = list(get_theme().LIST_COLOR_NICKNAMES)
- if random_colors:
- random.shuffle(colors)
- for i, user in enumerate(sorted_users):
- user.color = colors[i % len(colors)]
+ continue
+ color = self.search_for_color(user.nick)
+ if color != '':
+ continue
+ user.set_deterministic_color()
self.text_win.rebuild_everything(self._text_buffer)
@refresh_wrapper.conditional
@@ -371,7 +432,7 @@ class MucTab(ChatTab):
user.change_color(color)
config.set_and_save(nick, color, 'muc_colors')
nick_color_aliases = config.get_by_tabname('nick_color_aliases',
- self.name)
+ self.jid)
if nick_color_aliases:
# if any user in the room has a nick which is an alias of the
# nick, update its color
@@ -384,7 +445,7 @@ class MucTab(ChatTab):
self.text_win.rebuild_everything(self._text_buffer)
return True
- def on_input(self, key, raw):
+ def on_input(self, key: str, raw: bool) -> bool:
if not raw and key in self.key_func:
self.key_func[key]()
return False
@@ -397,18 +458,15 @@ class MucTab(ChatTab):
return False
def get_nick(self) -> str:
- if config.get('show_muc_jid'):
- return self.name
- bookmark = self.core.bookmarks[self.name]
+ if config.getbool('show_muc_jid'):
+ return cast(str, self.jid)
+ bookmark = self.core.bookmarks[self.jid]
if bookmark is not None and bookmark.name:
return bookmark.name
# TODO: send the disco#info identity name here, if it exists.
- return safeJID(self.name).user
-
- def get_text_window(self):
- return self.text_win
+ return self.jid.node
- def on_lose_focus(self):
+ def on_lose_focus(self) -> None:
if self.joined:
if self.input.text:
self.state = 'nonempty'
@@ -424,10 +482,10 @@ class MucTab(ChatTab):
self.send_chat_state('inactive')
self.check_scrolled()
- def on_gain_focus(self):
+ def on_gain_focus(self) -> None:
self.state = 'current'
if (self.text_win.built_lines and self.text_win.built_lines[-1] is None
- and not config.get('show_useless_separator')):
+ and not config.getbool('show_useless_separator')):
self.text_win.remove_line_separator()
curses.curs_set(1)
if self.joined and config.get_by_tabname(
@@ -435,19 +493,136 @@ class MucTab(ChatTab):
self.general_jid) and not self.input.get_text():
self.send_chat_state('active')
- def handle_presence(self, presence):
+ async def handle_message(self, message: SMessage) -> bool:
+ """Parse an incoming message
+
+ Returns False if the message was dropped silently.
"""
- Handle MUC presence
+ room_from = message['from'].bare
+ nick_from = message['mucnick']
+ user = self.get_user_by_name(nick_from)
+ if user and user in self.ignores:
+ return False
+
+ await self.core.events.trigger_async('muc_msg', message, self)
+ use_xhtml = config.get_by_tabname('enable_xhtml_im', room_from)
+ tmp_dir = get_image_cache()
+ body = xhtml.get_body_from_message_stanza(
+ message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
+
+ # TODO: #3314. Is this a MUC reflection?
+ # Is this an encrypted message? Is so ignore.
+ # It is not possible in the OMEMO case to decrypt these messages
+ # since we don't encrypt for our own device (something something
+ # forward secrecy), but even for non-FS encryption schemes anyway
+ # messages shouldn't have changed after a round-trip to the room.
+ # Otherwire replace the matching message we sent.
+ if not body:
+ return False
+
+ old_state = self.state
+ delayed, date = common.find_delayed_tag(message)
+ is_history = not self.joined and delayed
+
+ mdata = MessageData(
+ message, delayed, date, nick_from, user, room_from, body,
+ is_history
+ )
+
+ replaced = False
+ if message.xml.find('{urn:xmpp:message-correct:0}replace') is not None:
+ replaced = await self._handle_correction_message(mdata)
+ if not replaced:
+ await self._handle_normal_message(mdata)
+ if mdata.nick == self.own_nick:
+ self.set_last_sent_message(message, correct=replaced)
+ self._refresh_after_message(old_state)
+ return True
+
+ def _refresh_after_message(self, old_state: str) -> None:
+ """Refresh the appropriate UI after a message is received"""
+ if self is self.core.tabs.current_tab:
+ self.refresh()
+ elif self.state != old_state:
+ self.core.refresh_tab_win()
+ current = self.core.tabs.current_tab
+ current.refresh_input()
+ self.core.doupdate()
+
+ async def _handle_correction_message(self, message: MessageData) -> bool:
+ """Process a correction message.
+
+ Returns true if a message was actually corrected.
"""
+ replaced_id = message.message['replace']['id']
+ if replaced_id != '' and config.get_by_tabname(
+ 'group_corrections', JID(message.room_from)):
+ try:
+ delayed_date = message.date or datetime.now()
+ modify_hl = self.modify_message(
+ message.body,
+ replaced_id,
+ message.message['id'],
+ time=delayed_date,
+ delayed=message.delayed,
+ nickname=message.nick,
+ user=message.user
+ )
+ if modify_hl:
+ await self.core.events.trigger_async(
+ 'highlight',
+ message.message,
+ self
+ )
+ return True
+ except CorrectionError:
+ log.debug('Unable to correct a message', exc_info=True)
+ return False
+
+ async def _handle_normal_message(self, message: MessageData) -> None:
+ """
+ Process the non-correction groupchat message.
+ """
+ ui_msg: Union[InfoMessage, Message]
+ # Messages coming from MUC barejid (Server maintenance, IRC mode
+ # changes from biboumi, etc.) have no nick/resource and are displayed
+ # as info messages.
+ highlight = False
+ if message.nick:
+ highlight = self.message_is_highlight(
+ message.body, message.nick, message.is_history
+ )
+ ui_msg = Message(
+ txt=message.body,
+ time=message.date,
+ nickname=message.nick,
+ history=message.is_history,
+ delayed=message.delayed,
+ identifier=message.message['id'],
+ jid=message.message['from'],
+ user=message.user,
+ highlight=highlight,
+ )
+ else:
+ ui_msg = InfoMessage(
+ txt=message.body,
+ time=message.date,
+ identifier=message.message['id'],
+ )
+ self.add_message(ui_msg)
+ if highlight:
+ await self.core.events.trigger_async('highlight', message, self)
+
+ def handle_presence(self, presence: Presence) -> None:
+ """Handle MUC presence"""
self.reset_lag()
- status_codes = set()
- for status_code in presence.xml.findall(STATUS_XPATH):
- status_codes.add(status_code.attrib['code'])
+ status_codes = presence['muc']['status_codes']
if presence['type'] == 'error':
- self.core.room_error(presence, self.name)
+ self.core.room_error(presence, self.jid.bare)
elif not self.joined:
- if '110' in status_codes or self.own_nick == presence['from'].resource:
- self.process_presence_buffer(presence)
+ own = 110 in status_codes
+ if own or len(self.presence_buffer) >= 10:
+ self.process_presence_buffer(presence, own)
else:
self.presence_buffer.append(presence)
return
@@ -465,63 +640,64 @@ class MucTab(ChatTab):
self.input.refresh()
self.core.doupdate()
- def process_presence_buffer(self, last_presence):
+ def process_presence_buffer(self, last_presence: Presence, own: bool) -> None:
"""
Batch-process all the initial presences
"""
- deterministic = config.get_by_tabname('deterministic_nick_colors',
- self.name)
-
for stanza in self.presence_buffer:
try:
- self.handle_presence_unjoined(stanza, deterministic)
+ self.handle_presence_unjoined(stanza)
except PresenceError:
self.core.room_error(stanza, stanza['from'].bare)
- self.handle_presence_unjoined(last_presence, deterministic, own=True)
+ self.presence_buffer = []
+ self.handle_presence_unjoined(last_presence, own)
self.users.sort()
# Enable the self ping event, to regularly check if we
# are still in the room.
- self.enable_self_ping_event()
+ if own:
+ self.enable_self_ping_event()
if self.core.tabs.current_tab is not self:
self.refresh_tab_win()
self.core.tabs.current_tab.refresh_input()
self.core.doupdate()
- def handle_presence_unjoined(self, presence, deterministic, own=False):
+ def handle_presence_unjoined(self, presence: Presence, own: bool = False) -> None:
"""
Presence received while we are not in the room (before code=110)
"""
- from_nick, _, affiliation, show, status, role, jid, typ = dissect_presence(
- presence)
+ # If presence is coming from MUC barejid, ignore.
+ if not presence['from'].resource:
+ return None
+ dissected_presence = dissect_presence(presence)
+ from_nick, _, affiliation, show, status, role, jid, typ = dissected_presence
if typ == 'unavailable':
return
user_color = self.search_for_color(from_nick)
new_user = User(from_nick, affiliation, show, status, role, jid,
- deterministic, user_color)
+ user_color)
self.users.append(new_user)
self.core.events.trigger('muc_join', presence, self)
if own:
- status_codes = set()
- for status_code in presence.xml.findall(STATUS_XPATH):
- status_codes.add(status_code.attrib['code'])
+ status_codes = presence['muc']['status_codes']
self.own_join(from_nick, new_user, status_codes)
- def own_join(self, from_nick: str, new_user: User, status_codes: Set[str]):
+ def own_join(self, from_nick: str, new_user: User, status_codes: Set[int]) -> None:
"""
Handle the last presence we received, entering the room
"""
self.own_nick = from_nick
self.own_user = new_user
self.joined = True
- if self.name in self.core.initial_joins:
- self.core.initial_joins.remove(self.name)
+ if self.jid in self.core.initial_joins:
+ self.core.initial_joins.remove(self.jid)
self._state = 'normal'
elif self != self.core.tabs.current_tab:
self._state = 'joined'
if (self.core.tabs.current_tab is self
and self.core.status.show not in ('xa', 'away')):
self.send_chat_state('active')
- new_user.color = get_theme().COLOR_OWN_NICK
+ theme = get_theme()
+ new_user.color = theme.COLOR_OWN_NICK
if config.get_by_tabname('display_user_color_in_join_part',
self.general_jid):
@@ -529,54 +705,63 @@ class MucTab(ChatTab):
else:
color = "3"
- info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- warn_col = dump_tuple(get_theme().COLOR_WARNING_TEXT)
- spec_col = dump_tuple(get_theme().COLOR_JOIN_CHAR)
+ info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ warn_col = dump_tuple(theme.COLOR_WARNING_TEXT)
+ spec_col = dump_tuple(theme.COLOR_JOIN_CHAR)
enable_message = ('\x19%(color_spec)s}%(spec)s\x19%(info_col)s} You '
'(\x19%(nick_col)s}%(nick)s\x19%(info_col)s}) joined'
' the room') % {
'nick': from_nick,
- 'spec': get_theme().CHAR_JOIN,
+ 'spec': theme.CHAR_JOIN,
'color_spec': spec_col,
'nick_col': color,
'info_col': info_col,
}
- self.add_message(enable_message, typ=2)
- self.core.enable_private_tabs(self.name, enable_message)
- if '201' in status_codes:
+ self.add_message(MucOwnJoinMessage(enable_message))
+ self.core.enable_private_tabs(self.jid.bare, enable_message)
+ if 201 in status_codes:
self.add_message(
- '\x19%(info_col)s}Info: The room '
- 'has been created' % {'info_col': info_col},
- typ=0)
- if '170' in status_codes:
+ PersistentInfoMessage('Info: The room has been created'),
+ )
+ if 170 in status_codes:
self.add_message(
- '\x19%(warn_col)s}Warning:\x19%(info_col)s}'
- ' This room is publicly logged' % {
- 'info_col': info_col,
- 'warn_col': warn_col
- },
- typ=0)
- if '100' in status_codes:
+ InfoMessage(
+ '\x19%(warn_col)s}Warning:\x19%(info_col)s}'
+ ' This room is publicly logged' % {
+ 'info_col': info_col,
+ 'warn_col': warn_col
+ }
+ ),
+ )
+ if 100 in status_codes:
self.add_message(
- '\x19%(warn_col)s}Warning:\x19%(info_col)s}'
- ' This room is not anonymous.' % {
- 'info_col': info_col,
- 'warn_col': warn_col
- },
- typ=0)
-
- def handle_presence_joined(self, presence, status_codes):
+ InfoMessage(
+ '\x19%(warn_col)s}Warning:\x19%(info_col)s}'
+ ' This room is not anonymous.' % {
+ 'info_col': info_col,
+ 'warn_col': warn_col
+ },
+ ),
+ )
+ asyncio.create_task(LogLoader(
+ logger, self, config.get_by_tabname('use_log', self.general_jid)
+ ).tab_open())
+
+ def handle_presence_joined(self, presence: Presence, status_codes: Set[int]) -> None:
"""
Handle new presences when we are already in the room
"""
- from_nick, from_room, affiliation, show, status, role, jid, typ = dissect_presence(
- presence)
- change_nick = '303' in status_codes
- kick = '307' in status_codes and typ == 'unavailable'
- ban = '301' in status_codes and typ == 'unavailable'
- shutdown = '332' in status_codes and typ == 'unavailable'
- server_initiated = '333' in status_codes and typ == 'unavailable'
- non_member = '322' in status_codes and typ == 'unavailable'
+ # If presence is coming from MUC barejid, ignore.
+ if not presence['from'].resource:
+ return None
+ dissected_presence = dissect_presence(presence)
+ from_nick, from_room, affiliation, show, status, role, jid, typ = dissected_presence
+ change_nick = 303 in status_codes
+ kick = 307 in status_codes and typ == 'unavailable'
+ ban = 301 in status_codes and typ == 'unavailable'
+ shutdown = 332 in status_codes and typ == 'unavailable'
+ server_initiated = 333 in status_codes and typ == 'unavailable'
+ non_member = 322 in status_codes and typ == 'unavailable'
user = self.get_user_by_name(from_nick)
# New user
if not user and typ != "unavailable":
@@ -585,11 +770,11 @@ class MucTab(ChatTab):
self.on_user_join(from_nick, affiliation, show, status, role, jid,
user_color)
elif user is None:
- log.error('BUG: User %s in %s is None', from_nick, self.name)
+ log.error('BUG: User %s in %s is None', from_nick, self.jid)
return
elif change_nick:
self.core.events.trigger('muc_nickchange', presence, self)
- self.on_user_nick_change(presence, user, from_nick, from_room)
+ self.on_user_nick_change(presence, user, from_nick)
elif ban:
self.core.events.trigger('muc_ban', presence, self)
self.core.on_user_left_private_conversation(
@@ -609,39 +794,50 @@ class MucTab(ChatTab):
# user quit
elif typ == 'unavailable':
self.on_user_leave_groupchat(user, jid, status, from_nick,
- from_room, server_initiated)
+ JID(from_room), server_initiated)
+ ns = 'http://jabber.org/protocol/muc#user'
+ if presence.xml.find(f'{{{ns}}}x/{{{ns}}}destroy') is not None:
+ info = f'Room {self.jid} was destroyed.'
+ if presence['muc']['destroy']:
+ reason = presence['muc']['destroy']['reason']
+ altroom = presence['muc']['destroy']['jid']
+ if reason:
+ info += f' “{reason}”.'
+ if altroom:
+ info += f' The new address now is {altroom}.'
+ self.core.information(info, 'Info')
# status change
else:
self.on_user_change_status(user, from_nick, from_room, affiliation,
role, show, status)
- def on_non_member_kicked(self):
+ def on_non_member_kicked(self) -> None:
"""We have been kicked because the MUC is members-only"""
self.add_message(
- '\x19%(info_col)s}You have been kicked because you '
- 'are not a member and the room is now members-only.' %
- {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
- typ=2)
+ MucOwnLeaveMessage(
+ 'You have been kicked because you '
+ 'are not a member and the room is now members-only.'
+ )
+ )
self.disconnect()
- def on_muc_shutdown(self):
+ def on_muc_shutdown(self) -> None:
"""We have been kicked because the MUC service is shutting down"""
self.add_message(
- '\x19%(info_col)s}You have been kicked because the'
- ' MUC service is shutting down.' %
- {'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)},
- typ=2)
+ MucOwnLeaveMessage(
+ 'You have been kicked because the'
+ ' MUC service is shutting down.'
+ )
+ )
self.disconnect()
- def on_user_join(self, from_nick, affiliation, show, status, role, jid,
- color):
+ def on_user_join(self, from_nick: str, affiliation: str, show: str, status: str, role: str, jid: JID,
+ color: str) -> None:
"""
When a new user joins the groupchat
"""
- deterministic = config.get_by_tabname('deterministic_nick_colors',
- self.name)
user = User(from_nick, affiliation, show, status, role, jid,
- deterministic, color)
+ color)
bisect.insort_left(self.users, user)
hide_exit_join = config.get_by_tabname('hide_exit_join',
self.general_jid)
@@ -650,10 +846,11 @@ class MucTab(ChatTab):
self.general_jid):
color = dump_tuple(user.color)
else:
- color = 3
- info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- spec_col = dump_tuple(get_theme().COLOR_JOIN_CHAR)
- char_join = get_theme().CHAR_JOIN
+ color = "3"
+ theme = get_theme()
+ info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ spec_col = dump_tuple(theme.COLOR_JOIN_CHAR)
+ char_join = theme.CHAR_JOIN
if not jid.full:
msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}%(nick)s'
'\x19%(info_col)s} joined the room') % {
@@ -672,16 +869,17 @@ class MucTab(ChatTab):
'color': color,
'jid': jid.full,
'info_col': info_col,
- 'jid_color': dump_tuple(get_theme().COLOR_MUC_JID),
+ 'jid_color': dump_tuple(theme.COLOR_MUC_JID),
'color_spec': spec_col,
}
- self.add_message(msg, typ=2)
- self.core.on_user_rejoined_private_conversation(self.name, from_nick)
-
- def on_user_nick_change(self, presence, user, from_nick, from_room):
- new_nick = presence.xml.find(
- '{%s}x/{%s}item' % (NS_MUC_USER, NS_MUC_USER)).attrib['nick']
- old_color = user.color
+ self.add_message(PersistentInfoMessage(msg))
+ self.core.on_user_rejoined_private_conversation(self.jid.bare, from_nick)
+
+ def on_user_nick_change(self, presence: Presence, user: User, from_nick: str) -> None:
+ new_nick = presence['muc']['item']['nick']
+ if not new_nick:
+ return # should not happen
+ old_color_tuple = user.color
if user.nick == self.own_nick:
self.own_nick = new_nick
# also change our nick in all private discussions of this room
@@ -689,57 +887,56 @@ class MucTab(ChatTab):
user.change_nick(new_nick)
else:
user.change_nick(new_nick)
- deterministic = config.get_by_tabname('deterministic_nick_colors',
- self.name)
- color = config.get_by_tabname(new_nick, 'muc_colors') or None
- if color or deterministic:
- user.change_color(color, deterministic)
+ color = config.getstr(new_nick, section='muc_colors') or None
+ user.change_color(color)
self.users.remove(user)
bisect.insort_left(self.users, user)
if config.get_by_tabname('display_user_color_in_join_part',
self.general_jid):
color = dump_tuple(user.color)
- old_color = dump_tuple(old_color)
+ old_color = dump_tuple(old_color_tuple)
else:
- old_color = color = 3
+ old_color = color = "3"
info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
self.add_message(
- '\x19%(old_color)s}%(old)s\x19%(info_col)s} is'
- ' now known as \x19%(color)s}%(new)s' % {
- 'old': from_nick,
- 'new': new_nick,
- 'color': color,
- 'old_color': old_color,
- 'info_col': info_col
- },
- typ=2)
+ PersistentInfoMessage(
+ '\x19%(old_color)s}%(old)s\x19%(info_col)s} is'
+ ' now known as \x19%(color)s}%(new)s' % {
+ 'old': from_nick,
+ 'new': new_nick,
+ 'color': color,
+ 'old_color': old_color,
+ 'info_col': info_col
+ },
+ )
+ )
# rename the private tabs if needed
- self.core.rename_private_tabs(self.name, from_nick, user)
+ self.core.rename_private_tabs(self.jid.bare, from_nick, user)
- def on_user_banned(self, presence, user, from_nick):
+ def on_user_banned(self, presence: Presence, user: User, from_nick: str) -> None:
"""
When someone is banned from a muc
"""
+ cls: Type[InfoMessage] = PersistentInfoMessage
self.users.remove(user)
- by = presence.xml.find('{%s}x/{%s}item/{%s}actor' %
- (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
- reason = presence.xml.find('{%s}x/{%s}item/{%s}reason' %
- (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
- if by:
- by = by.get('jid') or by.get('nick') or None
- else:
- by = None
+ by = presence['muc']['item'].get_plugin('actor', check=True)
+ reason = presence['muc']['item']['reason']
+ by_repr: Union[JID, str, None] = None
+ if by is not None:
+ by_repr = by['jid'] or by['nick'] or None
- info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- char_kick = get_theme().CHAR_KICK
+ theme = get_theme()
+ info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ char_kick = theme.CHAR_KICK
if from_nick == self.own_nick: # we are banned
+ cls = MucOwnLeaveMessage
if by:
kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}'
' have been banned by \x194}%(by)s') % {
'spec': char_kick,
- 'by': by,
+ 'by': by_repr,
'info_col': info_col
}
else:
@@ -748,7 +945,7 @@ class MucTab(ChatTab):
'spec': char_kick,
'info_col': info_col
}
- self.core.disable_private_tabs(self.name, reason=kick_msg)
+ self.core.disable_private_tabs(self.jid.bare, reason=kick_msg)
self.disconnect()
self.refresh_tab_win()
self.core.tabs.current_tab.refresh_input()
@@ -757,11 +954,11 @@ class MucTab(ChatTab):
self.general_jid)
delay = common.parse_str_to_secs(delay)
if delay <= 0:
- muc.join_groupchat(self.core, self.name, self.own_nick)
+ muc.join_groupchat(self.core, self.jid, self.own_nick)
else:
self.core.add_timed_event(
timed_events.DelayedEvent(delay, muc.join_groupchat,
- self.core, self.name,
+ self.core, self.jid,
self.own_nick))
else:
@@ -769,16 +966,16 @@ class MucTab(ChatTab):
self.general_jid):
color = dump_tuple(user.color)
else:
- color = 3
+ color = "3"
- if by:
+ if by_repr:
kick_msg = ('\x191}%(spec)s \x19%(color)s}'
'%(nick)s\x19%(info_col)s} '
'has been banned by \x194}%(by)s') % {
'spec': char_kick,
'nick': from_nick,
'color': color,
- 'by': by,
+ 'by': by_repr,
'info_col': info_col
}
else:
@@ -789,29 +986,30 @@ class MucTab(ChatTab):
'color': color,
'info_col': info_col
}
- if reason is not None and reason.text:
+ if reason:
kick_msg += ('\x19%(info_col)s} Reason: \x196}'
'%(reason)s\x19%(info_col)s}') % {
- 'reason': reason.text,
+ 'reason': reason,
'info_col': info_col
}
- self.add_message(kick_msg, typ=2)
+ self.add_message(cls(kick_msg))
- def on_user_kicked(self, presence, user, from_nick):
+ def on_user_kicked(self, presence: Presence, user: User, from_nick: str) -> None:
"""
When someone is kicked from a muc
"""
+ cls: Type[InfoMessage] = PersistentInfoMessage
self.users.remove(user)
- actor_elem = presence.xml.find('{%s}x/{%s}item/{%s}actor' %
- (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
- reason = presence.xml.find('{%s}x/{%s}item/{%s}reason' %
- (NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
+ actor_elem = presence['muc']['item'].get_plugin('actor', check=True)
+ reason = presence['muc']['item']['reason']
by = None
- info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- char_kick = get_theme().CHAR_KICK
+ theme = get_theme()
+ info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ char_kick = theme.CHAR_KICK
if actor_elem is not None:
- by = actor_elem.get('nick') or actor_elem.get('jid')
+ by = actor_elem['nick'] or actor_elem.get['jid'] or None
if from_nick == self.own_nick: # we are kicked
+ cls = MucOwnLeaveMessage
if by:
kick_msg = ('\x191}%(spec)s \x193}You\x19'
'%(info_col)s} have been kicked'
@@ -826,7 +1024,7 @@ class MucTab(ChatTab):
'spec': char_kick,
'info_col': info_col
}
- self.core.disable_private_tabs(self.name, reason=kick_msg)
+ self.core.disable_private_tabs(self.jid.bare, reason=kick_msg)
self.disconnect()
self.refresh_tab_win()
self.core.tabs.current_tab.refresh_input()
@@ -836,18 +1034,18 @@ class MucTab(ChatTab):
self.general_jid)
delay = common.parse_str_to_secs(delay)
if delay <= 0:
- muc.join_groupchat(self.core, self.name, self.own_nick)
+ muc.join_groupchat(self.core, self.jid, self.own_nick)
else:
self.core.add_timed_event(
timed_events.DelayedEvent(delay, muc.join_groupchat,
- self.core, self.name,
+ self.core, self.jid,
self.own_nick))
else:
if config.get_by_tabname('display_user_color_in_join_part',
self.general_jid):
color = dump_tuple(user.color)
else:
- color = 3
+ color = "3"
if by:
kick_msg = ('\x191}%(spec)s \x19%(color)s}%(nick)s'
'\x19%(info_col)s} has been kicked by '
@@ -866,13 +1064,13 @@ class MucTab(ChatTab):
'color': color,
'info_col': info_col
}
- if reason is not None and reason.text:
+ if reason:
kick_msg += ('\x19%(info_col)s} Reason: \x196}'
'%(reason)s') % {
- 'reason': reason.text,
+ 'reason': reason,
'info_col': info_col
}
- self.add_message(kick_msg, typ=2)
+ self.add_message(cls(kick_msg))
def on_user_leave_groupchat(self,
user: User,
@@ -880,16 +1078,16 @@ class MucTab(ChatTab):
status: str,
from_nick: str,
from_room: JID,
- server_initiated=False):
+ server_initiated: bool = False) -> None:
"""
- When an user leaves a groupchat
+ When a user leaves a groupchat
"""
self.users.remove(user)
if self.own_nick == user.nick:
# We are now out of the room.
# Happens with some buggy (? not sure) servers
self.disconnect()
- self.core.disable_private_tabs(from_room)
+ self.core.disable_private_tabs(from_room.bare)
self.refresh_tab_win()
hide_exit_join = config.get_by_tabname('hide_exit_join',
@@ -900,9 +1098,10 @@ class MucTab(ChatTab):
self.general_jid):
color = dump_tuple(user.color)
else:
- color = 3
- info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- spec_col = dump_tuple(get_theme().COLOR_QUIT_CHAR)
+ color = "3"
+ theme = get_theme()
+ info_col = dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ spec_col = dump_tuple(theme.COLOR_QUIT_CHAR)
error_leave_txt = ''
if server_initiated:
@@ -914,18 +1113,18 @@ class MucTab(ChatTab):
'room%(error_leave)s') % {
'nick': from_nick,
'color': color,
- 'spec': get_theme().CHAR_QUIT,
+ 'spec': theme.CHAR_QUIT,
'info_col': info_col,
'color_spec': spec_col,
'error_leave': error_leave_txt,
}
else:
- jid_col = dump_tuple(get_theme().COLOR_MUC_JID)
+ jid_col = dump_tuple(theme.COLOR_MUC_JID)
leave_msg = ('\x19%(color_spec)s}%(spec)s \x19%(color)s}'
'%(nick)s\x19%(info_col)s} (\x19%(jid_col)s}'
'%(jid)s\x19%(info_col)s}) has left the '
'room%(error_leave)s') % {
- 'spec': get_theme().CHAR_QUIT,
+ 'spec': theme.CHAR_QUIT,
'nick': from_nick,
'color': color,
'jid': jid.full,
@@ -936,13 +1135,13 @@ class MucTab(ChatTab):
}
if status:
leave_msg += ' (\x19o%s\x19%s})' % (status, info_col)
- self.add_message(leave_msg, typ=2)
- self.core.on_user_left_private_conversation(from_room, user, status)
+ self.add_message(PersistentInfoMessage(leave_msg))
+ self.core.on_user_left_private_conversation(from_room.bare, user, status)
- def on_user_change_status(self, user, from_nick, from_room, affiliation,
- role, show, status):
+ def on_user_change_status(self, user: User, from_nick: str, from_room: str, affiliation: str,
+ role: str, show: str, status: str) -> None:
"""
- When an user changes her status
+ When a user changes her status
"""
# build the message
display_message = False # flag to know if something significant enough
@@ -951,17 +1150,18 @@ class MucTab(ChatTab):
self.general_jid):
color = dump_tuple(user.color)
else:
- color = 3
+ color = "3"
+ info_col = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
if from_nick == self.own_nick:
msg = '\x19%(color)s}You\x19%(info_col)s} changed: ' % {
- 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
+ 'info_col': info_col,
'color': color
}
else:
msg = '\x19%(color)s}%(nick)s\x19%(info_col)s} changed: ' % {
'nick': from_nick,
'color': color,
- 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ 'info_col': info_col
}
if affiliation != user.affiliation:
msg += 'affiliation: %s, ' % affiliation
@@ -994,15 +1194,16 @@ class MucTab(ChatTab):
or show != user.show or status != user.status)) or (
affiliation != user.affiliation or role != user.role):
# display the message in the room
- self._text_buffer.add_message(msg)
+ self.add_message(InfoMessage(msg))
self.core.on_user_changed_status_in_private(
- '%s/%s' % (from_room, from_nick), Status(show, status))
+ JID('%s/%s' % (from_room, from_nick)), Status(show, status)
+ )
self.users.remove(user)
# finally, effectively change the user status
user.update(affiliation, show, status, role)
bisect.insort_left(self.users, user)
- def disconnect(self):
+ def disconnect(self) -> None:
"""
Set the state of the room as not joined, so
we can know if we can join it, send messages to it, etc
@@ -1014,23 +1215,13 @@ class MucTab(ChatTab):
self.joined = False
self.disable_self_ping_event()
- def get_single_line_topic(self):
+ def get_single_line_topic(self) -> str:
"""
Return the topic as a single-line string (for the window header)
"""
return self.topic.replace('\n', '|')
- def log_message(self, txt, nickname, time=None, typ=1):
- """
- Log the messages in the archives, if it needs
- to be
- """
- if time is None and self.joined: # don't log the history messages
- if not logger.log_message(self.name, nickname, txt, typ=typ):
- self.core.information('Unable to write in the log file',
- 'Error')
-
- def get_user_by_name(self, nick):
+ def get_user_by_name(self, nick: str) -> Optional[User]:
"""
Gets the user associated with the given nick, or None if not found
"""
@@ -1039,65 +1230,34 @@ class MucTab(ChatTab):
return user
return None
- def add_message(self, txt, time=None, nickname=None, **kwargs):
- """
- Note that user can be None even if nickname is not None. It happens
- when we receive an history message said by someone who is not
- in the room anymore
- Return True if the message highlighted us. False otherwise.
- """
-
+ def add_message(self, msg: BaseMessage) -> None:
+ """Add a message to the text buffer and set various tab status"""
# reset self-ping interval
if self.self_ping_event:
self.enable_self_ping_event()
-
- self.log_message(txt, nickname, time=time, typ=kwargs.get('typ', 1))
- args = dict()
- for key, value in kwargs.items():
- if key not in ('typ', 'forced_user'):
- args[key] = value
- if nickname is not None:
- user = self.get_user_by_name(nickname)
- else:
- user = None
-
- if user:
- user.set_last_talked(datetime.now())
- args['user'] = user
- if not user and kwargs.get('forced_user'):
- args['user'] = kwargs['forced_user']
-
- if (not time and nickname and nickname != self.own_nick
- and self.state != 'current'):
- if (self.state != 'highlight'
- and config.get_by_tabname('notify_messages', self.name)):
+ super().add_message(msg)
+ if not isinstance(msg, Message):
+ return
+ if msg.user:
+ msg.user.set_last_talked(msg.time)
+ if config.get_by_tabname('notify_messages', self.jid) and self.state != 'current':
+ if msg.nickname != self.own_nick and not msg.history:
self.state = 'message'
- if time and not txt.startswith('/me'):
- txt = '\x19%(info_col)s}%(txt)s' % {
- 'txt': txt,
- 'info_col': dump_tuple(get_theme().COLOR_LOG_MSG)
- }
- elif not nickname:
- txt = '\x19%(info_col)s}%(txt)s' % {
- 'txt': txt,
- 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- }
- elif not kwargs.get('highlight'): # TODO
- args['highlight'] = self.do_highlight(txt, time, nickname)
- time = time or datetime.now()
- self._text_buffer.add_message(txt, time, nickname, **args)
- return args.get('highlight', False)
+ if msg.txt and msg.nickname:
+ self.do_highlight(msg.txt, msg.nickname, msg.history)
def modify_message(self,
- txt,
- old_id,
- new_id,
- time=None,
- nickname=None,
- user=None,
- jid=None):
- self.log_message(txt, nickname, time=time, typ=1)
- highlight = self.do_highlight(txt, time, nickname, corrected=True)
+ txt: str,
+ old_id: str,
+ new_id: str,
+ time: Optional[datetime] = None,
+ delayed: bool = False,
+ nickname: Optional[str] = None,
+ user: Optional[User] = None,
+ jid: Optional[JID] = None) -> bool:
+ highlight = self.message_is_highlight(
+ txt, nickname, delayed, corrected=True
+ )
message = self._text_buffer.modify_message(
txt,
old_id,
@@ -1107,14 +1267,15 @@ class MucTab(ChatTab):
user=user,
jid=jid)
if message:
- self.text_win.modify_message(old_id, message)
+ self.log_message(message)
+ self.text_win.modify_message(message.identifier, message)
return highlight
return False
- def matching_names(self):
- return [(1, safeJID(self.name).user), (3, self.name)]
+ def matching_names(self) -> List[Tuple[int, str]]:
+ return [(1, self.jid.node), (3, self.jid.full)]
- def enable_self_ping_event(self):
+ def enable_self_ping_event(self) -> None:
delay = config.get_by_tabname(
"self_ping_delay", self.general_jid, default=0)
interval = int(
@@ -1127,61 +1288,67 @@ class MucTab(ChatTab):
interval, self.send_self_ping)
self.core.add_timed_event(self.self_ping_event)
- def disable_self_ping_event(self):
+ def disable_self_ping_event(self) -> None:
if self.self_ping_event is not None:
self.core.remove_timed_event(self.self_ping_event)
self.self_ping_event = None
- def send_self_ping(self):
- timeout = config.get_by_tabname(
- "self_ping_timeout", self.general_jid, default=60)
- to = self.name + "/" + self.own_nick
- self.core.xmpp.plugin['xep_0199'].send_ping(
- jid=to,
- callback=self.on_self_ping_result,
- timeout_callback=self.on_self_ping_failed,
- timeout=timeout)
-
- def on_self_ping_result(self, iq):
- if iq["type"] == "error" and iq["error"]["condition"] != "feature-not-implemented":
+ def send_self_ping(self) -> None:
+ if self.core.xmpp.is_connected():
+ timeout = config.get_by_tabname(
+ "self_ping_timeout", self.general_jid, default=60)
+ to = self.jid.bare + "/" + self.own_nick
+ self.core.xmpp.plugin['xep_0199'].send_ping(
+ jid=JID(to),
+ callback=self.on_self_ping_result,
+ timeout_callback=self.on_self_ping_failed,
+ timeout=timeout)
+ else:
+ self.enable_self_ping_event()
+
+ def on_self_ping_result(self, iq: Iq) -> None:
+ if iq["type"] == "error" and iq["error"]["condition"] not in \
+ ("feature-not-implemented", "service-unavailable", "item-not-found"):
self.command_cycle(iq["error"]["text"] or "not in this room")
self.core.refresh_window()
else: # Re-send a self-ping in a few seconds
self.reset_lag()
self.enable_self_ping_event()
- def search_for_color(self, nick):
+ def search_for_color(self, nick: str) -> str:
"""
Search for the color of a nick in the config file.
Also, look at the colors of its possible aliases if nick_color_aliases
is set.
"""
- color = config.get_by_tabname(nick, 'muc_colors')
+ color = config.getstr(nick, section='muc_colors')
if color != '':
return color
nick_color_aliases = config.get_by_tabname('nick_color_aliases',
- self.name)
+ self.jid)
if nick_color_aliases:
nick_alias = re.sub('^_*(.*?)_*$', '\\1', nick)
- color = config.get_by_tabname(nick_alias, 'muc_colors')
+ color = config.getstr(nick_alias, section='muc_colors')
return color
- def on_self_ping_failed(self, iq):
+ def on_self_ping_failed(self, iq: Any = None) -> None:
if not self.lagged:
self.lagged = True
- info_text = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
self._text_buffer.add_message(
- "\x19%s}MUC service not responding." % info_text)
+ InfoMessage(
+ "MUC service not responding."
+ ),
+ )
self._state = 'disconnected'
self.core.refresh_window()
self.enable_self_ping_event()
- def reset_lag(self):
+ def reset_lag(self) -> None:
if self.lagged:
self.lagged = False
- info_text = dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- self._text_buffer.add_message(
- "\x19%s}MUC service is responding again." % info_text)
+ self.add_message(
+ InfoMessage("MUC service is responding again.")
+ )
if self != self.core.tabs.current_tab:
self._state = 'joined'
else:
@@ -1191,35 +1358,35 @@ class MucTab(ChatTab):
########################## UI ONLY #####################################
@refresh_wrapper.always
- def go_to_next_hl(self):
+ def go_to_next_hl(self) -> None:
"""
Go to the next HL in the room, or the last
"""
self.text_win.next_highlight()
@refresh_wrapper.always
- def go_to_prev_hl(self):
+ def go_to_prev_hl(self) -> None:
"""
Go to the previous HL in the room, or the first
"""
self.text_win.previous_highlight()
@refresh_wrapper.always
- def scroll_user_list_up(self):
+ def scroll_user_list_up(self) -> None:
"Scroll up in the userlist"
self.user_win.scroll_up()
@refresh_wrapper.always
- def scroll_user_list_down(self):
+ def scroll_user_list_down(self) -> None:
"Scroll down in the userlist"
self.user_win.scroll_down()
- def resize(self):
+ def resize(self) -> None:
"""
Resize the whole window. i.e. all its sub-windows
"""
self.need_resize = False
- if config.get('hide_user_list') or self.size.tab_degrade_x:
+ if config.getbool('hide_user_list') or self.size.tab_degrade_x:
text_width = self.width
else:
text_width = (self.width // 10) * 9
@@ -1243,18 +1410,18 @@ class MucTab(ChatTab):
self.text_win.resize(
self.height - 3 - info_win_height - tab_win_height, text_width, 1,
- 0)
- self.text_win.rebuild_everything(self._text_buffer)
+ 0, self._text_buffer, force=self.ui_config_changed)
+ self.ui_config_changed = False
self.info_header.resize(
1, self.width, self.height - 2 - info_win_height - tab_win_height,
0)
self.input.resize(1, self.width, self.height - 1, 0)
- def refresh(self):
+ def refresh(self) -> None:
if self.need_resize:
self.resize()
log.debug(' TAB Refresh: %s', self.__class__.__name__)
- if config.get('hide_user_list') or self.size.tab_degrade_x:
+ if config.getbool('hide_user_list') or self.size.tab_degrade_x:
display_user_list = False
else:
display_user_list = True
@@ -1273,10 +1440,10 @@ class MucTab(ChatTab):
self.info_win.refresh()
self.input.refresh()
- def on_info_win_size_changed(self):
+ def on_info_win_size_changed(self) -> None:
if self.core.information_win_size >= self.height - 3:
return
- if config.get("hide_user_list"):
+ if config.getbool("hide_user_list"):
text_width = self.width
else:
text_width = (self.width // 10) * 9
@@ -1289,7 +1456,7 @@ class MucTab(ChatTab):
Tab.tab_win_height(), 1, 1, 9 * (self.width // 10))
self.text_win.resize(
self.height - 3 - self.core.information_win_size -
- Tab.tab_win_height(), text_width, 1, 0)
+ Tab.tab_win_height(), text_width, 1, 0, self._text_buffer)
self.info_header.resize(
1, self.width, self.height - 2 - self.core.information_win_size -
Tab.tab_win_height(), 0)
@@ -1297,37 +1464,42 @@ class MucTab(ChatTab):
# This maxsize is kinda arbitrary, but most users won’t have that many
# nicknames anyway.
@functools.lru_cache(maxsize=8)
- def build_highlight_regex(self, nickname):
+ def build_highlight_regex(self, nickname: str) -> Pattern:
return re.compile(r"(^|\W)" + re.escape(nickname) + r"(\W|$)", re.I)
- def is_highlight(self, txt, time, nickname, own_nick, highlight_on,
- corrected=False):
+ def message_is_highlight(self, txt: str, nickname: Optional[str], history: bool,
+ corrected: bool = False) -> bool:
+ """Highlight algorithm for MUC tabs"""
+ # Don't highlight on info message or our own messages
+ if not nickname or nickname == self.own_nick:
+ return False
+ highlight_on = config.get_by_tabname(
+ 'highlight_on',
+ self.general_jid,
+ ).split(':')
highlighted = False
- if (not time or corrected) and nickname and nickname != own_nick:
- if self.build_highlight_regex(own_nick).search(txt):
+ if not history:
+ if self.build_highlight_regex(self.own_nick).search(txt):
highlighted = True
else:
- highlight_words = highlight_on.split(':')
- for word in highlight_words:
+ for word in highlight_on:
if word and word.lower() in txt.lower():
highlighted = True
break
return highlighted
- def do_highlight(self, txt, time, nickname, corrected=False):
- """
- Set the tab color and returns the nick color
- """
- own_nick = self.own_nick
- highlight_on = config.get_by_tabname('highlight_on', self.general_jid)
- highlighted = self.is_highlight(txt, time, nickname, own_nick,
- highlight_on, corrected)
- if highlighted and self.joined:
+ def do_highlight(self, txt: str, nickname: str, history: bool,
+ corrected: bool = False) -> bool:
+ """Set the tab color and returns the highlight state"""
+ highlighted = self.message_is_highlight(
+ txt, nickname, history, corrected
+ )
+ if highlighted and self.joined and not corrected:
if self.state != 'current':
self.state = 'highlight'
- beep_on = config.get('beep_on').split()
+ beep_on = config.getstr('beep_on').split()
if 'highlight' in beep_on and 'message' not in beep_on:
- if not config.get_by_tabname('disable_beep', self.name):
+ if not config.get_by_tabname('disable_beep', self.jid):
curses.beep()
return True
return False
@@ -1335,56 +1507,57 @@ class MucTab(ChatTab):
########################## COMMANDS ####################################
@command_args_parser.quoted(1, 1, [''])
- def command_invite(self, args):
+ async def command_invite(self, args: List[str]) -> None:
"""/invite <jid> [reason]"""
if args is None:
- return self.core.command.help('invite')
+ self.core.command.help('invite')
+ return
jid, reason = args
- self.core.command.invite('%s %s "%s"' % (jid, self.name, reason))
+ await self.core.command.invite('%s %s "%s"' % (jid, self.jid, reason))
@command_args_parser.quoted(1)
- def command_info(self, args):
+ def command_info(self, args: List[str]) -> None:
"""
/info <nick>
"""
if args is None:
- return self.core.command.help('info')
+ self.core.command.help('info')
+ return
nick = args[0]
if not self.print_info(nick):
self.core.information("Unknown user: %s" % nick, "Error")
@command_args_parser.quoted(0)
- def command_configure(self, ignored):
+ async def command_configure(self, ignored: Any) -> None:
"""
/configure
"""
- def on_form_received(form):
- if not form:
- self.core.information(
- 'Could not retrieve the configuration form', 'Error')
- return
+ try:
+ form = await self.core.xmpp.plugin['xep_0045'].get_room_config(
+ self.jid
+ )
self.core.open_new_form(form, self.cancel_config, self.send_config)
-
- fixes.get_room_form(self.core.xmpp, self.name, on_form_received)
+ except (IqError, IqTimeout, ValueError):
+ self.core.information(
+ 'Could not retrieve the configuration form', 'Error')
@command_args_parser.raw
- def command_cycle(self, msg):
+ def command_cycle(self, msg: str) -> None:
"""/cycle [reason]"""
self.leave_room(msg)
self.join()
- @command_args_parser.quoted(0, 1, [''])
- def command_recolor(self, args):
+ @command_args_parser.ignored
+ def command_recolor(self) -> None:
"""
/recolor [random]
Re-assigns color to the participants of the room
"""
- random_colors = args[0] == 'random'
- self.recolor(random_colors)
+ self.recolor()
@command_args_parser.quoted(2, 2, [''])
- def command_color(self, args):
+ def command_color(self, args: List[str]) -> None:
"""
/color <nick> <color>
Fix a color for a nick.
@@ -1392,52 +1565,71 @@ class MucTab(ChatTab):
User "random" to attribute a random color.
"""
if args is None:
- return self.core.command.help('color')
+ self.core.command.help('color')
+ return
nick = args[0]
color = args[1].lower()
if nick == self.own_nick:
- return self.core.information(
+ self.core.information(
"You cannot change the color of your"
- " own nick.", 'Error')
+ " own nick.", 'Error'
+ )
elif color not in xhtml.colors and color not in ('unset', 'random'):
- return self.core.information("Unknown color: %s" % color, 'Error')
- self.set_nick_color(nick, color)
+ self.core.information("Unknown color: %s" % color, 'Error')
+ else:
+ self.set_nick_color(nick, color)
@command_args_parser.quoted(1)
- def command_version(self, args):
+ async def command_version(self, args: List[str]) -> None:
"""
/version <jid or nick>
"""
if args is None:
- return self.core.command.help('version')
+ self.core.command.help('version')
+ return
nick = args[0]
- if nick in [user.nick for user in self.users]:
- jid = safeJID(self.name).bare
- jid = safeJID(jid + '/' + nick)
- else:
- jid = safeJID(nick)
- self.core.xmpp.plugin['xep_0092'].get_version(
- jid, callback=self.core.handler.on_version_result)
+ try:
+ if nick in {user.nick for user in self.users}:
+ jid = copy(self.jid)
+ jid.resource = nick
+ else:
+ jid = JID(nick)
+ except InvalidJID:
+ self.core.information('Invalid jid or nick %r' % nick, 'Error')
+ return
+ iq = await self.core.xmpp.plugin['xep_0092'].get_version(jid)
+ self.core.handler.on_version_result(iq)
@command_args_parser.quoted(1)
- def command_nick(self, args):
+ def command_nick(self, args: List[str]) -> None:
"""
/nick <nickname>
"""
if args is None:
- return self.core.command.help('nick')
+ self.core.command.help('nick')
+ return
nick = args[0]
if not self.joined:
- return self.core.information('/nick only works in joined rooms',
+ self.core.information('/nick only works in joined rooms',
'Info')
+ return
current_status = self.core.get_status()
- if not safeJID(self.name + '/' + nick):
- return self.core.information('Invalid nick', 'Info')
- muc.change_nick(self.core, self.name, nick, current_status.message,
- current_status.show)
+ try:
+ target_jid = copy(self.jid)
+ target_jid.resource = nick
+ except InvalidJID:
+ self.core.information('Invalid nick', 'Info')
+ return
+ muc.change_nick(
+ self.core,
+ self.jid,
+ nick,
+ current_status.message,
+ current_status.show,
+ )
@command_args_parser.quoted(0, 1, [''])
- def command_part(self, args):
+ def command_part(self, args: List[str]) -> None:
"""
/part [msg]
"""
@@ -1448,38 +1640,58 @@ class MucTab(ChatTab):
self.core.doupdate()
@command_args_parser.raw
- def command_close(self, msg):
+ def command_leave(self, msg: str) -> None:
+ """
+ /leave [msg]
+ """
+ self.command_close(msg)
+
+ @command_args_parser.raw
+ def command_close(self, msg: str) -> None:
"""
/close [msg]
"""
self.leave_room(msg)
+ if config.getbool('synchronise_open_rooms'):
+ if self.jid in self.core.bookmarks:
+ bookmark = self.core.bookmarks[self.jid]
+ if bookmark:
+ bookmark.autojoin = False
+ asyncio.create_task(
+ self.core.bookmarks.save(self.core.xmpp)
+ )
self.core.close_tab(self)
- def on_close(self):
+ def on_close(self) -> None:
super().on_close()
- self.leave_room('')
+ if self.joined:
+ self.leave_room('')
@command_args_parser.quoted(1, 1)
- def command_query(self, args):
+ def command_query(self, args: List[str]) -> None:
"""
/query <nick> [message]
"""
if args is None:
- return self.core.command.help('query')
+ self.core.command.help('query')
+ return
nick = args[0]
r = None
for user in self.users:
if user.nick == nick:
- r = self.core.open_private_window(self.name, user.nick)
+ r = self.core.open_private_window(self.jid.bare, user.nick)
if r and len(args) == 2:
msg = args[1]
- self.core.tabs.current_tab.command_say(
- xhtml.convert_simple_to_full_colors(msg))
+ asyncio.ensure_future(
+ r.command_say(
+ xhtml.convert_simple_to_full_colors(msg)
+ )
+ )
if not r:
self.core.information("Cannot find user: %s" % nick, 'Error')
@command_args_parser.raw
- def command_topic(self, subject):
+ def command_topic(self, subject: str) -> None:
"""
/topic [new topic]
"""
@@ -1489,30 +1701,31 @@ class MucTab(ChatTab):
self.change_topic(subject)
@command_args_parser.quoted(0)
- def command_names(self, args):
+ def command_names(self, args: Any) -> None:
"""
/names
"""
if not self.joined:
return
+ theme = get_theme()
aff = {
- 'owner': get_theme().CHAR_AFFILIATION_OWNER,
- 'admin': get_theme().CHAR_AFFILIATION_ADMIN,
- 'member': get_theme().CHAR_AFFILIATION_MEMBER,
- 'none': get_theme().CHAR_AFFILIATION_NONE,
+ 'owner': theme.CHAR_AFFILIATION_OWNER,
+ 'admin': theme.CHAR_AFFILIATION_ADMIN,
+ 'member': theme.CHAR_AFFILIATION_MEMBER,
+ 'none': theme.CHAR_AFFILIATION_NONE,
}
colors = {}
- colors["visitor"] = dump_tuple(get_theme().COLOR_USER_VISITOR)
- colors["moderator"] = dump_tuple(get_theme().COLOR_USER_MODERATOR)
- colors["participant"] = dump_tuple(get_theme().COLOR_USER_PARTICIPANT)
- color_other = dump_tuple(get_theme().COLOR_USER_NONE)
+ colors["visitor"] = dump_tuple(theme.COLOR_USER_VISITOR)
+ colors["moderator"] = dump_tuple(theme.COLOR_USER_MODERATOR)
+ colors["participant"] = dump_tuple(theme.COLOR_USER_PARTICIPANT)
+ color_other = dump_tuple(theme.COLOR_USER_NONE)
buff = ['Users: %s \n' % len(self.users)]
for user in self.users:
affiliation = aff.get(user.affiliation,
- get_theme().CHAR_AFFILIATION_NONE)
+ theme.CHAR_AFFILIATION_NONE)
color = colors.get(user.role, color_other)
buff.append(
'\x19%s}%s\x19o\x19%s}%s\x19o' %
@@ -1521,79 +1734,137 @@ class MucTab(ChatTab):
buff.append('\n')
message = ' '.join(buff)
- self._text_buffer.add_message(message)
+ self.add_message(InfoMessage(message))
self.text_win.refresh()
self.input.refresh()
@command_args_parser.quoted(1, 1)
- def command_kick(self, args):
+ async def command_kick(self, args: List[str]) -> None:
"""
/kick <nick> [reason]
"""
if args is None:
- return self.core.command.help('kick')
+ self.core.command.help('kick')
+ return
if len(args) == 2:
reason = args[1]
else:
reason = ''
nick = args[0]
- self.change_role(nick, 'none', reason)
+ await self.change_role(nick, 'none', reason)
@command_args_parser.quoted(1, 1)
- def command_ban(self, args):
+ async def command_ban(self, args: List[str]) -> None:
"""
/ban <nick> [reason]
"""
if args is None:
- return self.core.command.help('ban')
+ self.core.command.help('ban')
+ return
nick = args[0]
msg = args[1] if len(args) == 2 else ''
- self.change_affiliation(nick, 'outcast', msg)
+ await self.change_affiliation(nick, 'outcast', msg)
@command_args_parser.quoted(2, 1, [''])
- def command_role(self, args):
+ async def command_role(self, args: List[str]) -> None:
"""
/role <nick> <role> [reason]
- Changes the role of an user
+ Changes the role of a user
roles can be: none, visitor, participant, moderator
"""
-
- def callback(iq):
- if iq['type'] == 'error':
- self.core.room_error(iq, self.name)
-
if args is None:
- return self.core.command.help('role')
+ self.core.command.help('role')
+ return
nick, role, reason = args[0], args[1].lower(), args[2]
- self.change_role(nick, role, reason)
+ try:
+ await self.change_role(nick, role, reason)
+ except IqError as iq:
+ self.core.room_error(iq, self.jid.bare)
- @command_args_parser.quoted(2)
- def command_affiliation(self, args):
+ @command_args_parser.quoted(0, 2)
+ async def command_affiliation(self, args: List[str]) -> None:
"""
- /affiliation <nick> <role>
- Changes the affiliation of an user
+ /affiliation [<nick or jid> <affiliation>]
+ Changes the affiliation of a user
affiliations can be: outcast, none, member, admin, owner
"""
- def callback(iq):
- if iq['type'] == 'error':
- self.core.room_error(iq, self.name)
+ room = JID(self.name)
+ if not room:
+ self.core.information('affiliation: requires a valid chat address', 'Error')
+ return
- if args is None:
- return self.core.command.help('affiliation')
+ # List affiliations
+ if not args:
+ await self.get_users_affiliations(room)
+ return None
+
+ if len(args) != 2:
+ self.core.command.help('affiliation')
+ return
nick, affiliation = args[0], args[1].lower()
- self.change_affiliation(nick, affiliation)
+ # Set affiliation
+ await self.change_affiliation(nick, affiliation)
+
+ async def get_users_affiliations(self, jid: JID) -> None:
+ owners, admins, members, outcasts = await asyncio.gather(
+ self.core.xmpp['xep_0045'].get_affiliation_list(jid, 'owner'),
+ self.core.xmpp['xep_0045'].get_affiliation_list(jid, 'admin'),
+ self.core.xmpp['xep_0045'].get_affiliation_list(jid, 'member'),
+ self.core.xmpp['xep_0045'].get_affiliation_list(jid, 'outcast'),
+ return_exceptions=True,
+ )
+
+ all_errors = functools.reduce(
+ lambda acc, iq: acc and isinstance(iq, (IqError, IqTimeout)),
+ (owners, admins, members, outcasts),
+ True,
+ )
+ if all_errors:
+ self.core.information(
+ 'Can’t access affiliations for %s' % jid.bare,
+ 'Error',
+ )
+ return None
+
+ theme = get_theme()
+ aff_colors = {
+ 'owner': theme.CHAR_AFFILIATION_OWNER,
+ 'admin': theme.CHAR_AFFILIATION_ADMIN,
+ 'member': theme.CHAR_AFFILIATION_MEMBER,
+ 'outcast': theme.CHAR_AFFILIATION_OUTCAST,
+ }
+
+
+
+ lines = ['Affiliations for %s' % jid.bare]
+ affiliation_dict = {
+ 'owner': owners,
+ 'admin': admins,
+ 'member': members,
+ 'outcast': outcasts,
+ }
+ for affiliation, items in affiliation_dict.items():
+ if isinstance(items, BaseException) or not items:
+ continue
+ aff_char = aff_colors[affiliation]
+ lines.append(' %s%s' % (aff_char, affiliation.capitalize()))
+ for ajid in sorted(items):
+ lines.append(' %s' % ajid)
+
+ self.core.information('\n'.join(lines), 'Info')
+ return None
@command_args_parser.raw
- def command_say(self, line, correct=False):
+ async def command_say(self, line: str, attention: bool = False, correct: bool = False):
"""
/say <message>
Or normal input + enter
"""
- needed = 'inactive' if self.inactive else 'active'
- msg = self.core.xmpp.make_message(self.name)
+ chatstate = 'inactive' if self.inactive else 'active'
+ msg: SMessage = self.core.xmpp.make_message(self.jid)
msg['type'] = 'groupchat'
msg['body'] = line
# trigger the event BEFORE looking for colors.
@@ -1610,9 +1881,12 @@ class MucTab(ChatTab):
msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body'])
msg['body'] = xhtml.clean_text(msg['body'])
if config.get_by_tabname('send_chat_states', self.general_jid):
- msg['chat_state'] = needed
+ if chatstate == 'inactive':
+ self.send_chat_state(chatstate, always_send=True)
+ else:
+ msg['chat_state'] = chatstate
if correct:
- msg['replace']['id'] = self.last_sent_message['id']
+ msg['replace']['id'] = self.last_sent_message['id'] # type: ignore
self.cancel_paused_delay()
self.core.events.trigger('muc_say_after', msg, self)
if not msg['body']:
@@ -1620,24 +1894,26 @@ class MucTab(ChatTab):
self.text_win.refresh()
self.input.refresh()
return
- self.last_sent_message = msg
+ # TODO: #3314. Display outgoing MUC message.
+ self.set_last_sent_message(msg, correct=correct)
msg.send()
- self.chat_state = needed
+ self.chat_state = chatstate
@command_args_parser.raw
- def command_xhtml(self, msg):
+ def command_xhtml(self, msg: str) -> None:
message = self.generate_xhtml_message(msg)
if message:
message['type'] = 'groupchat'
message.send()
@command_args_parser.quoted(1)
- def command_ignore(self, args):
+ def command_ignore(self, args: List[str]) -> None:
"""
/ignore <nick>
"""
if args is None:
- return self.core.command.help('ignore')
+ self.core.command.help('ignore')
+ return
nick = args[0]
user = self.get_user_by_name(nick)
@@ -1650,12 +1926,13 @@ class MucTab(ChatTab):
self.core.information("%s is now ignored" % nick, 'info')
@command_args_parser.quoted(1)
- def command_unignore(self, args):
+ def command_unignore(self, args: List[str]) -> None:
"""
/unignore <nick>
"""
if args is None:
- return self.core.command.help('unignore')
+ self.core.command.help('unignore')
+ return
nick = args[0]
user = self.get_user_by_name(nick)
@@ -1667,9 +1944,33 @@ class MucTab(ChatTab):
self.ignores.remove(user)
self.core.information('%s is now unignored' % nick)
+ @command_args_parser.quoted(0, 1)
+ def command_request_voice(self, args: List[str]) -> None:
+ """
+ /request_voice [role]
+ Request voice in a moderated room
+ role can be: participant, moderator
+ """
+
+ room = JID(self.name)
+ if not room:
+ self.core.information('request_voice: requires a valid chat address', 'Error')
+ return
+
+ if len(args) > 1:
+ self.core.command.help('request_voice')
+ return
+
+ if args:
+ role = args[0]
+ else:
+ role = 'participant'
+
+ self.core.xmpp['xep_0045'].request_voice(room, role)
+
########################## COMPLETIONS #################################
- def completion(self):
+ def completion(self) -> None:
"""
Called when Tab is pressed, complete the nickname in the input
"""
@@ -1682,14 +1983,15 @@ class MucTab(ChatTab):
for user in sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True):
if user.nick != self.own_nick:
word_list.append(user.nick)
- after = config.get('after_completion') + ' '
+ after = config.getstr('after_completion') + ' '
input_pos = self.input.pos
- if ' ' not in self.input.get_text()[:input_pos] or (
+ text_before = self.input.get_text()[:input_pos]
+ if (' ' not in text_before and '\n' not in text_before) or (
self.input.last_completion and self.input.get_text()
[:input_pos] == self.input.last_completion + after):
add_after = after
else:
- if not config.get('add_space_after_completion'):
+ if not config.getbool('add_space_after_completion'):
add_after = ''
else:
add_after = ' '
@@ -1700,7 +2002,7 @@ class MucTab(ChatTab):
and not self.input.get_text().startswith('//'))
self.send_composing_chat_state(empty_after)
- def completion_version(self, the_input):
+ def completion_version(self, the_input: windows.MessageInput) -> Completion:
"""Completion for /version"""
userlist = []
for user in sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True):
@@ -1715,30 +2017,30 @@ class MucTab(ChatTab):
return Completion(the_input.auto_completion, userlist, quotify=False)
- def completion_info(self, the_input):
+ def completion_info(self, the_input: windows.MessageInput) -> Completion:
"""Completion for /info"""
userlist = []
for user in sorted(self.users, key=COMPARE_USERS_LAST_TALKED, reverse=True):
userlist.append(user.nick)
return Completion(the_input.auto_completion, userlist, quotify=False)
- def completion_nick(self, the_input):
+ def completion_nick(self, the_input: windows.MessageInput) -> Completion:
"""Completion for /nick"""
- nicks = [
+ nicks_list = [
os.environ.get('USER'),
- config.get('default_nick'),
- self.core.get_bookmark_nickname(self.name)
+ config.getstr('default_nick'),
+ self.core.get_bookmark_nickname(self.jid.bare)
]
- nicks = [i for i in nicks if i]
+ nicks = [i for i in nicks_list if i]
return Completion(the_input.auto_completion, nicks, '', quotify=False)
- def completion_recolor(self, the_input):
+ def completion_recolor(self, the_input: windows.MessageInput) -> Optional[Completion]:
if the_input.get_argument_position() == 1:
return Completion(
the_input.new_completion, ['random'], 1, '', quotify=False)
- return True
+ return None
- def completion_color(self, the_input):
+ def completion_color(self, the_input: windows.MessageInput) -> Optional[Completion]:
"""Completion for /color"""
n = the_input.get_argument_position(quoted=True)
if n == 1:
@@ -1754,8 +2056,9 @@ class MucTab(ChatTab):
colors.append('random')
return Completion(
the_input.new_completion, colors, 2, '', quotify=False)
+ return None
- def completion_ignore(self, the_input):
+ def completion_ignore(self, the_input: windows.MessageInput) -> Completion:
"""Completion for /ignore"""
userlist = [user.nick for user in self.users]
if self.own_nick in userlist:
@@ -1763,7 +2066,7 @@ class MucTab(ChatTab):
userlist.sort()
return Completion(the_input.auto_completion, userlist, quotify=False)
- def completion_role(self, the_input):
+ def completion_role(self, the_input: windows.MessageInput) -> Optional[Completion]:
"""Completion for /role"""
n = the_input.get_argument_position(quoted=True)
if n == 1:
@@ -1776,8 +2079,9 @@ class MucTab(ChatTab):
possible_roles = ['none', 'visitor', 'participant', 'moderator']
return Completion(
the_input.new_completion, possible_roles, 2, '', quotify=True)
+ return None
- def completion_affiliation(self, the_input):
+ def completion_affiliation(self, the_input: windows.MessageInput) -> Optional[Completion]:
"""Completion for /affiliation"""
n = the_input.get_argument_position(quoted=True)
if n == 1:
@@ -1800,20 +2104,26 @@ class MucTab(ChatTab):
2,
'',
quotify=True)
+ return None
- def completion_invite(self, the_input):
+ def completion_invite(self, the_input: windows.MessageInput) -> Optional[Completion]:
"""Completion for /invite"""
n = the_input.get_argument_position(quoted=True)
if n == 1:
return Completion(
- the_input.new_completion, roster.jids(), 1, quotify=True)
+ the_input.new_completion,
+ [str(i) for i in roster.jids()],
+ argument_position=1,
+ quotify=True)
+ return None
- def completion_topic(self, the_input):
+ def completion_topic(self, the_input: windows.MessageInput) -> Optional[Completion]:
if the_input.get_argument_position() == 1:
return Completion(
the_input.auto_completion, [self.topic], '', quotify=False)
+ return None
- def completion_quoted(self, the_input):
+ def completion_quoted(self, the_input: windows.MessageInput) -> Optional[Completion]:
"""Nick completion, but with quotes"""
if the_input.get_argument_position(quoted=True) == 1:
word_list = []
@@ -1823,16 +2133,23 @@ class MucTab(ChatTab):
return Completion(
the_input.new_completion, word_list, 1, quotify=True)
+ return None
- def completion_unignore(self, the_input):
+ def completion_unignore(self, the_input: windows.MessageInput) -> Optional[Completion]:
if the_input.get_argument_position() == 1:
users = [user.nick for user in self.ignores]
return Completion(the_input.auto_completion, users, quotify=False)
+ return None
+
+ def completion_request_voice(self, the_input: windows.MessageInput) -> Optional[Completion]:
+ """Completion for /request_voice"""
+ allowed = ['participant', 'moderator']
+ return Completion(the_input.auto_completion, allowed, quotify=False)
########################## REGISTER STUFF ##############################
- def register_keys(self):
+ def register_keys(self) -> None:
"Register tab-specific keys"
self.key_func['^I'] = self.completion
self.key_func['M-u'] = self.scroll_user_list_down
@@ -1840,7 +2157,7 @@ class MucTab(ChatTab):
self.key_func['M-n'] = self.go_to_next_hl
self.key_func['M-p'] = self.go_to_prev_hl
- def register_commands(self):
+ def register_commands(self) -> None:
"Register tab-specific commands"
self.register_commands_batch([{
'name': 'ignore',
@@ -1895,11 +2212,11 @@ class MucTab(ChatTab):
self.command_role,
'usage':
'<nick> <role> [reason]',
- 'desc': ('Set the role of an user. Roles can be:'
+ 'desc': ('Set the role of a user. Roles can be:'
' none, visitor, participant, moderator.'
' You also can give an optional reason.'),
'shortdesc':
- 'Set the role of an user.',
+ 'Set the role of a user.',
'completion':
self.completion_role
}, {
@@ -1908,11 +2225,11 @@ class MucTab(ChatTab):
'func':
self.command_affiliation,
'usage':
- '<nick or jid> <affiliation>',
- 'desc': ('Set the affiliation of an user. Affiliations can be:'
+ '[<nick or jid> [<affiliation>]]',
+ 'desc': ('Set the affiliation of a user. Affiliations can be:'
' outcast, none, member, admin, owner.'),
'shortdesc':
- 'Set the affiliation of an user.',
+ 'Set the affiliation of a user.',
'completion':
self.completion_affiliation
}, {
@@ -1968,15 +2285,23 @@ class MucTab(ChatTab):
'shortdesc':
'Leave the room.'
}, {
+ 'name': 'leave',
+ 'func': self.command_leave,
+ 'usage': '[message]',
+ 'desc': 'Deprecated alias for /close',
+ 'shortdesc': 'Leave the room.'
+ }, {
'name':
'close',
'func':
self.command_close,
'usage':
'[message]',
- 'desc': ('Disconnect from a room and close the tab.'
- ' You can specify an optional message if '
- 'you are still connected.'),
+ 'desc': ('Disconnect from a room and close the tab. '
+ 'You can specify an optional message if '
+ 'you are still connected. If synchronise_open_tabs '
+ 'is true, also disconnect you from your other '
+ 'clients.'),
'shortdesc':
'Close the tab.'
}, {
@@ -1998,12 +2323,11 @@ class MucTab(ChatTab):
'func':
self.command_recolor,
'usage':
- '[random]',
- 'desc': ('Re-assign a color to all participants of the'
- ' current room, based on the last time they talked.'
- ' Use this if the participants currently talking '
- 'have too many identical colors. Use /recolor random'
- ' for a non-deterministic result.'),
+ '',
+ 'desc': (
+ 'Re-assign a color to all participants of the room '
+ 'if the theme has changed.'
+ ),
'shortdesc':
'Change the nicks colors.',
'completion':
@@ -2020,7 +2344,7 @@ class MucTab(ChatTab):
'shortdesc':
'Fix a color for a nick.',
'completion':
- self.completion_recolor
+ self.completion_color
}, {
'name':
'cycle',
@@ -2040,10 +2364,10 @@ class MucTab(ChatTab):
'usage':
'<nickname>',
'desc': ('Display some information about the user '
- 'in the MUC: its/his/her role, affiliation,'
+ 'in the MUC: their role, affiliation,'
' status and status message.'),
'shortdesc':
- 'Show an user\'s infos.',
+ 'Show a user\'s infos.',
'completion':
self.completion_info
}, {
@@ -2091,6 +2415,19 @@ class MucTab(ChatTab):
'Invite a contact to this room',
'completion':
self.completion_invite
+ }, {
+ 'name':
+ 'request_voice',
+ 'func':
+ self.command_request_voice,
+ 'desc':
+ 'Request voice when we are a visitor in a moderated room',
+ 'usage':
+ '[role]',
+ 'shortdesc':
+ 'Request voice in a moderated room',
+ 'completion':
+ self.completion_request_voice
}])
@@ -2098,7 +2435,7 @@ class PresenceError(Exception):
pass
-def dissect_presence(presence):
+def dissect_presence(presence: Presence) -> Tuple[str, str, str, str, str, str, JID, str]:
"""
Extract relevant information from a presence
"""
diff --git a/poezio/tabs/privatetab.py b/poezio/tabs/privatetab.py
index 8f5f4d6f..1909e3c1 100644
--- a/poezio/tabs/privatetab.py
+++ b/poezio/tabs/privatetab.py
@@ -10,40 +10,46 @@ both participant’s nicks. It also has slightly different features than
the ConversationTab (such as tab-completion on nicks from the room).
"""
+import asyncio
import curses
import logging
+from datetime import datetime
from typing import Dict, Callable
+from slixmpp import JID
+from slixmpp.stanza import Message as SMessage
+
from poezio.tabs import OneToOneTab, MucTab, Tab
+from poezio import common
from poezio import windows
from poezio import xhtml
-from poezio.common import safeJID
-from poezio.config import config
+from poezio.config import config, get_image_cache
from poezio.core.structs import Command
from poezio.decorators import refresh_wrapper
-from poezio.logger import logger
from poezio.theming import get_theme, dump_tuple
from poezio.decorators import command_args_parser
+from poezio.text_buffer import CorrectionError
+from poezio.ui.types import (
+ Message,
+ PersistentInfoMessage,
+)
log = logging.getLogger(__name__)
class PrivateTab(OneToOneTab):
"""
- The tab containg a private conversation (someone from a MUC)
+ The tab containing a private conversation (someone from a MUC)
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
message_type = 'chat'
- additional_information = {} # type: Dict[str, Callable[[str], str]]
+ additional_information: Dict[str, Callable[[str], str]] = {}
- def __init__(self, core, name, nick):
- OneToOneTab.__init__(self, core, name)
+ def __init__(self, core, jid, nick, initial=None):
+ OneToOneTab.__init__(self, core, jid, initial)
self.own_nick = nick
- self.name = name
- self.text_win = windows.TextWin()
- self._text_buffer.add_window(self.text_win)
self.info_header = windows.PrivateInfoWin()
self.input = windows.MessageInput()
# keys
@@ -53,7 +59,7 @@ class PrivateTab(OneToOneTab):
'info',
self.command_info,
desc=
- 'Display some information about the user in the MUC: its/his/her role, affiliation, status and status message.',
+ 'Display some information about the user in the MUC: their role, affiliation, status and status message.',
shortdesc='Info about the user.')
self.register_command(
'version',
@@ -62,30 +68,41 @@ class PrivateTab(OneToOneTab):
'Get the software version of the current interlocutor (usually its XMPP client and Operating System).',
shortdesc='Get the software version of a jid.')
self.resize()
- self.parent_muc = self.core.tabs.by_name_and_class(
- safeJID(name).bare, MucTab)
+ self.parent_muc = self.core.tabs.by_name_and_class(self.jid.bare, MucTab)
self.on = True
self.update_commands()
self.update_keys()
+ @property
+ def log_name(self) -> str:
+ """Overriden from ChatTab because this is a case where we want the full JID"""
+ return self.jid.full
+
def remote_user_color(self):
- user = self.parent_muc.get_user_by_name(safeJID(self.name).resource)
+ user = self.parent_muc.get_user_by_name(self.jid.resource)
if user:
return dump_tuple(user.color)
return super().remote_user_color()
@property
- def general_jid(self):
- return self.name
+ def general_jid(self) -> JID:
+ return self.jid
- def get_dest_jid(self):
- return self.name
+ def get_dest_jid(self) -> JID:
+ return self.jid
@property
- def nick(self):
+ def nick(self) -> str:
return self.get_nick()
+ def ack_message(self, msg_id: str, msg_jid: JID):
+ if JID(msg_jid).bare == self.core.xmpp.boundjid.bare:
+ msg_jid = JID(self.jid.bare)
+ msg_jid.resource = self.own_nick
+ super().ack_message(msg_id, msg_jid)
+
@staticmethod
+ @refresh_wrapper.always
def add_information_element(plugin_name, callback):
"""
Lets a plugin add its own information to the PrivateInfoWin
@@ -93,22 +110,10 @@ class PrivateTab(OneToOneTab):
PrivateTab.additional_information[plugin_name] = callback
@staticmethod
+ @refresh_wrapper.always
def remove_information_element(plugin_name):
del PrivateTab.additional_information[plugin_name]
- def load_logs(self, log_nb):
- logs = logger.get_logs(
- safeJID(self.name).full.replace('/', '\\'), log_nb)
- return logs
-
- def log_message(self, txt, nickname, time=None, typ=1):
- """
- Log the messages in the archives.
- """
- if not logger.log_message(
- self.name, nickname, txt, date=time, typ=typ):
- self.core.information('Unable to write in the log file', 'Error')
-
def on_close(self):
super().on_close()
self.parent_muc.privates.remove(self)
@@ -124,7 +129,7 @@ class PrivateTab(OneToOneTab):
compare_users = lambda x: x.last_talked
word_list = [user.nick for user in sorted(self.parent_muc.users, key=compare_users, reverse=True)\
if user.nick != self.own_nick]
- after = config.get('after_completion') + ' '
+ after = config.getstr('after_completion') + ' '
input_pos = self.input.pos
if ' ' not in self.input.get_text()[:input_pos] or (self.input.last_completion and\
self.input.get_text()[:input_pos] == self.input.last_completion + after):
@@ -137,38 +142,87 @@ class PrivateTab(OneToOneTab):
and not self.input.get_text().startswith('//'))
self.send_composing_chat_state(empty_after)
+ async def handle_message(self, message: SMessage, display: bool = True):
+ sent = message['from'].bare == self.core.xmpp.boundjid.bare
+ jid = message['to'] if sent else message['from']
+ with_nick = jid.resource
+ sender_nick = with_nick
+ if sent:
+ sender_nick = (self.own_nick or self.core.own_nick)
+ room_from = jid.bare
+ use_xhtml = config.get_by_tabname(
+ 'enable_xhtml_im',
+ jid.bare
+ )
+ tmp_dir = get_image_cache()
+ if not sent:
+ await self.core.events.trigger_async('private_msg', message, self)
+ body = xhtml.get_body_from_message_stanza(
+ message, use_xhtml=use_xhtml, extract_images_to=tmp_dir)
+ if not body or not self:
+ return
+ delayed, date = common.find_delayed_tag(message)
+ replaced = False
+ user = self.parent_muc.get_user_by_name(with_nick)
+ if message.get_plugin('replace', check=True):
+ replaced_id = message['replace']['id']
+ if replaced_id != '' and config.get_by_tabname(
+ 'group_corrections', room_from):
+ try:
+ self.modify_message(
+ body,
+ replaced_id,
+ message['id'],
+ user=user,
+ time=date,
+ jid=message['from'],
+ nickname=sender_nick)
+ replaced = True
+ except CorrectionError:
+ log.debug('Unable to correct a message', exc_info=True)
+ if not replaced:
+ msg = Message(
+ txt=body,
+ time=date,
+ history=delayed,
+ nickname=sender_nick,
+ nick_color=get_theme().COLOR_OWN_NICK if sent else None,
+ user=user,
+ identifier=message['id'],
+ jid=message['from'],
+ )
+ if display:
+ self.add_message(msg)
+ else:
+ self.log_message(msg)
+ if sent:
+ self.set_last_sent_message(message, correct=replaced)
+ else:
+ self.last_remote_message = datetime.now()
+
+ @refresh_wrapper.always
@command_args_parser.raw
- def command_say(self, line, attention=False, correct=False):
+ async def command_say(self, line: str, attention: bool = False, correct: bool = False) -> None:
if not self.on:
return
- msg = self.core.xmpp.make_message(self.name)
+ await self._initial_log.wait()
+ our_jid = JID(self.jid.bare)
+ our_jid.resource = self.own_nick
+ msg: SMessage = self.core.xmpp.make_message(
+ mto=self.jid.full,
+ mfrom=our_jid,
+ )
msg['type'] = 'chat'
msg['body'] = line
+ msg.enable('muc')
# trigger the event BEFORE looking for colors.
# This lets a plugin insert \x19xxx} colors, that will
# be converted in xhtml.
self.core.events.trigger('private_say', msg, self)
if not msg['body']:
- self.cancel_paused_delay()
- self.text_win.refresh()
- self.input.refresh()
return
- user = self.parent_muc.get_user_by_name(self.own_nick)
- replaced = False
- if correct or msg['replace']['id']:
- msg['replace']['id'] = self.last_sent_message['id']
- if config.get_by_tabname('group_corrections', self.name):
- try:
- self.modify_message(
- msg['body'],
- self.last_sent_message['id'],
- msg['id'],
- user=user,
- jid=self.core.xmpp.boundjid,
- nickname=self.own_nick)
- replaced = True
- except:
- log.error('Unable to correct a message', exc_info=True)
+ if correct or msg['replace']['id'] and self.last_sent_message:
+ msg['replace']['id'] = self.last_sent_message['id'] # type: ignore
else:
del msg['replace']
@@ -177,43 +231,32 @@ class PrivateTab(OneToOneTab):
msg['html']['body'] = xhtml.poezio_colors_to_html(msg['body'])
msg['body'] = xhtml.clean_text(msg['body'])
if config.get_by_tabname('send_chat_states', self.general_jid):
- needed = 'inactive' if self.inactive else 'active'
- msg['chat_state'] = needed
+ if self.inactive:
+ self.send_chat_state('inactive', always_send=True)
+ else:
+ msg['chat_state'] = 'active'
if attention:
msg['attention'] = True
self.core.events.trigger('private_say_after', msg, self)
if not msg['body']:
- self.cancel_paused_delay()
- self.text_win.refresh()
- self.input.refresh()
return
- if not replaced:
- self.add_message(
- msg['body'],
- nickname=self.own_nick or self.core.own_nick,
- forced_user=user,
- nick_color=get_theme().COLOR_OWN_NICK,
- identifier=msg['id'],
- jid=self.core.xmpp.boundjid,
- typ=1)
-
- self.last_sent_message = msg
- msg._add_receipt = True
+ self.set_last_sent_message(msg, correct=correct)
+ await self.core.handler.on_groupchat_private_message(msg, sent=True)
+ # Our receipts slixmpp hack
+ msg._add_receipt = True # type: ignore
msg.send()
self.cancel_paused_delay()
- self.text_win.refresh()
- self.input.refresh()
@command_args_parser.quoted(0, 1)
- def command_version(self, args):
+ async def command_version(self, args):
"""
/version
"""
if args:
- return self.core.command.version(args[0])
- jid = safeJID(self.name)
- self.core.xmpp.plugin['xep_0092'].get_version(
- jid, callback=self.core.handler.on_version_result)
+ return await self.core.command.version(args[0])
+ jid = self.jid.full
+ iq = await self.core.xmpp.plugin['xep_0092'].get_version(jid)
+ self.core.handler.on_version_result(iq)
@command_args_parser.quoted(0, 1)
def command_info(self, arg):
@@ -223,7 +266,7 @@ class PrivateTab(OneToOneTab):
if arg and arg[0]:
self.parent_muc.command_info(arg[0])
else:
- user = safeJID(self.name).resource
+ user = self.jid.resource
self.parent_muc.command_info(user)
def resize(self):
@@ -238,8 +281,8 @@ class PrivateTab(OneToOneTab):
self.text_win.resize(
self.height - 2 - info_win_height - tab_win_height, self.width, 0,
- 0)
- self.text_win.rebuild_everything(self._text_buffer)
+ 0, self._text_buffer, force=self.ui_config_changed)
+ self.ui_config_changed = False
self.info_header.resize(
1, self.width, self.height - 2 - info_win_height - tab_win_height,
0)
@@ -252,7 +295,7 @@ class PrivateTab(OneToOneTab):
display_info_win = not self.size.tab_degrade_y
self.text_win.refresh()
- self.info_header.refresh(self.name, self.text_win, self.chatstate,
+ self.info_header.refresh(self.jid.full, self.text_win, self.chatstate,
PrivateTab.additional_information)
if display_info_win:
self.info_win.refresh()
@@ -261,12 +304,12 @@ class PrivateTab(OneToOneTab):
self.input.refresh()
def refresh_info_header(self):
- self.info_header.refresh(self.name, self.text_win, self.chatstate,
+ self.info_header.refresh(self.jid.full, self.text_win, self.chatstate,
PrivateTab.additional_information)
self.input.refresh()
def get_nick(self):
- return safeJID(self.name).resource
+ return self.jid.resource
def on_input(self, key, raw):
if not raw and key in self.key_func:
@@ -278,7 +321,7 @@ class PrivateTab(OneToOneTab):
empty_after = self.input.get_text() == '' or (
self.input.get_text().startswith('/')
and not self.input.get_text().startswith('//'))
- tab = self.core.tabs.by_name_and_class(safeJID(self.name).bare, MucTab)
+ tab = self.core.tabs.by_name_and_class(self.jid.bare, MucTab)
if tab and tab.joined:
self.send_composing_chat_state(empty_after)
return False
@@ -291,7 +334,7 @@ class PrivateTab(OneToOneTab):
self.text_win.remove_line_separator()
self.text_win.add_line_separator(self._text_buffer)
- tab = self.core.tabs.by_name_and_class(safeJID(self.name).bare, MucTab)
+ tab = self.core.tabs.by_name_and_class(self.jid.bare, MucTab)
if tab and tab.joined and config.get_by_tabname(
'send_chat_states', self.general_jid) and self.on:
self.send_chat_state('inactive')
@@ -300,7 +343,7 @@ class PrivateTab(OneToOneTab):
def on_gain_focus(self):
self.state = 'current'
curses.curs_set(1)
- tab = self.core.tabs.by_name_and_class(safeJID(self.name).bare, MucTab)
+ tab = self.core.tabs.by_name_and_class(self.jid.bare, MucTab)
if tab and tab.joined and config.get_by_tabname(
'send_chat_states',
self.general_jid,
@@ -317,9 +360,6 @@ class PrivateTab(OneToOneTab):
1, self.width, self.height - 2 - self.core.information_win_size -
Tab.tab_win_height(), 0)
- def get_text_window(self):
- return self.text_win
-
@refresh_wrapper.conditional
def rename_user(self, old_nick, user):
"""
@@ -327,16 +367,18 @@ class PrivateTab(OneToOneTab):
display a message.
"""
self.add_message(
- '\x19%(nick_col)s}%(old)s\x19%(info_col)s} is now '
- 'known as \x19%(nick_col)s}%(new)s' % {
- 'old': old_nick,
- 'new': user.nick,
- 'nick_col': dump_tuple(user.color),
- 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
- new_jid = safeJID(self.name).bare + '/' + user.nick
- self.name = new_jid
+ PersistentInfoMessage(
+ '\x19%(nick_col)s}%(old)s\x19%(info_col)s} is now '
+ 'known as \x19%(nick_col)s}%(new)s' % {
+ 'old': old_nick,
+ 'new': user.nick,
+ 'nick_col': dump_tuple(user.color),
+ 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
+ },
+ ),
+ )
+ new_jid = self.jid.bare + '/' + user.nick
+ self._name = new_jid
return self.core.tabs.current_tab is self
@refresh_wrapper.conditional
@@ -345,36 +387,41 @@ class PrivateTab(OneToOneTab):
The user left the associated MUC
"""
self.deactivate()
+ theme = get_theme()
if config.get_by_tabname('display_user_color_in_join_part',
self.general_jid):
color = dump_tuple(user.color)
else:
- color = dump_tuple(get_theme().COLOR_REMOTE_USER)
+ color = dump_tuple(theme.COLOR_REMOTE_USER)
if not status_message:
self.add_message(
- '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}'
- '%(nick)s\x19%(info_col)s} has left the room' % {
- 'nick': user.nick,
- 'spec': get_theme().CHAR_QUIT,
- 'nick_col': color,
- 'quit_col': dump_tuple(get_theme().COLOR_QUIT_CHAR),
- 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}'
+ '%(nick)s\x19%(info_col)s} has left the room' % {
+ 'nick': user.nick,
+ 'spec': theme.CHAR_QUIT,
+ 'nick_col': color,
+ 'quit_col': dump_tuple(theme.COLOR_QUIT_CHAR),
+ 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ },
+ ),
+ )
else:
self.add_message(
- '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}'
- '%(nick)s\x19%(info_col)s} has left the room'
- ' (%(status)s)' % {
- 'status': status_message,
- 'nick': user.nick,
- 'spec': get_theme().CHAR_QUIT,
- 'nick_col': color,
- 'quit_col': dump_tuple(get_theme().COLOR_QUIT_CHAR),
- 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ '\x19%(quit_col)s}%(spec)s \x19%(nick_col)s}'
+ '%(nick)s\x19%(info_col)s} has left the room'
+ ' (%(status)s)' % {
+ 'status': status_message,
+ 'nick': user.nick,
+ 'spec': theme.CHAR_QUIT,
+ 'nick_col': color,
+ 'quit_col': dump_tuple(theme.COLOR_QUIT_CHAR),
+ 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ },
+ ),
+ )
return self.core.tabs.current_tab is self
@refresh_wrapper.conditional
@@ -383,46 +430,51 @@ class PrivateTab(OneToOneTab):
The user (or at least someone with the same nick) came back in the MUC
"""
self.activate()
- self.check_features()
tab = self.parent_muc
- color = dump_tuple(get_theme().COLOR_REMOTE_USER)
+ theme = get_theme()
+ color = dump_tuple(theme.COLOR_REMOTE_USER)
if tab and config.get_by_tabname('display_user_color_in_join_part',
self.general_jid):
user = tab.get_user_by_name(nick)
if user:
color = dump_tuple(user.color)
self.add_message(
- '\x19%(join_col)s}%(spec)s \x19%(color)s}%(nick)s\x19'
- '%(info_col)s} joined the room' % {
- 'nick': nick,
- 'color': color,
- 'spec': get_theme().CHAR_JOIN,
- 'join_col': dump_tuple(get_theme().COLOR_JOIN_CHAR),
- 'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT)
- },
- typ=2)
+ PersistentInfoMessage(
+ '\x19%(join_col)s}%(spec)s \x19%(color)s}%(nick)s\x19'
+ '%(info_col)s} joined the room' % {
+ 'nick': nick,
+ 'color': color,
+ 'spec': theme.CHAR_JOIN,
+ 'join_col': dump_tuple(theme.COLOR_JOIN_CHAR),
+ 'info_col': dump_tuple(theme.COLOR_INFORMATION_TEXT)
+ },
+ ),
+ )
return self.core.tabs.current_tab is self
def activate(self, reason=None):
self.on = True
if reason:
- self.add_message(txt=reason, typ=2)
+ self.add_message(PersistentInfoMessage(reason))
def deactivate(self, reason=None):
self.on = False
if reason:
- self.add_message(txt=reason, typ=2)
+ self.add_message(PersistentInfoMessage(reason))
def matching_names(self):
- return [(3, safeJID(self.name).resource), (4, self.name)]
+ return [(3, self.jid.resource), (4, self.name)]
def add_error(self, error_message):
- error = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_CHAR_NACK),
+ theme = get_theme()
+ error = '\x19%s}%s\x19o' % (dump_tuple(theme.COLOR_CHAR_NACK),
error_message)
self.add_message(
- error,
- highlight=True,
- nickname='Error',
- nick_color=get_theme().COLOR_ERROR_MSG,
- typ=2)
+ Message(
+ error,
+ highlight=True,
+ nickname='Error',
+ nick_color=theme.COLOR_ERROR_MSG,
+ ),
+ )
self.core.refresh_window()
diff --git a/poezio/tabs/rostertab.py b/poezio/tabs/rostertab.py
index 9f609f61..18334c20 100644
--- a/poezio/tabs/rostertab.py
+++ b/poezio/tabs/rostertab.py
@@ -14,33 +14,36 @@ import ssl
from functools import partial
from os import getenv, path
from pathlib import Path
-from typing import Dict, Callable
+from typing import Dict, Callable, Union
+
+from slixmpp import JID, InvalidJID
+from slixmpp.exceptions import IqError, IqTimeout
-from poezio import common
from poezio import windows
-from poezio.common import safeJID, shell_split
+from poezio.common import shell_split
from poezio.config import config
from poezio.contact import Contact, Resource
from poezio.decorators import refresh_wrapper
from poezio.roster import RosterGroup, roster
from poezio.theming import get_theme, dump_tuple
-from poezio.decorators import command_args_parser
+from poezio.decorators import command_args_parser, deny_anonymous
from poezio.core.structs import Command, Completion
from poezio.tabs import Tab
+from poezio.ui.types import InfoMessage
log = logging.getLogger(__name__)
class RosterInfoTab(Tab):
"""
- A tab, splitted in two, containing the roster and infos
+ A tab, split in two, containing the roster and infos
"""
- plugin_commands = {} # type: Dict[str, Command]
- plugin_keys = {} # type: Dict[str, Callable]
+ plugin_commands: Dict[str, Command] = {}
+ plugin_keys: Dict[str, Callable] = {}
def __init__(self, core):
Tab.__init__(self, core)
- self.name = "Roster"
+ self._name = "Roster"
self.v_separator = windows.VerticalSeparator()
self.information_win = windows.TextWin()
self.core.information_buffer.add_window(self.information_win)
@@ -71,96 +74,54 @@ class RosterInfoTab(Tab):
self.key_func["s"] = self.start_search
self.key_func["S"] = self.start_search_slow
self.key_func["n"] = self.change_contact_name
- self.register_command(
- 'deny',
- self.command_deny,
- usage='[jid]',
- desc='Deny your presence to the provided JID (or the '
- 'selected contact in your roster), who is asking'
- 'you to be in his/here roster.',
- shortdesc='Deny a user your presence.',
- completion=self.completion_deny)
- self.register_command(
- 'accept',
- self.command_accept,
- usage='[jid]',
- desc='Allow the provided JID (or the selected contact '
- 'in your roster), to see your presence.',
- shortdesc='Allow a user your presence.',
- completion=self.completion_deny)
- self.register_command(
- 'add',
- self.command_add,
- usage='<jid>',
- desc='Add the specified JID to your roster, ask them to'
- ' allow you to see his presence, and allow them to'
- ' see your presence.',
- shortdesc='Add a user to your roster.')
- self.register_command(
- 'name',
- self.command_name,
- usage='<jid> [name]',
- shortdesc='Set the given JID\'s name.',
- completion=self.completion_name)
- self.register_command(
- 'groupadd',
- self.command_groupadd,
- usage='[<jid> <group>]|<group>',
- desc='Add the given JID or selected line to the given group.',
- shortdesc='Add a user to a group',
- completion=self.completion_groupadd)
- self.register_command(
- 'groupmove',
- self.command_groupmove,
- usage='<jid> <old group> <new group>',
- desc='Move the given JID from the old group to the new group.',
- shortdesc='Move a user to another group.',
- completion=self.completion_groupmove)
- self.register_command(
- 'groupremove',
- self.command_groupremove,
- usage='<jid> <group>',
- desc='Remove the given JID from the given group.',
- shortdesc='Remove a user from a group.',
- completion=self.completion_groupremove)
- self.register_command(
- 'remove',
- self.command_remove,
- usage='[jid]',
- desc='Remove the specified JID from your roster. This '
- 'will unsubscribe you from its presence, cancel '
- 'its subscription to yours, and remove the item '
- 'from your roster.',
- shortdesc='Remove a user from your roster.',
- completion=self.completion_remove)
- self.register_command(
- 'export',
- self.command_export,
- usage='[/path/to/file]',
- desc='Export your contacts into /path/to/file if '
- 'specified, or $HOME/poezio_contacts if not.',
- shortdesc='Export your roster to a file.',
- completion=partial(self.completion_file, 1))
- self.register_command(
- 'import',
- self.command_import,
- usage='[/path/to/file]',
- desc='Import your contacts from /path/to/file if '
- 'specified, or $HOME/poezio_contacts if not.',
- shortdesc='Import your roster from a file.',
- completion=partial(self.completion_file, 1))
- self.register_command(
- 'password',
- self.command_password,
- usage='<password>',
- shortdesc='Change your password')
-
self.register_command(
- 'reconnect',
- self.command_reconnect,
- desc='Disconnect from the remote server if you are '
- 'currently connected and then connect to it again.',
- shortdesc='Disconnect and reconnect to the server.')
+ 'name',
+ self.command_name,
+ usage='<jid> [name]',
+ shortdesc='Set the given JID\'s name.',
+ completion=self.completion_name)
+ self.register_command(
+ 'groupadd',
+ self.command_groupadd,
+ usage='[<jid> <group>]|<group>',
+ desc='Add the given JID or selected line to the given group.',
+ shortdesc='Add a user to a group',
+ completion=self.completion_groupadd)
+ self.register_command(
+ 'groupmove',
+ self.command_groupmove,
+ usage='<jid> <old group> <new group>',
+ desc='Move the given JID from the old group to the new group.',
+ shortdesc='Move a user to another group.',
+ completion=self.completion_groupmove)
+ self.register_command(
+ 'groupremove',
+ self.command_groupremove,
+ usage='<jid> <group>',
+ desc='Remove the given JID from the given group.',
+ shortdesc='Remove a user from a group.',
+ completion=self.completion_groupremove)
+ self.register_command(
+ 'export',
+ self.command_export,
+ usage='[/path/to/file]',
+ desc='Export your contacts into /path/to/file if '
+ 'specified, or $HOME/poezio_contacts if not.',
+ shortdesc='Export your roster to a file.',
+ completion=partial(self.completion_file, 1))
+ self.register_command(
+ 'import',
+ self.command_import,
+ usage='[/path/to/file]',
+ desc='Import your contacts from /path/to/file if '
+ 'specified, or $HOME/poezio_contacts if not.',
+ shortdesc='Import your roster from a file.',
+ completion=partial(self.completion_file, 1))
+ self.register_command(
+ 'password',
+ self.command_password,
+ usage='<password>',
+ shortdesc='Change your password')
self.register_command(
'disconnect',
self.command_disconnect,
@@ -183,18 +144,6 @@ class RosterInfoTab(Tab):
def check_blocking(self, features):
if 'urn:xmpp:blocking' in features and not self.core.xmpp.anon:
self.register_command(
- 'block',
- self.command_block,
- usage='[jid]',
- shortdesc='Prevent a JID from talking to you.',
- completion=self.completion_block)
- self.register_command(
- 'unblock',
- self.command_unblock,
- usage='[jid]',
- shortdesc='Allow a JID to talk to you.',
- completion=self.completion_unblock)
- self.register_command(
'list_blocks',
self.command_list_blocks,
shortdesc='Show the blocked contacts.')
@@ -250,50 +199,40 @@ class RosterInfoTab(Tab):
completion=self.completion_cert_fetch)
@property
- def selected_row(self):
+ def selected_row(self) -> Union[Contact, Resource]:
return self.roster_win.get_selected_row()
@command_args_parser.ignored
- def command_certs(self):
+ async def command_certs(self):
"""
/certs
"""
-
- def cb(iq):
- if iq['type'] == 'error':
- self.core.information(
- 'Unable to retrieve the certificate list.', 'Error')
- return
- certs = []
- for item in iq['sasl_certs']['items']:
- users = '\n'.join(item['users'])
- certs.append((item['name'], users))
-
- if not certs:
- return self.core.information('No certificates found', 'Info')
- msg = 'Certificates:\n'
- msg += '\n'.join(
- ((' %s%s' % (item[0] + (': ' if item[1] else ''), item[1]))
- for item in certs))
- self.core.information(msg, 'Info')
-
- self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb, timeout=3)
+ try:
+ iq = await self.core.xmpp.plugin['xep_0257'].get_certs(timeout=3)
+ except (IqError, IqTimeout):
+ self.core.information(
+ 'Unable to retrieve the certificate list.', 'Error')
+ return
+ certs = []
+ for item in iq['sasl_certs']['items']:
+ users = '\n'.join(item['users'])
+ certs.append((item['name'], users))
+
+ if not certs:
+ return self.core.information('No certificates found', 'Info')
+ msg = 'Certificates:\n'
+ msg += '\n'.join(
+ ((' %s%s' % (item[0] + (': ' if item[1] else ''), item[1]))
+ for item in certs))
+ self.core.information(msg, 'Info')
@command_args_parser.quoted(2, 1)
- def command_cert_add(self, args):
+ async def command_cert_add(self, args):
"""
/cert_add <name> <certfile> [cert-management]
"""
if not args or len(args) < 2:
return self.core.command.help('cert_add')
-
- def cb(iq):
- if iq['type'] == 'error':
- self.core.information('Unable to add the certificate.',
- 'Error')
- else:
- self.core.information('Certificate added.', 'Info')
-
name = args[0]
try:
@@ -319,8 +258,17 @@ class RosterInfoTab(Tab):
else:
management = True
- self.core.xmpp.plugin['xep_0257'].add_cert(
- name, crt, callback=cb, allow_management=management)
+ try:
+ await self.core.xmpp.plugin['xep_0257'].add_cert(
+ name,
+ crt,
+ allow_management=management
+ )
+ self.core.information('Certificate added.', 'Info')
+ except (IqError, IqTimeout):
+ self.core.information('Unable to add the certificate.',
+ 'Error')
+
def completion_cert_add(self, the_input):
"""
@@ -336,76 +284,62 @@ class RosterInfoTab(Tab):
return Completion(the_input.new_completion, ['true', 'false'], n)
@command_args_parser.quoted(1)
- def command_cert_disable(self, args):
+ async def command_cert_disable(self, args):
"""
/cert_disable <name>
"""
if not args:
return self.core.command.help('cert_disable')
-
- def cb(iq):
- if iq['type'] == 'error':
- self.core.information('Unable to disable the certificate.',
- 'Error')
- else:
- self.core.information('Certificate disabled.', 'Info')
-
name = args[0]
-
- self.core.xmpp.plugin['xep_0257'].disable_cert(name, callback=cb)
+ try:
+ await self.core.xmpp.plugin['xep_0257'].disable_cert(name)
+ self.core.information('Certificate disabled.', 'Info')
+ except (IqError, IqTimeout):
+ self.core.information('Unable to disable the certificate.',
+ 'Error')
@command_args_parser.quoted(1)
- def command_cert_revoke(self, args):
+ async def command_cert_revoke(self, args):
"""
/cert_revoke <name>
"""
if not args:
return self.core.command.help('cert_revoke')
-
- def cb(iq):
- if iq['type'] == 'error':
- self.core.information('Unable to revoke the certificate.',
- 'Error')
- else:
- self.core.information('Certificate revoked.', 'Info')
-
name = args[0]
-
- self.core.xmpp.plugin['xep_0257'].revoke_cert(name, callback=cb)
+ try:
+ await self.core.xmpp.plugin['xep_0257'].revoke_cert(name)
+ self.core.information('Certificate revoked.', 'Info')
+ except (IqError, IqTimeout):
+ self.core.information('Unable to revoke the certificate.',
+ 'Error')
@command_args_parser.quoted(2)
- def command_cert_fetch(self, args):
+ async def command_cert_fetch(self, args):
"""
/cert_fetch <name> <path>
"""
if not args or len(args) < 2:
return self.core.command.help('cert_fetch')
-
- def cb(iq):
- if iq['type'] == 'error':
- self.core.information('Unable to fetch the certificate.',
- 'Error')
- return
-
- cert = None
- for item in iq['sasl_certs']['items']:
- if item['name'] == name:
- cert = base64.b64decode(item['x509cert'])
- break
-
- if not cert:
- return self.core.information('Certificate not found.', 'Info')
-
- cert = ssl.DER_cert_to_PEM_cert(cert)
- with open(path, 'w') as fd:
- fd.write(cert)
-
- self.core.information('File stored at %s' % path, 'Info')
-
name = args[0]
path = args[1]
- self.core.xmpp.plugin['xep_0257'].get_certs(callback=cb)
+ try:
+ iq = await self.core.xmpp.plugin['xep_0257'].get_certs()
+ except (IqError, IqTimeout):
+ self.core.information('Unable to fetch the certificate.',
+ 'Error')
+ return
+ cert = None
+ for item in iq['sasl_certs']['items']:
+ if item['name'] == name:
+ cert = base64.b64decode(item['x509cert'])
+ break
+ if not cert:
+ return self.core.information('Certificate not found.', 'Info')
+ cert = ssl.DER_cert_to_PEM_cert(cert)
+ with open(path, 'w') as fd:
+ fd.write(cert)
+ self.core.information('File stored at %s' % path, 'Info')
def completion_cert_fetch(self, the_input):
"""
@@ -426,110 +360,30 @@ class RosterInfoTab(Tab):
if not tab:
log.debug('Received message from nonexistent tab: %s',
message['from'])
- message = '\x19%(info_col)s}Cannot send message to %(jid)s: contact blocked' % {
+ message = 'Cannot send message to %(jid)s: contact blocked' % {
'info_col': dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
'jid': message['from'],
}
- tab.add_message(message)
-
- @command_args_parser.quoted(0, 1)
- def command_block(self, args):
- """
- /block [jid]
- """
- item = self.roster_win.selected_row
- if args:
- jid = safeJID(args[0])
- elif isinstance(item, Contact):
- jid = item.bare_jid
- elif isinstance(item, Resource):
- jid = item.jid.bare
-
- def callback(iq):
- if iq['type'] == 'error':
- return self.core.information('Could not block %s.' % jid,
- 'Error')
- elif iq['type'] == 'result':
- return self.core.information('Blocked %s.' % jid, 'Info')
-
- self.core.xmpp.plugin['xep_0191'].block(jid, callback=callback)
-
- def completion_block(self, the_input):
- """
- Completion for /block
- """
- if the_input.get_argument_position() == 1:
- jids = roster.jids()
- return Completion(
- the_input.new_completion, jids, 1, '', quotify=False)
-
- @command_args_parser.quoted(0, 1)
- def command_unblock(self, args):
- """
- /unblock [jid]
- """
-
- def callback(iq):
- if iq['type'] == 'error':
- return self.core.information('Could not unblock the contact.',
- 'Error')
- elif iq['type'] == 'result':
- return self.core.information('Contact unblocked.', 'Info')
-
- item = self.roster_win.selected_row
- if args:
- jid = safeJID(args[0])
- elif isinstance(item, Contact):
- jid = item.bare_jid
- elif isinstance(item, Resource):
- jid = item.jid.bare
- self.core.xmpp.plugin['xep_0191'].unblock(jid, callback=callback)
-
- def completion_unblock(self, the_input):
- """
- Completion for /unblock
- """
-
- def on_result(iq):
- if iq['type'] == 'error':
- return
- l = sorted(str(item) for item in iq['blocklist']['items'])
- return Completion(the_input.new_completion, l, 1, quotify=False)
-
- if the_input.get_argument_position():
- self.core.xmpp.plugin['xep_0191'].get_blocked(callback=on_result)
- return True
+ tab.add_message(InfoMessage(message))
@command_args_parser.ignored
- def command_list_blocks(self):
+ async def command_list_blocks(self):
"""
/list_blocks
"""
-
- def callback(iq):
- if iq['type'] == 'error':
- return self.core.information(
- 'Could not retrieve the blocklist.', 'Error')
- s = 'List of blocked JIDs:\n'
- items = (str(item) for item in iq['blocklist']['items'])
- jids = '\n'.join(items)
- if jids:
- s += jids
- else:
- s = 'No blocked JIDs.'
- self.core.information(s, 'Info')
-
- self.core.xmpp.plugin['xep_0191'].get_blocked(callback=callback)
-
- @command_args_parser.ignored
- def command_reconnect(self):
- """
- /reconnect
- """
- if self.core.xmpp.is_connected():
- self.core.disconnect(reconnect=True)
+ try:
+ iq = await self.core.xmpp.plugin['xep_0191'].get_blocked()
+ except (IqError, IqTimeout) as iq:
+ return self.core.information(
+ 'Could not retrieve the blocklist.', 'Error')
+ s = 'List of blocked JIDs:\n'
+ items = (str(item) for item in iq['blocklist']['items'])
+ jids = '\n'.join(items)
+ if jids:
+ s += jids
else:
- self.core.xmpp.connect()
+ s = 'No blocked JIDs.'
+ self.core.information(s, 'Info')
@command_args_parser.ignored
def command_disconnect(self):
@@ -580,7 +434,9 @@ class RosterInfoTab(Tab):
roster_width)
self.information_win.resize(
self.height - 1 - tab_win_height - contact_win_h, info_width,
- 0, roster_width + 1, self.core.information_buffer)
+ 0, roster_width + 1, self.core.information_buffer,
+ force=self.ui_config_changed)
+ self.ui_config_changed = False
if display_contact_win:
y = self.height - tab_win_height - contact_win_h - 1
avatar_width = contact_win_h * 2
@@ -652,83 +508,36 @@ class RosterInfoTab(Tab):
self.core.information_buffer)
self.refresh()
+ @deny_anonymous
@command_args_parser.quoted(1)
- def command_password(self, args):
+ async def command_password(self, args):
"""
/password <password>
"""
-
- def callback(iq):
- if iq['type'] == 'result':
- self.core.information('Password updated', 'Account')
- if config.get('password'):
- config.silent_set('password', args[0])
- else:
- self.core.information('Unable to change the password',
- 'Account')
-
- self.core.xmpp.plugin['xep_0077'].change_password(
- args[0], callback=callback)
-
- @command_args_parser.quoted(0, 1)
- def command_deny(self, args):
- """
- /deny [jid]
- Denies a JID from our roster
- """
- if not args:
- item = self.roster_win.selected_row
- if isinstance(item, Contact):
- jid = item.bare_jid
- else:
- self.core.information('No subscription to deny', 'Warning')
- return
- else:
- jid = safeJID(args[0]).bare
- if jid not in [jid for jid in roster.jids()]:
- self.core.information('No subscription to deny', 'Warning')
- return
-
- contact = roster[jid]
- if contact:
- contact.unauthorize()
- self.core.information('Subscription to %s was revoked' % jid,
- 'Roster')
-
- @command_args_parser.quoted(1)
- def command_add(self, args):
- """
- Add the specified JID to the roster, and automatically
- accept the reverse subscription
- """
- if args is None:
- self.core.information('No JID specified', 'Error')
- return
- jid = safeJID(safeJID(args[0]).bare)
- if not str(jid):
- self.core.information(
- 'The provided JID (%s) is not valid' % (args[0], ), 'Error')
- return
- if jid in roster and roster[jid].subscription in ('to', 'both'):
- return self.core.information('Already subscribed.', 'Roster')
- roster.add(jid)
- roster.modified()
- self.core.information('%s was added to the roster' % jid, 'Roster')
-
+ try:
+ await self.core.xmpp.plugin['xep_0077'].change_password(
+ args[0]
+ )
+ self.core.information('Password updated', 'Account')
+ if config.getstr('password'):
+ config.silent_set('password', args[0])
+ except (IqError, IqTimeout):
+ self.core.information('Unable to change the password',
+ 'Account')
+
+ @deny_anonymous
@command_args_parser.quoted(1, 1)
- def command_name(self, args):
+ async def command_name(self, args):
"""
Set a name for the specified JID in your roster
"""
-
- def callback(iq):
- if not iq:
- self.core.information('The name could not be set.', 'Error')
- log.debug('Error in /name:\n%s', iq)
-
if args is None:
return self.core.command.help('name')
- jid = safeJID(args[0]).bare
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ self.core.information(f'Invalid JID: {args[0]}', 'Error')
+ return
name = args[1] if len(args) == 2 else ''
contact = roster[jid]
@@ -740,15 +549,19 @@ class RosterInfoTab(Tab):
if 'none' in groups:
groups.remove('none')
subscription = contact.subscription
- self.core.xmpp.update_roster(
- jid,
- name=name,
- groups=groups,
- subscription=subscription,
- callback=callback)
+ try:
+ await self.core.xmpp.update_roster(
+ jid,
+ name=name,
+ groups=groups,
+ subscription=subscription
+ )
+ except (IqError, IqTimeout):
+ self.core.information('The name could not be set.', 'Error')
+ @deny_anonymous
@command_args_parser.quoted(1, 1)
- def command_groupadd(self, args):
+ async def command_groupadd(self, args):
"""
Add the specified JID to the specified group
"""
@@ -764,7 +577,11 @@ class RosterInfoTab(Tab):
else:
return self.core.command.help('groupadd')
else:
- jid = safeJID(args[0]).bare
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ self.core.information(f'Invalid JID: {args[0]}', 'Error')
+ return
group = args[1]
contact = roster[jid]
@@ -787,28 +604,31 @@ class RosterInfoTab(Tab):
name = contact.name
subscription = contact.subscription
- def callback(iq):
- if iq:
- roster.update_contact_groups(jid)
- else:
- self.core.information('The group could not be set.', 'Error')
- log.debug('Error in groupadd:\n%s', iq)
- self.core.xmpp.update_roster(
- jid,
- name=name,
- groups=new_groups,
- subscription=subscription,
- callback=callback)
+ try:
+ await self.core.xmpp.update_roster(
+ jid,
+ name=name,
+ groups=new_groups,
+ subscription=subscription,
+ )
+ roster.update_contact_groups(jid)
+ except (IqError, IqTimeout):
+ self.core.information('The group could not be set.', 'Error')
+ @deny_anonymous
@command_args_parser.quoted(3)
- def command_groupmove(self, args):
+ async def command_groupmove(self, args):
"""
Remove the specified JID from the first specified group and add it to the second one
"""
if args is None:
return self.core.command.help('groupmove')
- jid = safeJID(args[0]).bare
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ self.core.information(f'Invalid JID: {args[0]}', 'Error')
+ return
group_from = args[1]
group_to = args[2]
@@ -845,30 +665,31 @@ class RosterInfoTab(Tab):
new_groups.remove(group_from)
name = contact.name
subscription = contact.subscription
+ try:
+ await self.core.xmpp.update_roster(
+ jid,
+ name=name,
+ groups=new_groups,
+ subscription=subscription,
+ )
+ roster.update_contact_groups(contact)
+ except (IqError, IqTimeout):
+ self.core.information('The group could not be set', 'Error')
- def callback(iq):
- if iq:
- roster.update_contact_groups(contact)
- else:
- self.core.information('The group could not be set', 'Error')
- log.debug('Error in groupmove:\n%s', iq)
-
- self.core.xmpp.update_roster(
- jid,
- name=name,
- groups=new_groups,
- subscription=subscription,
- callback=callback)
-
+ @deny_anonymous
@command_args_parser.quoted(2)
- def command_groupremove(self, args):
+ async def command_groupremove(self, args):
"""
Remove the specified JID from the specified group
"""
if args is None:
return self.core.command.help('groupremove')
- jid = safeJID(args[0]).bare
+ try:
+ jid = JID(args[0]).bare
+ except InvalidJID:
+ self.core.information(f'Invalid JID: {args[0]}', 'Error')
+ return
group = args[1]
contact = roster[jid]
@@ -890,39 +711,18 @@ class RosterInfoTab(Tab):
new_groups.remove(group)
name = contact.name
subscription = contact.subscription
+ try:
+ self.core.xmpp.update_roster(
+ jid,
+ name=name,
+ groups=new_groups,
+ subscription=subscription,
+ )
+ roster.update_contact_groups(jid)
+ except (IqError, IqTimeout):
+ self.core.information('The group could not be set')
- def callback(iq):
- if iq:
- roster.update_contact_groups(jid)
- else:
- self.core.information('The group could not be set')
- log.debug('Error in groupremove:\n%s', iq)
-
- self.core.xmpp.update_roster(
- jid,
- name=name,
- groups=new_groups,
- subscription=subscription,
- callback=callback)
-
- @command_args_parser.quoted(0, 1)
- def command_remove(self, args):
- """
- Remove the specified JID from the roster. i.e.: unsubscribe
- from its presence, and cancel its subscription to our.
- """
- if args:
- jid = safeJID(args[0]).bare
- else:
- item = self.roster_win.selected_row
- if isinstance(item, Contact):
- jid = item.bare_jid
- else:
- self.core.information('No roster item to remove', 'Error')
- return
- roster.remove(jid)
- del roster[jid]
-
+ @deny_anonymous
@command_args_parser.quoted(0, 1)
def command_import(self, args):
"""
@@ -948,9 +748,10 @@ class RosterInfoTab(Tab):
log.error('Unable to correct a message', exc_info=True)
return
for jid in lines:
- self.command_add(jid.lstrip('\n'))
+ self.core.command.command_add(jid.lstrip('\n'))
self.core.information('Contacts imported from %s' % filepath, 'Info')
+ @deny_anonymous
@command_args_parser.quoted(0, 1)
def command_export(self, args):
"""
@@ -1045,49 +846,6 @@ class RosterInfoTab(Tab):
the_input.new_completion, groups, n, '', quotify=True)
return False
- def completion_deny(self, the_input):
- """
- Complete the first argument from the list of the
- contact with ask=='subscribe'
- """
- jids = sorted(
- str(contact.bare_jid) for contact in roster.contacts.values()
- if contact.pending_in)
- return Completion(the_input.new_completion, jids, 1, '', quotify=False)
-
- @command_args_parser.quoted(0, 1)
- def command_accept(self, args):
- """
- Accept a JID from in roster. Authorize it AND subscribe to it
- """
- if not args:
- item = self.roster_win.selected_row
- if isinstance(item, Contact):
- jid = item.bare_jid
- else:
- self.core.information('No subscription to accept', 'Warning')
- return
- else:
- jid = safeJID(args[0]).bare
- nodepart = safeJID(jid).user
- jid = safeJID(jid)
- # crappy transports putting resources inside the node part
- if '\\2f' in nodepart:
- jid.user = nodepart.split('\\2f')[0]
- contact = roster[jid]
- if contact is None:
- return
- contact.pending_in = False
- roster.modified()
- self.core.xmpp.send_presence(pto=jid, ptype='subscribed')
- self.core.xmpp.client_roster.send_last_presence()
- if contact.subscription in ('from',
- 'none') and not contact.pending_out:
- self.core.xmpp.send_presence(
- pto=jid, ptype='subscribe', pnick=self.core.own_nick)
-
- self.core.information('%s is now authorized' % jid, 'Roster')
-
def refresh(self):
if self.need_resize:
self.resize()
@@ -1128,7 +886,7 @@ class RosterInfoTab(Tab):
Show or hide offline contacts
"""
option = 'roster_show_offline'
- value = config.get(option)
+ value = config.getbool(option)
success = config.silent_set(option, str(not value))
roster.modified()
if not success:
@@ -1272,15 +1030,6 @@ class RosterInfoTab(Tab):
'%s connected resource%s' % (len(cont), ''
if len(cont) == 1 else 's'))
acc.append('Current status: %s' % res.status)
- if cont.tune:
- acc.append('Tune: %s' % common.format_tune_string(cont.tune))
- if cont.mood:
- acc.append('Mood: %s' % cont.mood)
- if cont.activity:
- acc.append('Activity: %s' % cont.activity)
- if cont.gaming:
- acc.append(
- 'Game: %s' % (common.format_gaming_string(cont.gaming)))
msg = '\n'.join(acc)
elif isinstance(selected_row, Resource):
res = selected_row
@@ -1306,7 +1055,7 @@ class RosterInfoTab(Tab):
if isinstance(selected_row, Contact):
jid = selected_row.bare_jid
elif isinstance(selected_row, Resource):
- jid = safeJID(selected_row.jid).bare
+ jid = JID(selected_row.jid).bare
else:
return
self.on_slash()
@@ -1388,8 +1137,11 @@ def jid_and_name_match(contact, txt):
if not txt:
return True
txt = txt.lower()
- if txt in safeJID(contact.bare_jid).bare.lower():
- return True
+ try:
+ if txt in JID(contact.bare_jid).bare.lower():
+ return True
+ except InvalidJID:
+ pass
if txt in contact.name.lower():
return True
return False
@@ -1402,9 +1154,12 @@ def jid_and_name_match_slow(contact, txt):
"""
if not txt:
return True # Everything matches when search is empty
- user = safeJID(contact.bare_jid).bare
- if diffmatch(txt, user):
- return True
+ try:
+ user = JID(contact.bare_jid).bare
+ if diffmatch(txt, user):
+ return True
+ except InvalidJID:
+ pass
if contact.name and diffmatch(txt, contact.name):
return True
return False
diff --git a/poezio/tabs/xmltab.py b/poezio/tabs/xmltab.py
index c4a50df8..939af67d 100644
--- a/poezio/tabs/xmltab.py
+++ b/poezio/tabs/xmltab.py
@@ -10,7 +10,8 @@ log = logging.getLogger(__name__)
import curses
import os
-from slixmpp.xmlstream import matcher
+from slixmpp import JID, InvalidJID
+from slixmpp.xmlstream import matcher, StanzaBase
from slixmpp.xmlstream.tostring import tostring
from slixmpp.xmlstream.stanzabase import ElementBase
from xml.etree import ElementTree as ET
@@ -21,17 +22,16 @@ from poezio import text_buffer
from poezio import windows
from poezio.xhtml import clean_text
from poezio.decorators import command_args_parser, refresh_wrapper
-from poezio.common import safeJID
class MatchJID:
- def __init__(self, jid, dest=''):
+ def __init__(self, jid: JID, dest: str = ''):
self.jid = jid
self.dest = dest
- def match(self, xml):
- from_ = safeJID(xml['from'])
- to_ = safeJID(xml['to'])
+ def match(self, xml: StanzaBase):
+ from_ = xml['from']
+ to_ = xml['to']
if self.jid.full == self.jid.bare:
from_ = from_.bare
to_ = to_.bare
@@ -58,14 +58,14 @@ class XMLTab(Tab):
def __init__(self, core):
Tab.__init__(self, core)
self.state = 'normal'
- self.name = 'XMLTab'
+ self._name = 'XMLTab'
self.filters = []
self.core_buffer = self.core.xml_buffer
self.filtered_buffer = text_buffer.TextBuffer()
self.info_header = windows.XMLInfoWin()
- self.text_win = windows.XMLTextWin()
+ self.text_win = windows.TextWin()
self.core_buffer.add_window(self.text_win)
self.default_help_message = windows.HelpText("/ to enter a command")
@@ -120,7 +120,7 @@ class XMLTab(Tab):
usage='<filename>',
desc='Writes the content of the XML buffer into a file.',
shortdesc='Write in a file.')
- self.input = self.default_help_message
+ self.input = self.default_help_message # type: ignore
self.key_func['^T'] = self.close
self.key_func['^I'] = self.completion
self.key_func["KEY_DOWN"] = self.on_scroll_down
@@ -173,7 +173,7 @@ class XMLTab(Tab):
self.text_win.toggle_lock()
self.refresh()
- def match_stanza(self, stanza):
+ def match_stanza(self, stanza) -> bool:
for matcher_ in self.filters:
if not matcher_.match(stanza):
return False
@@ -190,33 +190,36 @@ class XMLTab(Tab):
self.command_filter_reset()
@command_args_parser.raw
- def command_filter_to(self, jid):
+ def command_filter_to(self, jid_str: str):
"""/filter_jid_to <jid>"""
- jid_obj = safeJID(jid)
- if not jid_obj:
+ try:
+ jid = JID(jid_str)
+ except InvalidJID:
return self.core.information('Invalid JID: %s' % jid, 'Error')
- self.update_filters(MatchJID(jid_obj, dest='to'))
+ self.update_filters(MatchJID(jid, dest='to'))
self.refresh()
@command_args_parser.raw
- def command_filter_from(self, jid):
+ def command_filter_from(self, jid_str: str):
"""/filter_jid_from <jid>"""
- jid_obj = safeJID(jid)
- if not jid_obj:
+ try:
+ jid = JID(jid_str)
+ except InvalidJID:
return self.core.information('Invalid JID: %s' % jid, 'Error')
- self.update_filters(MatchJID(jid_obj, dest='from'))
+ self.update_filters(MatchJID(jid, dest='from'))
self.refresh()
@command_args_parser.raw
- def command_filter_jid(self, jid):
+ def command_filter_jid(self, jid_str: str):
"""/filter_jid <jid>"""
- jid_obj = safeJID(jid)
- if not jid_obj:
+ try:
+ jid = JID(jid_str)
+ except InvalidJID:
return self.core.information('Invalid JID: %s' % jid, 'Error')
- self.update_filters(MatchJID(jid_obj))
+ self.update_filters(MatchJID(jid))
self.refresh()
@command_args_parser.quoted(1)
@@ -229,7 +232,7 @@ class XMLTab(Tab):
self.refresh()
@command_args_parser.raw
- def command_filter_xpath(self, xpath):
+ def command_filter_xpath(self, xpath: str):
"""/filter_xpath <xpath>"""
try:
self.update_filters(
@@ -262,7 +265,10 @@ class XMLTab(Tab):
else:
xml = self.core_buffer.messages[:]
text = '\n'.join(
- ('%s %s %s' % (msg.str_time, msg.nickname, clean_text(msg.txt))
+ ('%s %s %s' % (
+ msg.time.strftime('%H:%M:%S'),
+ 'IN' if msg.incoming else 'OUT',
+ clean_text(msg.txt))
for msg in xml))
filename = os.path.expandvars(os.path.expanduser(args[0]))
try:
@@ -283,7 +289,7 @@ class XMLTab(Tab):
self.input.do_command("/") # we add the slash
@refresh_wrapper.always
- def reset_help_message(self, _=None):
+ def reset_help_message(self, _=None) -> bool:
if self.closed:
return True
if self.core.tabs.current_tab is self:
@@ -291,10 +297,10 @@ class XMLTab(Tab):
self.input = self.default_help_message
return True
- def on_scroll_up(self):
+ def on_scroll_up(self) -> bool:
return self.text_win.scroll_up(self.text_win.height - 1)
- def on_scroll_down(self):
+ def on_scroll_down(self) -> bool:
return self.text_win.scroll_down(self.text_win.height - 1)
@command_args_parser.ignored
@@ -308,10 +314,11 @@ class XMLTab(Tab):
self.refresh()
self.core.doupdate()
- def execute_slash_command(self, txt):
+ def execute_slash_command(self, txt: str) -> bool:
if txt.startswith('/'):
- self.input.key_enter()
- self.execute_command(txt)
+ if isinstance(self.input, windows.CommandInput):
+ self.input.key_enter()
+ self.execute_command(txt)
return self.reset_help_message()
def completion(self):
diff --git a/poezio/text_buffer.py b/poezio/text_buffer.py
index 448adff3..bcee5989 100644
--- a/poezio/text_buffer.py
+++ b/poezio/text_buffer.py
@@ -8,98 +8,35 @@ Each text buffer can be linked to multiple windows, that will be rendered
independently by their TextWins.
"""
+from __future__ import annotations
+
import logging
-log = logging.getLogger(__name__)
-from typing import Union, Optional, List, Tuple
+from typing import (
+ Dict,
+ List,
+ Optional,
+ TYPE_CHECKING,
+ Tuple,
+ Union,
+)
+from dataclasses import dataclass
from datetime import datetime
from poezio.config import config
-from poezio.theming import get_theme, dump_tuple
-
-
-class Message:
- __slots__ = ('txt', 'nick_color', 'time', 'str_time', 'nickname', 'user',
- 'identifier', 'highlight', 'me', 'old_message', 'revisions',
- 'jid', 'ack')
-
- def __init__(self,
- txt: str,
- time: Optional[datetime],
- nickname: Optional[str],
- nick_color: Optional[Tuple],
- history: bool,
- user: Optional[str],
- identifier: Optional[str],
- str_time: Optional[str] = None,
- highlight: bool = False,
- old_message: Optional['Message'] = None,
- revisions: int = 0,
- jid: Optional[str] = None,
- ack: int = 0) -> None:
- """
- Create a new Message object with parameters, check for /me messages,
- and delayed messages
- """
- time = time if time is not None else datetime.now()
- if txt.startswith('/me '):
- me = True
- txt = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_ME_MESSAGE),
- txt[4:])
- else:
- me = False
- if history:
- txt = txt.replace(
- '\x19o',
- '\x19o\x19%s}' % dump_tuple(get_theme().COLOR_LOG_MSG))
- str_time = time.strftime("%Y-%m-%d %H:%M:%S")
- else:
- if str_time is None:
- str_time = time.strftime("%H:%M:%S")
- else:
- str_time = ''
-
- self.txt = txt.replace('\t', ' ') + '\x19o'
- self.nick_color = nick_color
- self.time = time
- self.str_time = str_time
- self.nickname = nickname
- self.user = user
- self.identifier = identifier
- self.highlight = highlight
- self.me = me
- self.old_message = old_message
- self.revisions = revisions
- self.jid = jid
- self.ack = ack
-
- def _other_elems(self) -> str:
- "Helper for the repr_message function"
- acc = []
- fields = list(self.__slots__)
- fields.remove('old_message')
- for field in fields:
- acc.append('%s=%s' % (field, repr(getattr(self, field))))
- return 'Message(%s, %s' % (', '.join(acc), 'old_message=')
-
- def __repr__(self) -> str:
- """
- repr() for the Message class, for debug purposes, since the default
- repr() is recursive, so it can stack overflow given too many revisions
- of a message
- """
- init = self._other_elems()
- acc = [init]
- next_message = self.old_message
- rev = 1
- while next_message is not None:
- acc.append(next_message._other_elems())
- next_message = next_message.old_message
- rev += 1
- acc.append('None')
- while rev:
- acc.append(')')
- rev -= 1
- return ''.join(acc)
+from poezio.ui.types import (
+ BaseMessage,
+ Message,
+ MucOwnJoinMessage,
+ MucOwnLeaveMessage,
+)
+
+if TYPE_CHECKING:
+ from poezio.windows.text_win import TextWin
+ from poezio.user import User
+ from slixmpp import JID
+
+
+log = logging.getLogger(__name__)
class CorrectionError(Exception):
@@ -110,6 +47,15 @@ class AckError(Exception):
pass
+@dataclass
+class HistoryGap:
+ """Class representing a period of non-presence inside a MUC"""
+ leave_message: Optional[BaseMessage]
+ join_message: Optional[BaseMessage]
+ last_timestamp_before_leave: Optional[datetime]
+ first_timestamp_after_join: Optional[datetime]
+
+
class TextBuffer:
"""
This class just keep trace of messages, in a list with various
@@ -119,63 +65,133 @@ class TextBuffer:
def __init__(self, messages_nb_limit: Optional[int] = None) -> None:
if messages_nb_limit is None:
- messages_nb_limit = config.get('max_messages_in_memory')
- self._messages_nb_limit = messages_nb_limit # type: int
+ messages_nb_limit = config.getint('max_messages_in_memory')
+ self._messages_nb_limit: int = messages_nb_limit
# Message objects
- self.messages = [] # type: List[Message]
+ self.messages: List[BaseMessage] = []
+ # COMPAT: Correction id -> Original message id.
+ self.correction_ids: Dict[str, str] = {}
# we keep track of one or more windows
# so we can pass the new messages to them, as they are added, so
# they (the windows) can build the lines from the new message
- self._windows = []
+ self._windows: List[TextWin] = []
def add_window(self, win) -> None:
self._windows.append(win)
+ def find_last_gap_muc(self) -> Optional[HistoryGap]:
+ """Find the last known history gap contained in buffer"""
+ leave: Optional[Tuple[int, BaseMessage]] = None
+ join: Optional[Tuple[int, BaseMessage]] = None
+ for i, item in enumerate(reversed(self.messages)):
+ if isinstance(item, MucOwnLeaveMessage):
+ leave = (len(self.messages) - i - 1, item)
+ break
+ elif join and isinstance(item, MucOwnJoinMessage):
+ leave = (len(self.messages) - i - 1, item)
+ break
+ if isinstance(item, MucOwnJoinMessage):
+ join = (len(self.messages) - i - 1, item)
+
+ last_timestamp = None
+ first_timestamp = datetime.now()
+
+ # Identify the special case when we got disconnected from a chatroom
+ # without receiving or sending the relevant presence, therefore only
+ # having two joins with no leave, and messages in the middle.
+ if leave and join and isinstance(leave[1], MucOwnJoinMessage):
+ for i in range(join[0] - 1, leave[0], - 1):
+ if isinstance(self.messages[i], Message):
+ leave = (
+ i,
+ self.messages[i]
+ )
+ last_timestamp = self.messages[i].time
+ break
+ # If we have a normal gap but messages inbetween, it probably
+ # already has history, so abort there without returning it.
+ if join and leave:
+ for i in range(leave[0] + 1, join[0], 1):
+ if isinstance(self.messages[i], Message):
+ return None
+ elif not (join or leave):
+ return None
+
+ # If a leave message is found, get the last Message timestamp
+ # before it.
+ if leave is None:
+ leave_msg = None
+ elif last_timestamp is None:
+ leave_msg = leave[1]
+ for i in range(leave[0], 0, -1):
+ if isinstance(self.messages[i], Message):
+ last_timestamp = self.messages[i].time
+ break
+ else:
+ leave_msg = leave[1]
+ # If a join message is found, get the first Message timestamp
+ # after it, or the current time.
+ if join is None:
+ join_msg = None
+ else:
+ join_msg = join[1]
+ for i in range(join[0], len(self.messages)):
+ msg = self.messages[i]
+ if isinstance(msg, Message) and msg.time < first_timestamp:
+ first_timestamp = msg.time
+ break
+ return HistoryGap(
+ leave_message=leave_msg,
+ join_message=join_msg,
+ last_timestamp_before_leave=last_timestamp,
+ first_timestamp_after_join=first_timestamp,
+ )
+
+ def get_gap_index(self, gap: HistoryGap) -> Optional[int]:
+ """Find the first index to insert into inside a gap"""
+ if gap.leave_message is None:
+ return 0
+ for i, msg in enumerate(self.messages):
+ if msg is gap.leave_message:
+ return i + 1
+ return None
+
+ def add_history_messages(self, messages: List[BaseMessage], gap: Optional[HistoryGap] = None) -> None:
+ """Insert history messages at their correct place """
+ index = 0
+ new_index = None
+ if gap is not None:
+ new_index = self.get_gap_index(gap)
+ if new_index is None: # Not sure what happened, abort
+ return
+ index = new_index
+ for message in messages:
+ self.messages.insert(index, message)
+ index += 1
+ log.debug('inserted message: %s', message)
+ for window in self._windows: # make the associated windows
+ window.rebuild_everything(self)
+
@property
- def last_message(self) -> Optional[Message]:
+ def last_message(self) -> Optional[BaseMessage]:
return self.messages[-1] if self.messages else None
- def add_message(self,
- txt: str,
- time: Optional[datetime] = None,
- nickname: Optional[str] = None,
- nick_color: Optional[Tuple] = None,
- history: bool = False,
- user: Optional[str] = None,
- highlight: bool = False,
- identifier: Optional[str] = None,
- str_time: Optional[str] = None,
- jid: Optional[str] = None,
- ack: int = 0) -> int:
+ def add_message(self, msg: BaseMessage):
"""
Create a message and add it to the text buffer
"""
- msg = Message(
- txt,
- time,
- nickname,
- nick_color,
- history,
- user,
- identifier,
- str_time=str_time,
- highlight=highlight,
- jid=jid,
- ack=ack)
self.messages.append(msg)
while len(self.messages) > self._messages_nb_limit:
self.messages.pop(0)
ret_val = 0
- show_timestamps = config.get('show_timestamps')
- nick_size = config.get('max_nick_length')
+ show_timestamps = config.getbool('show_timestamps')
+ nick_size = config.getbool('max_nick_length')
for window in self._windows: # make the associated windows
# build the lines from the new message
nb = window.build_new_message(
msg,
- history=history,
- highlight=highlight,
timestamp=show_timestamps,
nick_size=nick_size)
if ret_val == 0:
@@ -185,35 +201,42 @@ class TextBuffer:
return min(ret_val, 1)
- def _find_message(self, old_id: str) -> int:
+ def _find_message(self, orig_id: str) -> Tuple[str, int]:
"""
Find a message in the text buffer from its message id
"""
+ # When looking for a message, ensure the id doesn't appear in a
+ # message we've removed from our message list. If so return the index
+ # of the corresponding id for the original message instead.
+ orig_id = self.correction_ids.get(orig_id, orig_id)
+
for i in range(len(self.messages) - 1, -1, -1):
msg = self.messages[i]
- if msg.identifier == old_id:
- return i
- return -1
+ if msg.identifier == orig_id:
+ return (orig_id, i)
+ return (orig_id, -1)
- def ack_message(self, old_id: str, jid: str) -> Union[None, bool, Message]:
+ def ack_message(self, old_id: str, jid: JID) -> Union[None, bool, Message]:
"""Mark a message as acked"""
return self._edit_ack(1, old_id, jid)
def nack_message(self, error: str, old_id: str,
- jid: str) -> Union[None, bool, Message]:
+ jid: JID) -> Union[None, bool, Message]:
"""Mark a message as errored"""
return self._edit_ack(-1, old_id, jid, append=error)
- def _edit_ack(self, value: int, old_id: str, jid: str,
+ def _edit_ack(self, value: int, old_id: str, jid: JID,
append: str = '') -> Union[None, bool, Message]:
"""
Edit the ack status of a message, and optionally
append some text.
"""
- i = self._find_message(old_id)
+ _, i = self._find_message(old_id)
if i == -1:
return None
msg = self.messages[i]
+ if not isinstance(msg, Message):
+ return None
if msg.ack == 1: # Message was already acked
return False
if msg.jid != jid:
@@ -227,29 +250,35 @@ class TextBuffer:
def modify_message(self,
txt: str,
- old_id: str,
+ orig_id: str,
new_id: str,
highlight: bool = False,
time: Optional[datetime] = None,
- user: Optional[str] = None,
- jid: Optional[str] = None):
+ user: Optional[User] = None,
+ jid: Optional[JID] = None) -> Message:
"""
Correct a message in a text buffer.
+
+ Version 1.1.0 of Last Message Correction (0308) added clarifications
+ that break the way poezio handles corrections. Instead of linking
+ corrections to the previous correction/message as we were doing, we
+ are now required to link all corrections to the original messages.
"""
- i = self._find_message(old_id)
+ orig_id, i = self._find_message(orig_id)
if i == -1:
log.debug(
'Message %s not found in text_buffer, abort replacement.',
- old_id)
+ orig_id)
raise CorrectionError("nothing to replace")
msg = self.messages[i]
-
+ if not isinstance(msg, Message):
+ raise CorrectionError('Wrong message type')
if msg.user and msg.user is not user:
raise CorrectionError("Different users")
- elif len(msg.str_time) > 8: # ugly
+ elif msg.delayed:
raise CorrectionError("Delayed message")
elif not msg.user and (msg.jid is None or jid is None):
raise CorrectionError('Could not check the '
@@ -257,29 +286,44 @@ class TextBuffer:
elif not msg.user and msg.jid != jid:
raise CorrectionError(
'Messages %s and %s have not been '
- 'sent by the same fullJID' % (old_id, new_id))
+ 'sent by the same fullJID' % (orig_id, new_id))
if not time:
- time = msg.time
+ time = datetime.now()
+
+ self.correction_ids[new_id] = orig_id
message = Message(
- txt,
- time,
- msg.nickname,
- msg.nick_color,
- False,
- msg.user,
- new_id,
+ txt=txt,
+ time=time,
+ nickname=msg.nickname,
+ nick_color=msg.nick_color,
+ user=msg.user,
+ identifier=orig_id,
highlight=highlight,
old_message=msg,
revisions=msg.revisions + 1,
jid=jid)
self.messages[i] = message
- log.debug('Replacing message %s with %s.', old_id, new_id)
+ log.debug('Replacing message %s with %s.', orig_id, new_id)
return message
def del_window(self, win) -> None:
self._windows.remove(win)
+ def find_last_message(self) -> Optional[Message]:
+ """Find the last real message received in this buffer"""
+ for message in reversed(self.messages):
+ if isinstance(message, Message):
+ return message
+ return None
+
+ def find_first_message(self) -> Optional[Message]:
+ """Find the first real message received in this buffer"""
+ for message in self.messages:
+ if isinstance(message, Message):
+ return message
+ return None
+
def __del__(self):
size = len(self.messages)
log.debug('** Deleting %s messages from textbuffer', size)
diff --git a/poezio/theming.py b/poezio/theming.py
index db1ccb39..187d07c5 100755
--- a/poezio/theming.py
+++ b/poezio/theming.py
@@ -1,9 +1,10 @@
+#!/usr/bin/env python3
# Copyright 2010-2011 Florent Le Coz <louiz@louiz.org>
#
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Define the variables (colors and some other stuff) that are
used when drawing the interface.
@@ -73,11 +74,11 @@ except ImportError:
import curses
import functools
-import os
-from typing import Dict, List, Union, Tuple, Optional
+from typing import Dict, List, Union, Tuple, Optional, cast
from pathlib import Path
from os import path
from poezio import colors, xdg
+from datetime import datetime
from importlib import machinery
finder = machinery.PathFinder()
@@ -143,6 +144,14 @@ class Theme:
return sub_mapping[sub] if sub == keep else ''
return sub_mapping.get(sub, '')
+ # Short date format (only show time)
+ SHORT_TIME_FORMAT = '%H:%M:%S'
+ SHORT_TIME_FORMAT_LENGTH = len(datetime.now().strftime(SHORT_TIME_FORMAT))
+
+ # Long date format (show date and time)
+ LONG_TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
+ LONG_TIME_FORMAT_LENGTH = len(datetime.now().strftime(LONG_TIME_FORMAT))
+
# Message text color
COLOR_NORMAL_TEXT = (-1, -1)
COLOR_INFORMATION_TEXT = (5, -1) # TODO
@@ -178,12 +187,13 @@ class Theme:
CHAR_CHATSTATE_COMPOSING = 'X'
CHAR_CHATSTATE_PAUSED = 'p'
- # These characters are used for the affiliation in the user list
- # in a MUC
+ # These characters are used for the affiliation wherever needed, e.g., in
+ # the user list in a MUC, or when displaying affiliation lists.
CHAR_AFFILIATION_OWNER = '~'
CHAR_AFFILIATION_ADMIN = '&'
CHAR_AFFILIATION_MEMBER = '+'
CHAR_AFFILIATION_NONE = '-'
+ CHAR_AFFILIATION_OUTCAST = '!'
# XML Tab
CHAR_XML_IN = 'IN '
@@ -198,7 +208,7 @@ class Theme:
COLOR_REVISIONS_MESSAGE = (3, -1, 'b')
# Color for various important text. For example the "?" before JIDs in
- # the roster that require an user action.
+ # the roster that require a user action.
COLOR_IMPORTANT_TEXT = (3, 5, 'b')
# Separators
@@ -224,6 +234,15 @@ class Theme:
COLOR_TAB_ATTENTION = (7, 1)
COLOR_TAB_DISCONNECTED = (7, 8)
+ # If autocolor_tab_names is set to true, the following modes are used to
+ # distinguish tabs with normal and important messages.
+ MODE_TAB_NORMAL = ''
+ MODE_TAB_IMPORTANT = 'r' # reverse video mode
+
+ # This is the mode used for the tab name in the info bar of MUC and 1:1
+ # chat tabs.
+ MODE_TAB_NAME = 'r'
+
COLOR_VERTICAL_TAB_NORMAL = (4, -1)
COLOR_VERTICAL_TAB_NONEMPTY = (4, -1)
COLOR_VERTICAL_TAB_JOINED = (82, -1)
@@ -281,7 +300,7 @@ class Theme:
(224, -1), (225, -1), (226, -1), (227, -1)]
# XEP-0392 consistent color generation palette placeholder
# it’s generated on first use when accessing the ccg_palette property
- CCG_PALETTE = None # type: Optional[Dict[float, int]]
+ CCG_PALETTE: Optional[Dict[float, int]] = None
CCG_Y = 0.5**0.45
# yapf: enable
@@ -315,7 +334,9 @@ class Theme:
COLOR_COLUMN_HEADER_SEL = (4, 36)
# Strings for special messages (like join, quit, nick change, etc)
- # Special messages
+ CHAR_BEFORE_NICK_ME = '* '
+ CHAR_AFTER_NICK_ME = ' '
+ CHAR_AFTER_NICK = '> '
CHAR_JOIN = '--->'
CHAR_QUIT = '<---'
CHAR_KICK = '-!-'
@@ -358,7 +379,7 @@ class Theme:
# Info messages color (the part before the ">")
INFO_COLORS = {
'info': (5, -1),
- 'error': (16, 1),
+ 'error': (9, 7, 'b'),
'warning': (1, -1),
'roster': (2, -1),
'help': (10, -1),
@@ -371,7 +392,7 @@ class Theme:
}
@property
- def ccg_palette(self):
+ def ccg_palette(self) -> Optional[Dict[float, int]]:
prepare_ccolor_palette(self)
return self.CCG_PALETTE
@@ -383,8 +404,7 @@ theme = Theme()
# Each time we use a color tuple, we check if it has already been used.
# If not we create a new color_pair and keep it in that dict, to use it
# the next time.
-curses_colors_dict = {
-} # type: Dict[Union[Tuple[int, int], Tuple[int, int, str]], int]
+curses_colors_dict: Dict[Union[Tuple[int, int], Tuple[int, int, str]], int] = {}
# yapf: disable
@@ -408,7 +428,7 @@ table_256_to_16 = [
]
# yapf: enable
-load_path = [] # type: List[str]
+load_path: List[str] = []
def color_256_to_16(color):
@@ -441,13 +461,14 @@ def to_curses_attr(
returns a valid curses attr that can be passed directly to attron() or attroff()
"""
# extract the color from that tuple
+ colors: Union[Tuple[int, int], Tuple[int, int, str]]
if len(color_tuple) == 3:
colors = (color_tuple[0], color_tuple[1])
else:
colors = color_tuple
bold = False
- if curses.COLORS != 256:
+ if curses.COLORS < 256:
# We are not in a term supporting 256 colors, so we convert
# colors to numbers between -1 and 8
colors = (color_256_to_16(colors[0]), color_256_to_16(colors[1]))
@@ -466,7 +487,7 @@ def to_curses_attr(
curses_colors_dict[colors] = pair
curses_pair = curses.color_pair(pair)
if len(color_tuple) == 3:
- additional_val = color_tuple[2]
+ _, _, additional_val = cast(Tuple[int, int, str], color_tuple)
if 'b' in additional_val or bold is True:
curses_pair = curses_pair | curses.A_BOLD
if 'u' in additional_val:
@@ -476,6 +497,8 @@ def to_curses_attr(
curses, 'A_ITALIC') else curses.A_REVERSE)
if 'a' in additional_val:
curses_pair = curses_pair | curses.A_BLINK
+ if 'r' in additional_val:
+ curses_pair = curses_pair | curses.A_REVERSE
return curses_pair
@@ -498,7 +521,7 @@ def update_themes_dir(option: Optional[str] = None,
load_path.append(default_dir)
# import from the user-defined prefs
- themes_dir_str = config.get('themes_dir')
+ themes_dir_str = config.getstr('themes_dir')
themes_dir = Path(themes_dir_str).expanduser(
) if themes_dir_str else xdg.DATA_HOME / 'themes'
try:
@@ -544,7 +567,7 @@ def prepare_ccolor_palette(theme: Theme) -> None:
def reload_theme() -> Optional[str]:
- theme_name = config.get('theme')
+ theme_name = config.getstr('theme')
global theme
if theme_name == 'default' or not theme_name.strip():
theme = Theme()
@@ -552,10 +575,10 @@ def reload_theme() -> Optional[str]:
new_theme = None
exc = None
try:
- loader = finder.find_module(theme_name, load_path)
- if not loader:
+ spec = finder.find_spec(theme_name, path=load_path)
+ if not spec or not spec.loader:
return 'Failed to load the theme %s' % theme_name
- new_theme = loader.load_module()
+ new_theme = spec.loader.load_module(theme_name)
except Exception as e:
log.error('Failed to load the theme %s', theme_name, exc_info=True)
exc = e
@@ -564,7 +587,7 @@ def reload_theme() -> Optional[str]:
return 'Failed to load theme: %s' % exc
if hasattr(new_theme, 'theme'):
- theme = new_theme.theme
+ theme = new_theme.theme # type: ignore
prepare_ccolor_palette(theme)
return None
return 'No theme present in the theme file'
diff --git a/poezio/timed_events.py b/poezio/timed_events.py
index cd7659e2..314ed76c 100644
--- a/poezio/timed_events.py
+++ b/poezio/timed_events.py
@@ -3,7 +3,7 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Timed events are the standard way to schedule events for later in poezio.
@@ -32,11 +32,11 @@ class DelayedEvent:
:param function callback: The handler that will be executed.
:param args: Optional arguments passed to the handler.
"""
- self.callback = callback # type: Callable
- self.args = args # type: Tuple[Any, ...]
- self.delay = delay # type: Union[int, float]
+ self.callback: Callable = callback
+ self.args: Tuple[Any, ...] = args
+ self.delay: Union[int, float] = delay
# An asyncio handler, as returned by call_later() or call_at()
- self.handler = None # type: Optional[Handle]
+ self.handler: Optional[Handle] = None
class TimedEvent(DelayedEvent):
diff --git a/poezio/types.py b/poezio/types.py
new file mode 100644
index 00000000..8d727f46
--- /dev/null
+++ b/poezio/types.py
@@ -0,0 +1,8 @@
+"""Poezio type stuff"""
+
+try:
+ from typing import TypedDict
+except ImportError:
+ from typing_extensions import TypedDict
+
+__all__ = ['TypedDict']
diff --git a/poezio/ui/__init__.py b/poezio/ui/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/poezio/ui/__init__.py
diff --git a/poezio/ui/consts.py b/poezio/ui/consts.py
new file mode 100644
index 00000000..91f19a82
--- /dev/null
+++ b/poezio/ui/consts.py
@@ -0,0 +1,4 @@
+FORMAT_CHAR = '\x19'
+# These are non-printable chars, so they should never appear in the input,
+# I guess. But maybe we can find better chars that are even less risky.
+FORMAT_CHARS = '\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x1A'
diff --git a/poezio/windows/funcs.py b/poezio/ui/funcs.py
index 22977374..023432ee 100644
--- a/poezio/windows/funcs.py
+++ b/poezio/ui/funcs.py
@@ -4,14 +4,14 @@ Standalone functions used by the modules
import string
from typing import Optional, List
-from poezio.windows.base_wins import FORMAT_CHAR, format_chars
+from poezio.ui.consts import FORMAT_CHAR, FORMAT_CHARS
DIGITS = string.digits + '-'
def find_first_format_char(text: str,
chars: str = None) -> int:
- to_find = chars or format_chars
+ to_find = chars or FORMAT_CHARS
pos = -1
for char in to_find:
p = text.find(char)
@@ -22,12 +22,14 @@ def find_first_format_char(text: str,
return pos
-def truncate_nick(nick: Optional[str], size=10) -> Optional[str]:
+def truncate_nick(nick: Optional[str], size=10) -> str:
if size < 1:
size = 1
- if nick and len(nick) > size:
- return nick[:size] + '…'
- return nick
+ if nick:
+ if len(nick) > size:
+ return nick[:size] + '…'
+ return nick
+ return ''
def parse_attrs(text: str, previous: Optional[List[str]] = None) -> List[str]:
diff --git a/poezio/ui/render.py b/poezio/ui/render.py
new file mode 100644
index 00000000..aad482b5
--- /dev/null
+++ b/poezio/ui/render.py
@@ -0,0 +1,280 @@
+from __future__ import annotations
+
+import curses
+
+from datetime import (
+ datetime,
+ date,
+)
+from functools import singledispatch
+from math import ceil, log10
+from typing import (
+ List,
+ Optional,
+ Tuple,
+ TYPE_CHECKING,
+)
+
+from poezio import poopt
+from poezio.theming import (
+ get_theme,
+)
+from poezio.ui.consts import (
+ FORMAT_CHAR,
+)
+from poezio.ui.funcs import (
+ truncate_nick,
+ parse_attrs,
+)
+from poezio.ui.types import (
+ BaseMessage,
+ Message,
+ StatusMessage,
+ UIMessage,
+ XMLLog,
+)
+
+if TYPE_CHECKING:
+ from poezio.windows import Win
+
+# msg is a reference to the corresponding Message object. text_start and
+# text_end are the position delimiting the text in this line.
+class Line:
+ __slots__ = ('msg', 'start_pos', 'end_pos', 'prepend')
+
+ def __init__(self, msg: BaseMessage, start_pos: int, end_pos: int, prepend: str) -> None:
+ self.msg = msg
+ self.start_pos = start_pos
+ self.end_pos = end_pos
+ self.prepend = prepend
+
+ def __repr__(self):
+ return '(%s, %s)' % (self.start_pos, self.end_pos)
+
+
+LinePos = Tuple[int, int]
+
+
+def generate_lines(lines: List[LinePos], msg: BaseMessage, default_color: str = '') -> List[Line]:
+ line_objects = []
+ attrs: List[str] = []
+ prepend = default_color if default_color else ''
+ for line in lines:
+ saved = Line(
+ msg=msg,
+ start_pos=line[0],
+ end_pos=line[1],
+ prepend=prepend)
+ attrs = parse_attrs(msg.txt[line[0]:line[1]], attrs)
+ if attrs:
+ prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs)
+ else:
+ if default_color:
+ prepend = default_color
+ else:
+ prepend = ''
+ line_objects.append(saved)
+ return line_objects
+
+
+@singledispatch
+def build_lines(msg: BaseMessage, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]:
+ offset = msg.compute_offset(timestamp, nick_size)
+ lines = poopt.cut_text(msg.txt, width - offset - 1)
+ return generate_lines(lines, msg, default_color='')
+
+
+@build_lines.register(type(None))
+def build_separator(*args, **kwargs):
+ return [None]
+
+
+@build_lines.register(Message)
+def build_message(msg: Message, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]:
+ """
+ Build a list of lines from this message.
+ """
+ txt = msg.txt
+ if not txt:
+ return []
+ offset = msg.compute_offset(timestamp, nick_size)
+ lines = poopt.cut_text(txt, width - offset - 1)
+ generated_lines = generate_lines(lines, msg, default_color='')
+ return generated_lines
+
+
+@build_lines.register(StatusMessage)
+def build_status(msg: StatusMessage, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]:
+ msg.rebuild()
+ offset = msg.compute_offset(timestamp, nick_size)
+ lines = poopt.cut_text(msg.txt, width - offset - 1)
+ return generate_lines(lines, msg, default_color='')
+
+
+@build_lines.register(XMLLog)
+def build_xmllog(msg: XMLLog, width: int, timestamp: bool, nick_size: int = 10) -> List[Line]:
+ offset = msg.compute_offset(timestamp, nick_size)
+ lines = poopt.cut_text(msg.txt, width - offset - 1)
+ return generate_lines(lines, msg, default_color='')
+
+
+@singledispatch
+def write_pre(msg: BaseMessage, win: Win, with_timestamps: bool, nick_size: int) -> int:
+ """Write the part before text (only the timestamp)"""
+ if with_timestamps:
+ return PreMessageHelpers.write_time(win, False, msg.time)
+ return 0
+
+
+@write_pre.register(UIMessage)
+def write_pre_uimessage(msg: UIMessage, win: Win, with_timestamps: bool, nick_size: int) -> int:
+ """ Write the prefix of a ui message log
+ - timestamp (short or long)
+ - level
+ """
+ color: Optional[Tuple]
+ offset = 0
+ if with_timestamps:
+ offset += PreMessageHelpers.write_time(win, False, msg.time)
+
+ if not msg.level: # not a message, nothing to do afterwards
+ return offset
+
+ level = truncate_nick(msg.level, nick_size)
+ offset += poopt.wcswidth(level)
+ color = msg.color
+ PreMessageHelpers.write_nickname(win, level, color, False)
+ win.addstr('> ')
+ offset += 2
+ return offset
+
+
+@write_pre.register(Message)
+def write_pre_message(msg: Message, win: Win, with_timestamps: bool, nick_size: int) -> int:
+ """Write the part before the body:
+ - timestamp (short or long)
+ - ack/nack
+ - nick (with a "* " for /me)
+ - LMC number if present
+ """
+ color: Optional[Tuple]
+ offset = 0
+ if with_timestamps:
+ offset += PreMessageHelpers.write_time(win, msg.history, msg.time)
+
+ if not msg.nickname: # not a message, nothing to do afterwards
+ return offset
+
+ nick = truncate_nick(msg.nickname, nick_size)
+ offset += poopt.wcswidth(nick)
+ if msg.nick_color:
+ color = msg.nick_color
+ elif msg.user:
+ color = msg.user.color
+ else:
+ color = None
+ if msg.ack:
+ if msg.ack > 0:
+ offset += PreMessageHelpers.write_ack(win)
+ else:
+ offset += PreMessageHelpers.write_nack(win)
+ theme = get_theme()
+ if msg.me:
+ with win.colored_text(color=theme.COLOR_ME_MESSAGE):
+ win.addstr(theme.CHAR_BEFORE_NICK_ME)
+ PreMessageHelpers.write_nickname(win, nick, color, msg.highlight)
+ offset += PreMessageHelpers.write_revisions(win, msg)
+ win.addstr(theme.CHAR_AFTER_NICK_ME)
+ offset += len(theme.CHAR_BEFORE_NICK_ME) + len(theme.CHAR_AFTER_NICK_ME)
+ else:
+ PreMessageHelpers.write_nickname(win, nick, color, msg.highlight)
+ offset += PreMessageHelpers.write_revisions(win, msg)
+ win.addstr(theme.CHAR_AFTER_NICK)
+ offset += len(theme.CHAR_AFTER_NICK)
+ return offset
+
+
+@write_pre.register(XMLLog)
+def write_pre_xmllog(msg: XMLLog, win: Win, with_timestamps: bool, nick_size: int) -> int:
+ """Write the part before the stanza (timestamp + IN/OUT)"""
+ offset = 0
+ if with_timestamps:
+ offset += 1 + PreMessageHelpers.write_time(win, False, msg.time)
+ theme = get_theme()
+ if msg.incoming:
+ char = theme.CHAR_XML_IN
+ color = theme.COLOR_XML_IN
+ else:
+ char = theme.CHAR_XML_OUT
+ color = theme.COLOR_XML_OUT
+ nick = truncate_nick(char, nick_size)
+ offset += poopt.wcswidth(nick)
+ PreMessageHelpers.write_nickname(win, char, color)
+ win.addstr(' ')
+ return offset
+
+class PreMessageHelpers:
+
+ @staticmethod
+ def write_revisions(buffer: Win, msg: Message) -> int:
+ if msg.revisions:
+ color = get_theme().COLOR_REVISIONS_MESSAGE
+ with buffer.colored_text(color=color):
+ buffer.addstr('%d' % msg.revisions)
+ return ceil(log10(msg.revisions + 1))
+ return 0
+
+ @staticmethod
+ def write_ack(buffer: Win) -> int:
+ theme = get_theme()
+ color = theme.COLOR_CHAR_ACK
+ with buffer.colored_text(color=color):
+ buffer.addstr(theme.CHAR_ACK_RECEIVED)
+ buffer.addstr(' ')
+ return poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1
+
+ @staticmethod
+ def write_nack(buffer: Win) -> int:
+ theme = get_theme()
+ color = theme.COLOR_CHAR_NACK
+ with buffer.colored_text(color=color):
+ buffer.addstr(theme.CHAR_NACK)
+ buffer.addstr(' ')
+ return poopt.wcswidth(theme.CHAR_NACK) + 1
+
+ @staticmethod
+ def write_nickname(buffer: Win, nickname: str, color, highlight=False) -> None:
+ """
+ Write the nickname, using the user's color
+ and return the number of written characters
+ """
+ if not nickname:
+ return
+ attr = None
+ if highlight:
+ hl_color = get_theme().COLOR_HIGHLIGHT_NICK
+ if hl_color == "reverse":
+ attr = curses.A_REVERSE
+ else:
+ color = hl_color
+ with buffer.colored_text(color=color, attr=attr):
+ buffer.addstr(nickname)
+
+ @staticmethod
+ def write_time(buffer: Win, history: bool, time: datetime) -> int:
+ """
+ Write the date on the yth line of the window
+ """
+ if time:
+ theme = get_theme()
+ if history and time.date() != date.today():
+ format = theme.LONG_TIME_FORMAT
+ else:
+ format = theme.SHORT_TIME_FORMAT
+ time_str = time.strftime(format)
+ color = theme.COLOR_TIME_STRING
+ with buffer.colored_text(color=color):
+ buffer.addstr(time_str)
+ buffer.addstr(' ')
+ return poopt.wcswidth(time_str) + 1
+ return 0
diff --git a/poezio/ui/types.py b/poezio/ui/types.py
new file mode 100644
index 00000000..27ccbd62
--- /dev/null
+++ b/poezio/ui/types.py
@@ -0,0 +1,260 @@
+from __future__ import annotations
+
+from datetime import datetime
+from math import ceil, log10
+from typing import Optional, Tuple, Dict, Any, Callable
+
+from slixmpp import JID
+
+from poezio import poopt
+from poezio.theming import dump_tuple, get_theme
+from poezio.ui.funcs import truncate_nick
+from poezio.user import User
+
+
+class BaseMessage:
+ """Base class for all ui-related messages"""
+ __slots__ = ('txt', 'time', 'identifier')
+
+ txt: str
+ identifier: str
+ time: datetime
+
+ def __init__(self, txt: str, identifier: str = '', time: Optional[datetime] = None):
+ self.txt = txt
+ self.identifier = identifier
+ if time is not None:
+ self.time = time
+ else:
+ self.time = datetime.now()
+
+ def compute_offset(self, with_timestamps: bool, nick_size: int) -> int:
+ """Compute the offset of the message"""
+ theme = get_theme()
+ return theme.SHORT_TIME_FORMAT_LENGTH + 1
+
+
+class EndOfArchive(BaseMessage):
+ """Marker added to a buffer when we reach the end of a MAM archive"""
+
+
+class InfoMessage(BaseMessage):
+ """Information message"""
+ def __init__(self, txt: str, identifier: str = '', time: Optional[datetime] = None):
+ txt = ('\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)) + txt
+ super().__init__(txt=txt, identifier=identifier, time=time)
+
+
+class UIMessage(BaseMessage):
+ """Message displayed through poezio UI"""
+ __slots__ = ('level', 'color')
+ level: str
+ color: Optional[Tuple]
+
+ def __init__(self, txt: str, level: str):
+ BaseMessage.__init__(self, txt=txt)
+ self.level = level.capitalize()
+ colors = get_theme().INFO_COLORS
+ self.color = colors.get(level.lower(), colors.get('default', None))
+
+ def compute_offset(self, with_timestamps: bool, nick_size: int) -> int:
+ """Compute the x-position at which the message should be printed"""
+ offset = 0
+ theme = get_theme()
+ if with_timestamps:
+ offset += 1 + theme.SHORT_TIME_FORMAT_LENGTH
+ level = self.level
+ if not level: # not a message, nothing to do afterwards
+ return offset
+ level = truncate_nick(level, nick_size) or ''
+ offset += poopt.wcswidth(level)
+ offset += 2
+ return offset
+
+
+class LoggableTrait:
+ """Trait for classes of messages that should go through the logger"""
+ pass
+
+
+class PersistentInfoMessage(InfoMessage, LoggableTrait):
+ """Information message thatt will be logged"""
+ pass
+
+
+class MucOwnLeaveMessage(InfoMessage, LoggableTrait):
+ """Status message displayed on our room leave/kick/ban"""
+
+
+class MucOwnJoinMessage(InfoMessage, LoggableTrait):
+ """Status message displayed on our room join"""
+
+
+class XMLLog(BaseMessage):
+ """XML Log message"""
+ __slots__ = ('incoming')
+ incoming: bool
+
+ def __init__(
+ self,
+ txt: str,
+ incoming: bool,
+ ):
+ BaseMessage.__init__(
+ self,
+ txt=txt,
+ )
+ self.incoming = incoming
+
+ def compute_offset(self, with_timestamps: bool, nick_size: int) -> int:
+ offset = 0
+ theme = get_theme()
+ if with_timestamps:
+ offset += 1 + theme.SHORT_TIME_FORMAT_LENGTH
+ if self.incoming:
+ nick = theme.CHAR_XML_IN
+ else:
+ nick = theme.CHAR_XML_OUT
+ nick = truncate_nick(nick, nick_size) or ''
+ offset += 1 + len(nick)
+ return offset
+
+
+class StatusMessage(BaseMessage):
+ """A dynamically formatted status message"""
+ __slots__ = ('format_string', 'format_args')
+ format_string: str
+ format_args: Dict[str, Callable[[], Any]]
+
+ def __init__(self, format_string: str, format_args: dict):
+ BaseMessage.__init__(
+ self,
+ txt='',
+ )
+ self.format_string = format_string
+ self.format_args = format_args
+ self.rebuild()
+
+ def rebuild(self):
+ real_args = {}
+ for key, func in self.format_args.items():
+ real_args[key] = func()
+ self.txt = self.format_string.format(**real_args)
+
+
+class Message(BaseMessage, LoggableTrait):
+ __slots__ = ('nick_color', 'nickname', 'user', 'delayed', 'history',
+ 'highlight', 'me', 'old_message', 'revisions',
+ 'jid', 'ack')
+ nick_color: Optional[Tuple]
+ nickname: Optional[str]
+ user: Optional[User]
+ delayed: bool
+ history: bool
+ highlight: bool
+ me: bool
+ old_message: Optional[Message]
+ revisions: int
+ jid: Optional[JID]
+ ack: int
+
+ def __init__(self,
+ txt: str,
+ nickname: Optional[str],
+ time: Optional[datetime] = None,
+ nick_color: Optional[Tuple] = None,
+ delayed: bool = False,
+ history: bool = False,
+ user: Optional[User] = None,
+ identifier: Optional[str] = '',
+ highlight: bool = False,
+ old_message: Optional[Message] = None,
+ revisions: int = 0,
+ jid: Optional[JID] = None,
+ ack: int = 0) -> None:
+ """
+ Create a new Message object with parameters, check for /me messages,
+ and delayed messages
+ """
+ BaseMessage.__init__(
+ self,
+ txt=txt.replace('\t', ' ') + '\x19o',
+ identifier=identifier or '',
+ time=time,
+ )
+ if txt.startswith('/me '):
+ me = True
+ txt = '\x19%s}%s\x19o' % (dump_tuple(get_theme().COLOR_ME_MESSAGE),
+ txt[4:])
+ else:
+ me = False
+ self.txt = txt
+ self.delayed = delayed or history
+ self.history = history
+ self.nickname = nickname
+ self.nick_color = nick_color
+ self.user = user
+ self.highlight = highlight
+ self.me = me
+ self.old_message = old_message
+ self.revisions = revisions
+ self.jid = jid
+ self.ack = ack
+
+ def _other_elems(self) -> str:
+ "Helper for the repr_message function"
+ acc = []
+ fields = list(self.__slots__)
+ fields.remove('old_message')
+ for field in fields:
+ acc.append('%s=%s' % (field, repr(getattr(self, field))))
+ return 'Message(%s, %s' % (', '.join(acc), 'old_message=')
+
+ def __repr__(self) -> str:
+ """
+ repr() for the Message class, for debug purposes, since the default
+ repr() is recursive, so it can stack overflow given too many revisions
+ of a message
+ """
+ init = self._other_elems()
+ acc = [init]
+ next_message = self.old_message
+ rev = 1
+ while next_message is not None:
+ acc.append(next_message._other_elems())
+ next_message = next_message.old_message
+ rev += 1
+ acc.append('None')
+ while rev:
+ acc.append(')')
+ rev -= 1
+ return ''.join(acc)
+
+ def compute_offset(self, with_timestamps: bool, nick_size: int) -> int:
+ """Compute the x-position at which the message should be printed"""
+ offset = 0
+ theme = get_theme()
+ if with_timestamps:
+ if self.history:
+ offset += 1 + theme.LONG_TIME_FORMAT_LENGTH
+ else:
+ offset += 1 + theme.SHORT_TIME_FORMAT_LENGTH
+
+ if not self.nickname: # not a message, nothing to do afterwards
+ return offset
+
+ nick = truncate_nick(self.nickname, nick_size) or ''
+ offset += poopt.wcswidth(nick)
+ if self.ack:
+ theme = get_theme()
+ if self.ack > 0:
+ offset += poopt.wcswidth(theme.CHAR_ACK_RECEIVED) + 1
+ else:
+ offset += poopt.wcswidth(theme.CHAR_NACK) + 1
+ if self.me:
+ offset += 3
+ else:
+ offset += 2
+ if self.revisions:
+ offset += ceil(log10(self.revisions + 1))
+ return offset
diff --git a/poezio/user.py b/poezio/user.py
index 655eb0de..602ee2c8 100644
--- a/poezio/user.py
+++ b/poezio/user.py
@@ -3,16 +3,15 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Define the user class.
-An user is a MUC participant, not a roster contact (see contact.py)
+A user is a MUC participant, not a roster contact (see contact.py)
"""
import logging
from datetime import timedelta, datetime
from hashlib import md5
-from random import choice
from typing import Optional, Tuple
from poezio import xhtml, colors
@@ -26,7 +25,7 @@ ROLE_DICT = {'': 0, 'none': 0, 'visitor': 1, 'participant': 2, 'moderator': 3}
class User:
"""
- keep trace of an user in a Room
+ keep track of a user in a Room
"""
__slots__ = ('last_talked', 'jid', 'chatstate', 'affiliation', 'show',
'status', 'role', 'nick', 'color')
@@ -38,28 +37,28 @@ class User:
status: str,
role: str,
jid: JID,
- deterministic=True,
color='') -> None:
# The oldest possible time
- self.last_talked = datetime(1, 1, 1) # type: datetime
+ self.last_talked: datetime = datetime(1, 1, 1)
self.update(affiliation, show, status, role)
self.change_nick(nick)
- self.jid = jid # type: JID
- self.chatstate = None # type: Optional[str]
- self.color = (1, 1) # type: Tuple[int, int]
+ self.jid: JID = jid
+ self.chatstate: Optional[str] = None
+ self.color: Tuple[int, int] = (1, 1)
if color != '':
- self.change_color(color, deterministic)
+ self.change_color(color)
else:
- if deterministic:
- self.set_deterministic_color()
- else:
- self.color = choice(get_theme().LIST_COLOR_NICKNAMES)
+ self.set_deterministic_color()
- def set_deterministic_color(self):
+ def set_deterministic_color(self) -> None:
theme = get_theme()
if theme.ccg_palette:
# use XEP-0392 CCG
- fg_color = colors.ccg_text_to_color(theme.ccg_palette, self.nick)
+ if self.jid and self.jid.domain:
+ input_ = self.jid.bare
+ else:
+ input_ = self.nick
+ fg_color = colors.ccg_text_to_color(theme.ccg_palette, input_)
self.color = fg_color, -1
else:
mod = len(theme.LIST_COLOR_NICKNAMES)
@@ -78,14 +77,10 @@ class User:
def change_nick(self, nick: str):
self.nick = nick
- def change_color(self, color_name: Optional[str], deterministic=False):
- color = xhtml.colors.get(color_name)
+ def change_color(self, color_name: Optional[str]):
+ color = xhtml.colors.get(color_name or '')
if color is None:
- log.error('Unknown color "%s"', color_name)
- if deterministic:
- self.set_deterministic_color()
- else:
- self.color = choice(get_theme().LIST_COLOR_NICKNAMES)
+ self.set_deterministic_color()
else:
self.color = (color, -1)
@@ -93,7 +88,8 @@ class User:
"""
time: datetime object
"""
- self.last_talked = time
+ if time > self.last_talked:
+ self.last_talked = time
def has_talked_since(self, t: int) -> bool:
"""
diff --git a/poezio/utils.py b/poezio/utils.py
new file mode 100644
index 00000000..124d2002
--- /dev/null
+++ b/poezio/utils.py
@@ -0,0 +1,21 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+ Utilities
+"""
+
+from random import choice
+
+VOWELS = 'aiueo'
+CONSONANTS = 'bcdfghjklmnpqrstvwxz'
+
+
+def pronounceable(length: int = 6) -> str:
+ """Generates a pronounceable name"""
+ out = ''
+ vowels = choice((True, False))
+ for _ in range(0, length):
+ out += choice(VOWELS if vowels else CONSONANTS)
+ vowels = not vowels
+ return out
diff --git a/poezio/version.py b/poezio/version.py
new file mode 100644
index 00000000..2397b102
--- /dev/null
+++ b/poezio/version.py
@@ -0,0 +1,2 @@
+__version__ = '0.14'
+__version_info__ = (0, 14, 0)
diff --git a/poezio/windows/__init__.py b/poezio/windows/__init__.py
index 8775b0a2..bbd6dc30 100644
--- a/poezio/windows/__init__.py
+++ b/poezio/windows/__init__.py
@@ -17,7 +17,7 @@ from poezio.windows.list import ListWin, ColumnHeaderWin
from poezio.windows.misc import VerticalSeparator
from poezio.windows.muc import UserList, Topic
from poezio.windows.roster_win import RosterWin, ContactInfoWin
-from poezio.windows.text_win import BaseTextWin, TextWin, XMLTextWin
+from poezio.windows.text_win import TextWin
from poezio.windows.image import ImageWin
__all__ = [
@@ -28,5 +28,5 @@ __all__ = [
'BookmarksInfoWin', 'ConfirmStatusWin', 'HelpText', 'Input',
'HistoryInput', 'MessageInput', 'CommandInput', 'ListWin',
'ColumnHeaderWin', 'VerticalSeparator', 'UserList', 'Topic', 'RosterWin',
- 'ContactInfoWin', 'TextWin', 'XMLTextWin', 'ImageWin', 'BaseTextWin'
+ 'ContactInfoWin', 'TextWin', 'ImageWin'
]
diff --git a/poezio/windows/base_wins.py b/poezio/windows/base_wins.py
index 6dabd7b8..658e1533 100644
--- a/poezio/windows/base_wins.py
+++ b/poezio/windows/base_wins.py
@@ -7,40 +7,37 @@ the text window, the roster window, etc.
A Tab (see the poezio.tabs module) is composed of multiple Windows
"""
-TAB_WIN = None
-
-import logging
-log = logging.getLogger(__name__)
+from __future__ import annotations
import curses
+import logging
import string
-from typing import Optional, Tuple
+from contextlib import contextmanager
+from typing import Optional, Tuple, TYPE_CHECKING, cast
from poezio.theming import to_curses_attr, read_tuple
-FORMAT_CHAR = '\x19'
-# These are non-printable chars, so they should never appear in the input,
-# I guess. But maybe we can find better chars that are even less risky.
-format_chars = '\x0E\x0F\x10\x11\x12\x13\x14\x15\x16\x17\x18\x1A'
+from poezio.ui.consts import FORMAT_CHAR
+log = logging.getLogger(__name__)
-class DummyWin:
- def __getattribute__(self, name: str):
- if name != '__bool__':
- return lambda *args, **kwargs: (0, 0)
- else:
- return object.__getattribute__(self, name)
-
- def __bool__(self) -> bool:
- return False
+if TYPE_CHECKING:
+ from _curses import _CursesWindow # pylint: disable=E0611
class Win:
__slots__ = ('_win', 'height', 'width', 'y', 'x')
+ width: int
+ height: int
+ x: int
+ y: int
+
def __init__(self) -> None:
- self._win = None
+ if TAB_WIN is None:
+ raise ValueError
+ self._win: _CursesWindow = TAB_WIN
self.height, self.width = 0, 0
def _resize(self, height: int, width: int, y: int, x: int) -> None:
@@ -49,11 +46,11 @@ class Win:
return
self.height, self.width, self.x, self.y = height, width, x, y
try:
+ if TAB_WIN is None:
+ raise ValueError('TAB_WIN is None')
self._win = TAB_WIN.derwin(height, width, y, x)
except:
log.debug('DEBUG: mvwin returned ERR. Please investigate')
- if self._win is None:
- self._win = DummyWin()
def resize(self, height: int, width: int, y: int, x: int) -> None:
"""
@@ -76,6 +73,24 @@ class Win:
# of the screen.
pass
+ @contextmanager
+ def colored_text(self, color: Optional[Tuple]=None, attr: Optional[int]=None):
+ """Context manager which sets up an attr/color when inside"""
+ if color is None and attr is None:
+ yield None
+ return
+ mode: int
+ if color is not None:
+ mode = to_curses_attr(color)
+ if attr is not None:
+ mode = mode | attr
+ else:
+ # attr cannot be none here
+ mode = cast(int, attr)
+ self._win.attron(mode)
+ yield None
+ self._win.attroff(mode)
+
def addstr(self, *args) -> None:
"""
Safe call to addstr
@@ -160,3 +175,6 @@ class Win:
self.addnstr(' ' * size, size, to_curses_attr(color))
else:
self.addnstr(' ' * size, size)
+
+
+TAB_WIN: Optional[_CursesWindow] = None
diff --git a/poezio/windows/bookmark_forms.py b/poezio/windows/bookmark_forms.py
index 2940ef04..a0e57cc7 100644
--- a/poezio/windows/bookmark_forms.py
+++ b/poezio/windows/bookmark_forms.py
@@ -4,22 +4,23 @@ Windows used inthe bookmarkstab
import curses
from typing import List, Tuple, Optional
-from poezio.windows import base_wins
+from slixmpp import JID, InvalidJID
+
from poezio.windows.base_wins import Win
from poezio.windows.inputs import Input
from poezio.windows.data_forms import FieldInput, FieldInputMixin
from poezio.theming import to_curses_attr, get_theme
-from poezio.common import safeJID
from poezio.bookmarks import Bookmark, BookmarkList
class BookmarkNameInput(FieldInput, Input):
- def __init__(self, field) -> None:
+ def __init__(self, field: Bookmark) -> None:
FieldInput.__init__(self, field)
Input.__init__(self)
self.text = field.name
- self.pos = len(self.text)
+ self.pos = 0
+ self.view_pos = 0
self.color = get_theme().COLOR_NORMAL_TEXT
def save(self) -> None:
@@ -30,17 +31,24 @@ class BookmarkNameInput(FieldInput, Input):
class BookmarkJIDInput(FieldInput, Input):
- def __init__(self, field) -> None:
+ def __init__(self, field: Bookmark) -> None:
FieldInput.__init__(self, field)
Input.__init__(self)
- jid = safeJID(field.jid)
+ try:
+ jid = JID(field.jid)
+ except InvalidJID:
+ jid = JID('')
jid.resource = field.nick or None
self.text = jid.full
- self.pos = len(self.text)
+ self.pos = 0
+ self.view_pos = 0
self.color = get_theme().COLOR_NORMAL_TEXT
def save(self) -> None:
- jid = safeJID(self.get_text())
+ try:
+ jid = JID(self.get_text())
+ except InvalidJID:
+ jid = JID('')
self._field.jid = jid.bare
self._field.nick = jid.resource
@@ -49,14 +57,14 @@ class BookmarkJIDInput(FieldInput, Input):
class BookmarkMethodInput(FieldInputMixin):
- def __init__(self, field) -> None:
+ def __init__(self, field: Bookmark) -> None:
FieldInput.__init__(self, field)
Win.__init__(self)
self.options = ('local', 'remote')
# val_pos is the position of the currently selected option
self.val_pos = self.options.index(field.method)
- def do_command(self, key: str) -> None:
+ def do_command(self, key: str, raw: bool = False) -> None:
if key == 'KEY_LEFT':
if self.val_pos > 0:
self.val_pos -= 1
@@ -89,7 +97,7 @@ class BookmarkMethodInput(FieldInputMixin):
class BookmarkPasswordInput(FieldInput, Input):
- def __init__(self, field) -> None:
+ def __init__(self, field: Bookmark) -> None:
FieldInput.__init__(self, field)
Input.__init__(self)
self.text = field.password or ''
@@ -119,13 +127,13 @@ class BookmarkPasswordInput(FieldInput, Input):
class BookmarkAutojoinWin(FieldInputMixin):
- def __init__(self, field) -> None:
+ def __init__(self, field: Bookmark) -> None:
FieldInput.__init__(self, field)
Win.__init__(self)
self.last_key = 'KEY_RIGHT'
self.value = field.autojoin
- def do_command(self, key: str) -> None:
+ def do_command(self, key: str, raw: bool = False) -> None:
if key == 'KEY_LEFT' or key == 'KEY_RIGHT':
self.value = not self.value
self.last_key = key
@@ -155,14 +163,14 @@ class BookmarksWin(Win):
__slots__ = ('scroll_pos', '_current_input', 'current_horizontal_input',
'_bookmarks', 'lines')
- def __init__(self, bookmarks: BookmarkList, height: int, width: int, y: int, x: int) -> None:
- self._win = base_wins.TAB_WIN.derwin(height, width, y, x)
+ def __init__(self, bookmarks: BookmarkList) -> None:
+ Win.__init__(self)
self.scroll_pos = 0
self._current_input = 0
self.current_horizontal_input = 0
self._bookmarks = list(bookmarks)
- self.lines = [] # type: List[Tuple[BookmarkNameInput, BookmarkJIDInput, BookmarkPasswordInput, BookmarkAutojoinWin, BookmarkMethodInput]]
- for bookmark in sorted(self._bookmarks, key=lambda x: x.jid):
+ self.lines: List[Tuple[BookmarkNameInput, BookmarkJIDInput, BookmarkPasswordInput, BookmarkAutojoinWin, BookmarkMethodInput]] = []
+ for bookmark in sorted(self._bookmarks, key=lambda x: str(x.jid)):
self.lines.append((BookmarkNameInput(bookmark),
BookmarkJIDInput(bookmark),
BookmarkPasswordInput(bookmark),
@@ -190,11 +198,13 @@ class BookmarksWin(Win):
BookmarkPasswordInput(bookmark),
BookmarkAutojoinWin(bookmark),
BookmarkMethodInput(bookmark)))
- self.lines[self.current_input][
- self.current_horizontal_input].set_color(
- get_theme().COLOR_NORMAL_TEXT)
+ if len(self.lines) > 1:
+ self.lines[self.current_input][
+ self.current_horizontal_input].set_color(
+ get_theme().COLOR_NORMAL_TEXT)
self.current_horizontal_input = 0
- self.current_input = len(self.lines) - 1
+ if len(self.lines) > 1:
+ self.current_input = len(self.lines) - 1
if self.current_input - self.scroll_pos > self.height - 1:
self.scroll_pos = self.current_input - self.height + 1
self.refresh()
@@ -212,9 +222,7 @@ class BookmarksWin(Win):
return bm
def resize(self, height: int, width: int, y: int, x: int) -> None:
- self.height = height
- self.width = width
- self._win = base_wins.TAB_WIN.derwin(height, width, y, x)
+ super().resize(height, width, y, x)
# Adjust the scroll position, if resizing made the window too small
# for the cursor to be visible
while self.current_input - self.scroll_pos > self.height - 1:
@@ -245,9 +253,10 @@ class BookmarksWin(Win):
return
if self.current_input == 0:
return
+ theme = get_theme()
self.lines[self.current_input][
self.current_horizontal_input].set_color(
- get_theme().COLOR_NORMAL_TEXT)
+ theme.COLOR_NORMAL_TEXT)
self.current_input -= 1
# Adjust the scroll position if the current_input would be outside
# of the visible area
@@ -256,20 +265,21 @@ class BookmarksWin(Win):
self.refresh()
self.lines[self.current_input][
self.current_horizontal_input].set_color(
- get_theme().COLOR_SELECTED_ROW)
+ theme.COLOR_SELECTED_ROW)
def go_to_next_horizontal_input(self) -> None:
if not self.lines:
return
+ theme = get_theme()
self.lines[self.current_input][
self.current_horizontal_input].set_color(
- get_theme().COLOR_NORMAL_TEXT)
+ theme.COLOR_NORMAL_TEXT)
self.current_horizontal_input += 1
- if self.current_horizontal_input > 3:
+ if self.current_horizontal_input > 4:
self.current_horizontal_input = 0
self.lines[self.current_input][
self.current_horizontal_input].set_color(
- get_theme().COLOR_SELECTED_ROW)
+ theme.COLOR_SELECTED_ROW)
def go_to_next_page(self) -> bool:
if not self.lines:
@@ -278,9 +288,10 @@ class BookmarksWin(Win):
if self.current_input == len(self.lines) - 1:
return False
+ theme = get_theme()
self.lines[self.current_input][
self.current_horizontal_input].set_color(
- get_theme().COLOR_NORMAL_TEXT)
+ theme.COLOR_NORMAL_TEXT)
inc = min(self.height, len(self.lines) - self.current_input - 1)
if self.current_input + inc - self.scroll_pos > self.height - 1:
@@ -291,7 +302,7 @@ class BookmarksWin(Win):
self.current_input += inc
self.lines[self.current_input][
self.current_horizontal_input].set_color(
- get_theme().COLOR_SELECTED_ROW)
+ theme.COLOR_SELECTED_ROW)
return True
def go_to_previous_page(self) -> bool:
@@ -301,9 +312,10 @@ class BookmarksWin(Win):
if self.current_input == 0:
return False
+ theme = get_theme()
self.lines[self.current_input][
self.current_horizontal_input].set_color(
- get_theme().COLOR_NORMAL_TEXT)
+ theme.COLOR_NORMAL_TEXT)
dec = min(self.height, self.current_input)
self.current_input -= dec
@@ -314,7 +326,7 @@ class BookmarksWin(Win):
self.refresh()
self.lines[self.current_input][
self.current_horizontal_input].set_color(
- get_theme().COLOR_SELECTED_ROW)
+ theme.COLOR_SELECTED_ROW)
return True
def go_to_previous_horizontal_input(self) -> None:
@@ -322,19 +334,20 @@ class BookmarksWin(Win):
return
if self.current_horizontal_input == 0:
return
+ theme = get_theme()
self.lines[self.current_input][
self.current_horizontal_input].set_color(
- get_theme().COLOR_NORMAL_TEXT)
+ theme.COLOR_NORMAL_TEXT)
self.current_horizontal_input -= 1
self.lines[self.current_input][
self.current_horizontal_input].set_color(
- get_theme().COLOR_SELECTED_ROW)
+ theme.COLOR_SELECTED_ROW)
- def on_input(self, key: str) -> None:
+ def on_input(self, key: str, raw: bool = False) -> None:
if not self.lines:
return
self.lines[self.current_input][
- self.current_horizontal_input].do_command(key)
+ self.current_horizontal_input].do_command(key, raw=raw)
def refresh(self) -> None:
# store the cursor status
@@ -356,7 +369,7 @@ class BookmarksWin(Win):
continue
if i >= self.height + self.scroll_pos:
break
- for j in range(4):
+ for j in range(5):
inp[j].refresh()
if self.lines and self.current_input < self.height - 1:
@@ -377,5 +390,8 @@ class BookmarksWin(Win):
def save(self) -> None:
for line in self.lines:
- for item in line:
- item.save()
+ line[0].save()
+ line[1].save()
+ line[2].save()
+ line[3].save()
+ line[4].save()
diff --git a/poezio/windows/data_forms.py b/poezio/windows/data_forms.py
index b8dd8531..db174703 100644
--- a/poezio/windows/data_forms.py
+++ b/poezio/windows/data_forms.py
@@ -6,6 +6,7 @@ does not inherit from the Win base class), as it will create the
others when needed.
"""
+from typing import Type
from poezio.windows import base_wins
from poezio.windows.base_wins import Win
from poezio.windows.inputs import Input
@@ -189,7 +190,7 @@ class TextMultiWin(FieldInputMixin):
if not self.options or self.options[-1] != '':
self.options.append('')
else:
- self.edition_input.do_command(key)
+ self.edition_input.do_command(key, raw=raw)
self.refresh()
def refresh(self):
@@ -272,7 +273,7 @@ class ListMultiWin(FieldInputMixin):
self._field.set_answer(values)
def get_help_message(self):
- return '←, →: Switch between the value. Space: select or unselect a value'
+ return '←, →: Switch between the value. Space: select or deselect a value'
class ListSingleWin(FieldInputMixin):
@@ -330,7 +331,8 @@ class TextSingleWin(FieldInputMixin, Input):
Input.__init__(self)
self.text = field.get_value() if isinstance(field.get_value(), str)\
else ""
- self.pos = len(self.text)
+ self.pos = 0
+ self.view_pos = 0
self.color = get_theme().COLOR_NORMAL_TEXT
def reply(self):
@@ -396,10 +398,10 @@ class FormWin:
for (name, field) in self._form.getFields().items():
if field['type'] == 'hidden':
continue
- try:
+ if field['type'] not in self.input_classes:
+ input_class: Type[FieldInputMixin] = TextSingleWin
+ else:
input_class = self.input_classes[field['type']]
- except IndexError:
- continue
label = field['label']
desc = field['desc']
if field['type'] == 'fixed':
@@ -438,10 +440,11 @@ class FormWin:
return
if self.current_input == len(self.inputs) - 1:
return
+ theme = get_theme()
self.inputs[self.current_input]['input'].set_color(
- get_theme().COLOR_NORMAL_TEXT)
+ theme.COLOR_NORMAL_TEXT)
self.inputs[self.current_input]['label'].set_color(
- get_theme().COLOR_NORMAL_TEXT)
+ theme.COLOR_NORMAL_TEXT)
self.current_input += 1
jump = 0
while self.current_input + jump != len(
@@ -460,19 +463,20 @@ class FormWin:
self.scroll_pos += 1
self.refresh()
self.inputs[self.current_input]['input'].set_color(
- get_theme().COLOR_SELECTED_ROW)
+ theme.COLOR_SELECTED_ROW)
self.inputs[self.current_input]['label'].set_color(
- get_theme().COLOR_SELECTED_ROW)
+ theme.COLOR_SELECTED_ROW)
def go_to_previous_input(self):
if not self.inputs:
return
if self.current_input == 0:
return
+ theme = get_theme()
self.inputs[self.current_input]['input'].set_color(
- get_theme().COLOR_NORMAL_TEXT)
+ theme.COLOR_NORMAL_TEXT)
self.inputs[self.current_input]['label'].set_color(
- get_theme().COLOR_NORMAL_TEXT)
+ theme.COLOR_NORMAL_TEXT)
self.current_input -= 1
jump = 0
while self.current_input - jump > 0 and self.inputs[self.current_input
@@ -489,9 +493,9 @@ class FormWin:
self.refresh()
self.current_input -= jump
self.inputs[self.current_input]['input'].set_color(
- get_theme().COLOR_SELECTED_ROW)
+ theme.COLOR_SELECTED_ROW)
self.inputs[self.current_input]['label'].set_color(
- get_theme().COLOR_SELECTED_ROW)
+ theme.COLOR_SELECTED_ROW)
def on_input(self, key, raw=False):
if not self.inputs:
@@ -521,11 +525,10 @@ class FormWin:
inp['input'].refresh()
inp['label'].refresh()
if self.inputs and self.current_input < self.height - 1:
- self.inputs[self.current_input]['input'].set_color(
- get_theme().COLOR_SELECTED_ROW)
+ color = get_theme().COLOR_SELECTED_ROW
+ self.inputs[self.current_input]['input'].set_color(color)
self.inputs[self.current_input]['input'].refresh()
- self.inputs[self.current_input]['label'].set_color(
- get_theme().COLOR_SELECTED_ROW)
+ self.inputs[self.current_input]['label'].set_color(color)
self.inputs[self.current_input]['label'].refresh()
def refresh_current_input(self):
diff --git a/poezio/windows/image.py b/poezio/windows/image.py
index 75f4d588..2862d2d9 100644
--- a/poezio/windows/image.py
+++ b/poezio/windows/image.py
@@ -2,6 +2,8 @@
Defines a window which contains either an image or a border.
"""
+from __future__ import annotations
+
import curses
from io import BytesIO
@@ -11,6 +13,15 @@ try:
except ImportError:
HAS_PIL = False
+try:
+ import gi
+ gi.require_version('Rsvg', '2.0')
+ from gi.repository import Rsvg
+ import cairo
+ HAS_RSVG = True
+except (ImportError, ValueError, AttributeError):
+ HAS_RSVG = False
+
from poezio.windows.base_wins import Win
from poezio.theming import get_theme, to_curses_attr
from poezio.xhtml import _parse_css_color
@@ -19,6 +30,36 @@ from poezio.config import config
from typing import Tuple, Optional, Callable
+MAX_SIZE = 16
+
+
+def render_svg(svg: bytes) -> Optional[Image.Image]:
+ if not HAS_RSVG:
+ return None
+ try:
+ handle = Rsvg.Handle.new_from_data(svg)
+ dimensions = handle.get_dimensions()
+ biggest_dimension = max(dimensions.width, dimensions.height)
+ scale = MAX_SIZE / biggest_dimension
+ translate_x = (biggest_dimension - dimensions.width) / 2
+ translate_y = (biggest_dimension - dimensions.height) / 2
+
+ surface = cairo.ImageSurface(cairo.Format.ARGB32, MAX_SIZE, MAX_SIZE)
+ context = cairo.Context(surface)
+ context.scale(scale, scale)
+ context.translate(translate_x, translate_y)
+ handle.render_cairo(context)
+ data = surface.get_data()
+ image = Image.frombytes('RGBA', (MAX_SIZE, MAX_SIZE), data.tobytes())
+ # This is required because Cairo uses a BGRA (in host endianness)
+ # format, and PIL an ABGR (in byte order) format. Yes, this is
+ # confusing.
+ b, g, r, a = image.split()
+ return Image.merge('RGB', (r, g, b))
+ except Exception:
+ return None
+
+
class ImageWin(Win):
"""
A window which contains either an image or a border.
@@ -27,10 +68,10 @@ class ImageWin(Win):
__slots__ = ('_image', '_display_avatar')
def __init__(self) -> None:
- self._image = None # type: Optional[Image]
+ self._image: Optional[Image.Image] = None
Win.__init__(self)
- if config.get('image_use_half_blocks'):
- self._display_avatar = self._display_avatar_half_blocks # type: Callable[[int, int], None]
+ if config.getbool('image_use_half_blocks'):
+ self._display_avatar: Callable[[int, int], None] = self._display_avatar_half_blocks
else:
self._display_avatar = self._display_avatar_full_blocks
@@ -45,7 +86,14 @@ class ImageWin(Win):
if data is not None and HAS_PIL:
image_file = BytesIO(data)
try:
- image = Image.open(image_file)
+ try:
+ image = Image.open(image_file)
+ except OSError:
+ # TODO: Make the caller pass the MIME type, so we don’t
+ # have to try all renderers like that.
+ image = render_svg(data)
+ if image is None:
+ raise
except OSError:
self._display_border()
else:
diff --git a/poezio/windows/info_bar.py b/poezio/windows/info_bar.py
index 15821c10..6e6c3bbd 100644
--- a/poezio/windows/info_bar.py
+++ b/poezio/windows/info_bar.py
@@ -5,14 +5,19 @@ This window is the one listing the current opened tabs in poezio.
The GlobalInfoBar can be either horizontal or vertical
(VerticalGlobalInfoBar).
"""
+import curses
+import itertools
import logging
-log = logging.getLogger(__name__)
-import curses
+from typing import List, Optional
from poezio.config import config
from poezio.windows.base_wins import Win
from poezio.theming import get_theme, to_curses_attr
+from poezio.common import unique_prefix_of
+from poezio.colors import ccg_text_to_color
+
+log = logging.getLogger(__name__)
class GlobalInfoBar(Win):
@@ -25,42 +30,93 @@ class GlobalInfoBar(Win):
def refresh(self) -> None:
log.debug('Refresh: %s', self.__class__.__name__)
self._win.erase()
+ theme = get_theme()
self.addstr(0, 0, "[",
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ to_curses_attr(theme.COLOR_INFORMATION_BAR))
- show_names = config.get('show_tab_names')
- show_nums = config.get('show_tab_numbers')
- use_nicks = config.get('use_tab_nicks')
- show_inactive = config.get('show_inactive_tabs')
+ show_names = config.getbool('show_tab_names')
+ show_nums = config.getbool('show_tab_numbers')
+ use_nicks = config.getbool('use_tab_nicks')
+ show_inactive = config.getbool('show_inactive_tabs')
+ unique_prefix_tab_names = config.getbool('unique_prefix_tab_names')
+ autocolor_tab_names = config.getbool('autocolor_tab_names')
+
+ if unique_prefix_tab_names:
+ unique_prefixes: List[Optional[str]] = [None] * len(self.core.tabs)
+ sorted_tab_indices = sorted(
+ (str(tab.name), i)
+ for i, tab in enumerate(self.core.tabs)
+ )
+ prev_name = ""
+ for (name, i), next_item in itertools.zip_longest(
+ sorted_tab_indices, sorted_tab_indices[1:]):
+ # TODO: should this maybe use something smarter than .lower()?
+ # something something stringprep?
+ name = name.lower()
+ prefix_prev = unique_prefix_of(name, prev_name)
+ if next_item is not None:
+ prefix_next = unique_prefix_of(name, next_item[0].lower())
+ else:
+ prefix_next = name[0]
+
+ # to be unique, we have to use the longest prefix
+ if len(prefix_next) > len(prefix_prev):
+ prefix = prefix_next
+ else:
+ prefix = prefix_prev
+
+ unique_prefixes[i] = prefix
+ prev_name = name
for nb, tab in enumerate(self.core.tabs):
if not tab:
continue
color = tab.color
- if not show_inactive and color is get_theme().COLOR_TAB_NORMAL:
+ if not show_inactive and color is theme.COLOR_TAB_NORMAL and (
+ tab.priority < 0):
continue
+ if autocolor_tab_names:
+ # TODO: in case of private MUC conversations, we should try to
+ # get hold of more information to make the colour the same as
+ # the nickname colour in the MUC.
+ fgcolor, bgcolor, *flags = color
+ # this is fugly, but I’m not sure how to improve it... since
+ # apparently the state is only kept in the color -.-
+ if (color == theme.COLOR_TAB_HIGHLIGHT or
+ color == theme.COLOR_TAB_PRIVATE):
+ fgcolor = ccg_text_to_color(theme.ccg_palette, tab.name)
+ bgcolor = -1
+ flags = theme.MODE_TAB_IMPORTANT
+ elif color == theme.COLOR_TAB_NEW_MESSAGE:
+ fgcolor = ccg_text_to_color(theme.ccg_palette, tab.name)
+ bgcolor = -1
+ flags = theme.MODE_TAB_NORMAL
+
+ color = (fgcolor, bgcolor) + tuple(flags)
try:
if show_nums or not show_names:
self.addstr("%s" % str(nb), to_curses_attr(color))
if show_names:
self.addstr(' ', to_curses_attr(color))
if show_names:
- if use_nicks:
+ if unique_prefix_tab_names:
+ self.addstr(unique_prefixes[nb], to_curses_attr(color))
+ elif use_nicks:
self.addstr("%s" % str(tab.get_nick()),
to_curses_attr(color))
else:
self.addstr("%s" % tab.name, to_curses_attr(color))
self.addstr("|",
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ to_curses_attr(theme.COLOR_INFORMATION_BAR))
except: # end of line
break
(y, x) = self._win.getyx()
self.addstr(y, x - 1, '] ',
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ to_curses_attr(theme.COLOR_INFORMATION_BAR))
(y, x) = self._win.getyx()
remaining_size = self.width - x
self.addnstr(' ' * remaining_size, remaining_size,
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ to_curses_attr(theme.COLOR_INFORMATION_BAR))
self._refresh()
@@ -76,17 +132,24 @@ class VerticalGlobalInfoBar(Win):
height, width = self._win.getmaxyx()
self._win.erase()
sorted_tabs = [tab for tab in self.core.tabs if tab]
- if not config.get('show_inactive_tabs'):
+ theme = get_theme()
+ if not config.getbool('show_inactive_tabs'):
sorted_tabs = [
tab for tab in sorted_tabs
- if tab.vertical_color != get_theme().COLOR_VERTICAL_TAB_NORMAL
+ if (
+ tab.vertical_color != theme.COLOR_VERTICAL_TAB_NORMAL or
+ tab.priority > 0
+ )
]
nb_tabs = len(sorted_tabs)
- use_nicks = config.get('use_tab_nicks')
+ use_nicks = config.getbool('use_tab_nicks')
if nb_tabs >= height:
+ # TODO: As sorted_tabs filters out gap tabs this ensures pos is
+ # always set, preventing UnboundLocalError. Now is this how this
+ # should be fixed.
+ pos = 0
for y, tab in enumerate(sorted_tabs):
- if tab.vertical_color == get_theme(
- ).COLOR_VERTICAL_TAB_CURRENT:
+ if tab.vertical_color == theme.COLOR_VERTICAL_TAB_CURRENT:
pos = y
break
# center the current tab as much as possible
@@ -96,20 +159,20 @@ class VerticalGlobalInfoBar(Win):
sorted_tabs = sorted_tabs[-height:]
else:
sorted_tabs = sorted_tabs[pos - height // 2:pos + height // 2]
- asc_sort = (config.get('vertical_tab_list_sort') == 'asc')
+ asc_sort = (config.getstr('vertical_tab_list_sort') == 'asc')
for y, tab in enumerate(sorted_tabs):
color = tab.vertical_color
if asc_sort:
y = height - y - 1
self.addstr(y, 0, "%2d" % tab.nb,
- to_curses_attr(get_theme().COLOR_VERTICAL_TAB_NUMBER))
+ to_curses_attr(theme.COLOR_VERTICAL_TAB_NUMBER))
self.addstr('.')
if use_nicks:
self.addnstr("%s" % tab.get_nick(), width - 4,
to_curses_attr(color))
else:
self.addnstr("%s" % tab.name, width - 4, to_curses_attr(color))
- separator = to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR)
+ separator = to_curses_attr(theme.COLOR_VERTICAL_SEPARATOR)
self._win.attron(separator)
self._win.vline(0, width - 1, curses.ACS_VLINE, height)
self._win.attroff(separator)
diff --git a/poezio/windows/info_wins.py b/poezio/windows/info_wins.py
index abc0a401..227dc115 100644
--- a/poezio/windows/info_wins.py
+++ b/poezio/windows/info_wins.py
@@ -3,15 +3,27 @@ Module defining all the "info wins", ie the bar which is on top of the
info buffer in normal tabs
"""
+from __future__ import annotations
+
+from typing import Optional, Dict, TYPE_CHECKING, Any
+
import logging
-log = logging.getLogger(__name__)
-from poezio.common import safeJID
+from slixmpp import JID, InvalidJID
+
from poezio.config import config
from poezio.windows.base_wins import Win
-from poezio.windows.funcs import truncate_nick
+from poezio.ui.funcs import truncate_nick
from poezio.theming import get_theme, to_curses_attr
+from poezio.colors import ccg_text_to_color
+
+if TYPE_CHECKING:
+ from poezio.user import User
+ from poezio.tabs import MucTab
+ from poezio.windows import TextWin
+
+log = logging.getLogger(__name__)
class InfoWin(Win):
@@ -92,11 +104,18 @@ class PrivateInfoWin(InfoWin):
to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
def write_room_name(self, name):
- jid = safeJID(name)
+ # TODO: autocolour this too, but we need more info about the occupant
+ # (whether we know its real jid) and the room (whether it is
+ # anonymous) to provide correct colouring.
+ try:
+ jid = JID(name)
+ except InvalidJID:
+ jid = JID('')
room_name, nick = jid.bare, jid.resource
- self.addstr(nick, to_curses_attr(get_theme().COLOR_PRIVATE_NAME))
+ theme = get_theme()
+ self.addstr(nick, to_curses_attr(theme.COLOR_PRIVATE_NAME))
txt = ' from room %s' % room_name
- self.addstr(txt, to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.addstr(txt, to_curses_attr(theme.COLOR_INFORMATION_BAR))
def write_chatstate(self, state):
if state:
@@ -119,15 +138,16 @@ class MucListInfoWin(InfoWin):
def refresh(self, name=None, window=None):
log.debug('Refresh: %s', self.__class__.__name__)
self._win.erase()
+ theme = get_theme()
if name:
self.addstr(name,
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ to_curses_attr(theme.COLOR_INFORMATION_BAR))
else:
self.addstr(self.message,
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ to_curses_attr(theme.COLOR_INFORMATION_BAR))
if window:
self.print_scroll_position(window)
- self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ self.finish_line(theme.COLOR_INFORMATION_BAR)
self._refresh()
@@ -147,7 +167,10 @@ class ConversationInfoWin(InfoWin):
# from someone not in our roster. In this case, we display
# only the maximum information from the message we can get.
log.debug('Refresh: %s', self.__class__.__name__)
- jid = safeJID(jid)
+ try:
+ jid = JID(jid)
+ except InvalidJID:
+ jid = JID('')
if contact:
if jid.resource:
resource = contact[jid.full]
@@ -161,7 +184,7 @@ class ConversationInfoWin(InfoWin):
# resource can now be a Resource: user is in the roster and online
# or resource is None: user is in the roster but offline
self._win.erase()
- if config.get('show_jid_in_conversations'):
+ if config.getbool('show_jid_in_conversations'):
self.write_contact_jid(jid)
self.write_contact_information(contact)
self.write_resource_information(resource)
@@ -176,9 +199,9 @@ class ConversationInfoWin(InfoWin):
Write all information added by plugins by getting the
value returned by the callbacks.
"""
+ color = to_curses_attr(get_theme().COLOR_INFORMATION_BAR)
for plugin in information.values():
- self.addstr(plugin(jid),
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.addstr(plugin(jid), color)
def write_resource_information(self, resource):
"""
@@ -188,38 +211,58 @@ class ConversationInfoWin(InfoWin):
presence = "unavailable"
else:
presence = resource.presence
- color = get_theme().color_show(presence)
+ theme = get_theme()
+ color = theme.color_show(presence)
if not presence:
- presence = get_theme().CHAR_STATUS
- self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ presence = theme.CHAR_STATUS
+ self.addstr('[', to_curses_attr(theme.COLOR_INFORMATION_BAR))
self.addstr(presence, to_curses_attr(color))
if resource and resource.status:
shortened = resource.status[:20] + (resource.status[:20] and '…')
self.addstr(' %s' % shortened,
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
- self.addstr(']', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ to_curses_attr(theme.COLOR_INFORMATION_BAR))
+ self.addstr(']', to_curses_attr(theme.COLOR_INFORMATION_BAR))
def write_contact_information(self, contact):
"""
Write the information about the contact
"""
+ theme = get_theme()
+ color = to_curses_attr(theme.COLOR_INFORMATION_BAR)
+ if config.get('autocolor_tab_names') and contact is not None:
+ name_color = (
+ ccg_text_to_color(theme.ccg_palette, str(contact.bare_jid)),
+ -1,
+ theme.MODE_TAB_NAME,
+ )
+ else:
+ name_color = color
+
if not contact:
- self.addstr("(contact not in roster)",
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.addstr("(contact not in roster)", color)
return
display_name = contact.name
if display_name:
- self.addstr('%s ' % (display_name),
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.addstr('%s ' % (display_name), name_color)
def write_contact_jid(self, jid):
"""
Just write the jid that we are talking to
"""
- self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
- self.addstr(jid.full,
- to_curses_attr(get_theme().COLOR_CONVERSATION_NAME))
- self.addstr('] ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ theme = get_theme()
+ color = to_curses_attr(theme.COLOR_INFORMATION_BAR)
+ if config.get('autocolor_tab_names'):
+ name_color = (
+ ccg_text_to_color(theme.ccg_palette, str(contact.jid)),
+ -1,
+ theme.MODE_TAB_NAME,
+ )
+ else:
+ name_color = theme.COLOR_CONVERSATION_NAME
+
+ self.addstr('[', color)
+ self.addstr(jid.full, to_curses_attr(name_color))
+ self.addstr('] ', color)
def write_chatstate(self, state):
if state:
@@ -236,14 +279,16 @@ class DynamicConversationInfoWin(ConversationInfoWin):
"""
log.debug("write_contact_jid DynamicConversationInfoWin, jid: %s",
jid.resource)
- self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ theme = get_theme()
+ color = to_curses_attr(theme.COLOR_INFORMATION_BAR)
+ self.addstr('[', color)
self.addstr(jid.bare,
- to_curses_attr(get_theme().COLOR_CONVERSATION_NAME))
+ to_curses_attr(theme.COLOR_CONVERSATION_NAME))
if jid.resource:
self.addstr(
"/%s" % (jid.resource, ),
- to_curses_attr(get_theme().COLOR_CONVERSATION_RESOURCE))
- self.addstr('] ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ to_curses_attr(theme.COLOR_CONVERSATION_RESOURCE))
+ self.addstr('] ', color)
class MucInfoWin(InfoWin):
@@ -254,10 +299,16 @@ class MucInfoWin(InfoWin):
__slots__ = ()
- def __init__(self):
+ def __init__(self) -> None:
InfoWin.__init__(self)
- def refresh(self, room, window=None, user=None, information=None):
+ def refresh(
+ self,
+ room: MucTab,
+ window: Optional[TextWin] = None,
+ user: Optional[User] = None,
+ information: Optional[Dict[str, Any]] = None
+ ) -> None:
log.debug('Refresh: %s', self.__class__.__name__)
self._win.erase()
self.write_room_name(room)
@@ -277,22 +328,34 @@ class MucInfoWin(InfoWin):
Write all information added by plugins by getting the
value returned by the callbacks.
"""
+ color = to_curses_attr(get_theme().COLOR_INFORMATION_BAR)
for plugin in information.values():
- self.addstr(plugin(jid),
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.addstr(plugin(jid), color)
def write_room_name(self, room):
- self.addstr('[', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ theme = get_theme()
+ color = to_curses_attr(theme.COLOR_INFORMATION_BAR)
+ label_color = theme.COLOR_GROUPCHAT_NAME
+
+ if config.get('autocolor_tab_names'):
+ label_color = ccg_text_to_color(
+ theme.ccg_palette,
+ room.jid.bare,
+ ), -1, theme.MODE_TAB_NAME
+
+ self.addstr('[', color)
self.addstr(room.name,
- to_curses_attr(get_theme().COLOR_GROUPCHAT_NAME))
- self.addstr(']', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ to_curses_attr(label_color))
+ self.addstr(']', color)
def write_participants_number(self, room):
- self.addstr('{', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ theme = get_theme()
+ color = to_curses_attr(theme.COLOR_INFORMATION_BAR)
+ self.addstr('{', color)
self.addstr(
str(len(room.users)),
- to_curses_attr(get_theme().COLOR_GROUPCHAT_NAME))
- self.addstr('} ', to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ to_curses_attr(theme.COLOR_GROUPCHAT_NAME))
+ self.addstr('} ', color)
def write_disconnected(self, room):
"""
@@ -338,7 +401,10 @@ class ConversationStatusMessageWin(InfoWin):
def refresh(self, jid, contact):
log.debug('Refresh: %s', self.__class__.__name__)
- jid = safeJID(jid)
+ try:
+ jid = JID(jid)
+ except InvalidJID:
+ jid = JID('')
if contact:
if jid.resource:
resource = contact[jid.full]
@@ -386,10 +452,11 @@ class ConfirmStatusWin(Win):
def refresh(self):
log.debug('Refresh: %s', self.__class__.__name__)
self._win.erase()
+ theme = get_theme()
if self.critical:
- color = get_theme().COLOR_WARNING_PROMPT
+ color = theme.COLOR_WARNING_PROMPT
else:
- color = get_theme().COLOR_INFORMATION_BAR
+ color = theme.COLOR_INFORMATION_BAR
c_color = to_curses_attr(color)
self.addstr(self.text, c_color)
self.finish_line(color)
diff --git a/poezio/windows/input_placeholders.py b/poezio/windows/input_placeholders.py
index 4d414636..3ec57583 100644
--- a/poezio/windows/input_placeholders.py
+++ b/poezio/windows/input_placeholders.py
@@ -23,7 +23,7 @@ class HelpText(Win):
def __init__(self, text: str = '') -> None:
Win.__init__(self)
- self.txt = text # type: str
+ self.txt: str = text
def refresh(self, txt: Optional[str] = None) -> None:
log.debug('Refresh: %s', self.__class__.__name__)
diff --git a/poezio/windows/inputs.py b/poezio/windows/inputs.py
index c0c73419..01b94ac0 100644
--- a/poezio/windows/inputs.py
+++ b/poezio/windows/inputs.py
@@ -5,13 +5,14 @@ Text inputs.
import curses
import logging
import string
-from typing import List, Dict, Callable, Optional
+from typing import List, Dict, Callable, Optional, ClassVar
from poezio import keyboard
from poezio import common
from poezio import poopt
-from poezio.windows.base_wins import Win, format_chars
-from poezio.windows.funcs import find_first_format_char
+from poezio.windows.base_wins import Win
+from poezio.ui.consts import FORMAT_CHARS
+from poezio.ui.funcs import find_first_format_char
from poezio.config import config
from poezio.theming import to_curses_attr
@@ -40,7 +41,7 @@ class Input(Win):
# it easy cut and paste text between various input
def __init__(self) -> None:
- self.key_func = {
+ self.key_func: Dict[str, Callable] = {
"KEY_LEFT": self.key_left,
"KEY_RIGHT": self.key_right,
"KEY_END": self.key_end,
@@ -65,7 +66,7 @@ class Input(Win):
'^?': self.key_backspace,
"M-^?": self.delete_word,
# '^J': self.add_line_break,
- } # type: Dict[str, Callable]
+ }
Win.__init__(self)
self.text = ''
self.pos = 0 # The position of the “cursor” in the text
@@ -75,8 +76,8 @@ class Input(Win):
# screen
self.on_input = DEFAULT_ON_INPUT # callback called on any key pressed
self.color = None # use this color on addstr
- self.last_completion = None # type: Optional[str]
- self.hit_list = [] # type: List[str]
+ self.last_completion: Optional[str] = None
+ self.hit_list: List[str] = []
def on_delete(self) -> None:
"""
@@ -109,7 +110,7 @@ class Input(Win):
"""
if self.pos == 0:
return True
- separators = string.punctuation + ' '
+ separators = string.punctuation + ' ' + '\n'
while self.pos > 0 and self.text[self.pos - 1] in separators:
self.key_left()
while self.pos > 0 and self.text[self.pos - 1] not in separators:
@@ -122,7 +123,7 @@ class Input(Win):
"""
if self.is_cursor_at_end():
return True
- separators = string.punctuation + ' '
+ separators = string.punctuation + ' ' + '\n'
while not self.is_cursor_at_end() and self.text[self.pos] in separators:
self.key_right()
while not self.is_cursor_at_end() and self.text[self.
@@ -134,7 +135,7 @@ class Input(Win):
"""
Delete the word just before the cursor
"""
- separators = string.punctuation + ' '
+ separators = string.punctuation + ' ' + '\n'
while self.pos > 0 and self.text[self.pos - 1] in separators:
self.key_backspace()
while self.pos > 0 and self.text[self.pos - 1] not in separators:
@@ -145,7 +146,7 @@ class Input(Win):
"""
Delete the word just after the cursor
"""
- separators = string.punctuation + ' '
+ separators = string.punctuation + ' ' + '\n'
while not self.is_cursor_at_end() and self.text[self.pos] in separators:
self.key_dc()
while not self.is_cursor_at_end() and self.text[self.
@@ -408,12 +409,14 @@ class Input(Win):
Normal completion
"""
pos = self.pos
- if pos < len(
- self.text) and after.endswith(' ') and self.text[pos] == ' ':
+ if pos < len(self.text) and after.endswith(' ') and self.text[pos] in ' \n':
after = after[:
-1] # remove the last space if we are already on a space
if not self.last_completion:
space_before_cursor = self.text.rfind(' ', 0, pos)
+ line_before_cursor = self.text.rfind('\n', 0, pos)
+ if line_before_cursor > space_before_cursor:
+ space_before_cursor = line_before_cursor
if space_before_cursor != -1:
begin = self.text[space_before_cursor + 1:pos]
else:
@@ -487,7 +490,7 @@ class Input(Win):
(\x0E to \x19 instead of \x19 + attr). We do not use any }
char in this version
"""
- chars = format_chars + '\n'
+ chars = FORMAT_CHARS + '\n'
if y is not None and x is not None:
self.move(y, x)
format_char = find_first_format_char(text, chars)
@@ -497,7 +500,7 @@ class Input(Win):
if text[format_char] == '\n':
attr_char = '|'
else:
- attr_char = self.text_attributes[format_chars.index(
+ attr_char = self.text_attributes[FORMAT_CHARS.index(
text[format_char])]
self.addstr(text[:format_char])
self.addstr(attr_char, curses.A_REVERSE)
@@ -589,9 +592,10 @@ class HistoryInput(Input):
An input with colors and stuff, plus an history
^R allows to search inside the history (as in a shell)
"""
- __slots__ = ('help_message', 'histo_pos', 'current_completed', 'search')
+ __slots__ = ('help_message', 'histo_pos', 'current_completed', 'search',
+ 'history')
- history = [] # type: List[str]
+ global_history: ClassVar[List[str]] = []
def __init__(self) -> None:
Input.__init__(self)
@@ -600,8 +604,10 @@ class HistoryInput(Input):
self.current_completed = ''
self.key_func['^R'] = self.toggle_search
self.search = False
- if config.get('separate_history'):
- self.history = [] # type: List[str]
+ if config.getbool('separate_history'):
+ self.history: List[str] = []
+ else:
+ self.history = self.__class__.global_history
def toggle_search(self) -> None:
if self.help_message:
@@ -678,7 +684,7 @@ class MessageInput(HistoryInput):
Also letting the user enter colors or other text markups
"""
# The history is common to all MessageInput
- history = [] # type: List[str]
+ global_history: ClassVar[List[str]] = []
def __init__(self) -> None:
HistoryInput.__init__(self)
@@ -695,7 +701,7 @@ class MessageInput(HistoryInput):
def cb(attr_char):
if attr_char in self.text_attributes:
- char = format_chars[self.text_attributes.index(attr_char)]
+ char = FORMAT_CHARS[self.text_attributes.index(attr_char)]
self.do_command(char, False)
self.rewrite_text()
@@ -724,7 +730,7 @@ class CommandInput(HistoryInput):
HelpMessage when a command is started
The on_input callback
"""
- history = [] # type: List[str]
+ global_history: ClassVar[List[str]] = []
def __init__(self, help_message: str, on_abort, on_success, on_input=None) -> None:
HistoryInput.__init__(self)
diff --git a/poezio/windows/list.py b/poezio/windows/list.py
index f03dcf6a..1c5d834f 100644
--- a/poezio/windows/list.py
+++ b/poezio/windows/list.py
@@ -24,10 +24,10 @@ class ListWin(Win):
def __init__(self, columns: Dict[str, int], with_headers: bool = True) -> None:
Win.__init__(self)
- self._columns = columns # type: Dict[str, int]
- self._columns_sizes = {} # type: Dict[str, int]
+ self._columns: Dict[str, int] = columns
+ self._columns_sizes: Dict[str, int] = {}
self.sorted_by = (None, None) # for example: ('name', '↑')
- self.lines = [] # type: List[str]
+ self.lines: List[str] = []
self._selected_row = 0
self._starting_pos = 0 # The column number from which we start the refresh
@@ -40,7 +40,7 @@ class ListWin(Win):
def empty(self) -> None:
"""
- emtpy the list and reset some important values as well
+ empty the list and reset some important values as well
"""
self.lines = []
self._selected_row = 0
@@ -94,6 +94,7 @@ class ListWin(Win):
log.debug('Refresh: %s', self.__class__.__name__)
self._win.erase()
lines = self.lines[self._starting_pos:self._starting_pos + self.height]
+ color = to_curses_attr(get_theme().COLOR_INFORMATION_BAR)
for y, line in enumerate(lines):
x = 0
for col in self._columns.items():
@@ -106,9 +107,7 @@ class ListWin(Win):
if not txt:
continue
if line is self.lines[self._selected_row]:
- self.addstr(
- y, x, txt[:size],
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ self.addstr(y, x, txt[:size], color)
else:
self.addstr(y, x, txt[:size])
x += size
@@ -174,7 +173,7 @@ class ColumnHeaderWin(Win):
def __init__(self, columns: List[str]) -> None:
Win.__init__(self)
self._columns = columns
- self._columns_sizes = {} # type: Dict[str, int]
+ self._columns_sizes: Dict[str, int] = {}
self._column_sel = ''
self._column_order = ''
self._column_order_asc = False
@@ -189,23 +188,24 @@ class ColumnHeaderWin(Win):
log.debug('Refresh: %s', self.__class__.__name__)
self._win.erase()
x = 0
+ theme = get_theme()
for col in self._columns:
txt = col
if col in self._column_order:
if self._column_order_asc:
- txt += get_theme().CHAR_COLUMN_ASC
+ txt += theme.CHAR_COLUMN_ASC
else:
- txt += get_theme().CHAR_COLUMN_DESC
+ txt += theme.CHAR_COLUMN_DESC
#⇓⇑↑↓⇧⇩▲▼
size = self._columns_sizes[col]
txt += ' ' * (size - len(txt))
if col in self._column_sel:
self.addstr(
0, x, txt,
- to_curses_attr(get_theme().COLOR_COLUMN_HEADER_SEL))
+ to_curses_attr(theme.COLOR_COLUMN_HEADER_SEL))
else:
self.addstr(0, x, txt,
- to_curses_attr(get_theme().COLOR_COLUMN_HEADER))
+ to_curses_attr(theme.COLOR_COLUMN_HEADER))
x += size
self._refresh()
diff --git a/poezio/windows/misc.py b/poezio/windows/misc.py
index 6c04b814..a621b61d 100644
--- a/poezio/windows/misc.py
+++ b/poezio/windows/misc.py
@@ -22,8 +22,10 @@ class VerticalSeparator(Win):
__slots__ = ()
def rewrite_line(self) -> None:
- self._win.vline(0, 0, curses.ACS_VLINE, self.height,
- to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR))
+ self._win.vline(
+ 0, 0, curses.ACS_VLINE, self.height,
+ to_curses_attr(get_theme().COLOR_VERTICAL_SEPARATOR)
+ ) # type: ignore
self._refresh()
def refresh(self) -> None:
@@ -37,7 +39,7 @@ class SimpleTextWin(Win):
def __init__(self, text) -> None:
Win.__init__(self)
self._text = text
- self.built_lines = [] # type: List[str]
+ self.built_lines: List[str] = []
def rebuild_text(self) -> None:
"""
diff --git a/poezio/windows/muc.py b/poezio/windows/muc.py
index 72dc602c..0e95ac1b 100644
--- a/poezio/windows/muc.py
+++ b/poezio/windows/muc.py
@@ -33,7 +33,7 @@ class UserList(Win):
def __init__(self) -> None:
Win.__init__(self)
self.pos = 0
- self.cache = [] # type: List[CachedUser]
+ self.cache: List[CachedUser] = []
def scroll_up(self) -> bool:
self.pos += self.height - 1
@@ -65,14 +65,14 @@ class UserList(Win):
def refresh(self, users: List[User]) -> None:
log.debug('Refresh: %s', self.__class__.__name__)
- if config.get('hide_user_list'):
+ if config.getbool('hide_user_list'):
return # do not refresh if this win is hidden.
if len(users) < self.height:
self.pos = 0
elif self.pos >= len(users) - self.height and self.pos != 0:
self.pos = len(users) - self.height
self._win.erase()
- asc_sort = (config.get('user_list_sort').lower() == 'asc')
+ asc_sort = (config.getstr('user_list_sort').lower() == 'asc')
if asc_sort:
y, _ = self._win.getmaxyx()
y -= 1
@@ -110,15 +110,16 @@ class UserList(Win):
self.addstr(y, 1, symbol, to_curses_attr(color))
def draw_status_chatstate(self, y: int, user: User) -> None:
- show_col = get_theme().color_show(user.show)
+ theme = get_theme()
+ show_col = theme.color_show(user.show)
if user.chatstate == 'composing':
- char = get_theme().CHAR_CHATSTATE_COMPOSING
+ char = theme.CHAR_CHATSTATE_COMPOSING
elif user.chatstate == 'active':
- char = get_theme().CHAR_CHATSTATE_ACTIVE
+ char = theme.CHAR_CHATSTATE_ACTIVE
elif user.chatstate == 'paused':
- char = get_theme().CHAR_CHATSTATE_PAUSED
+ char = theme.CHAR_CHATSTATE_PAUSED
else:
- char = get_theme().CHAR_STATUS
+ char = theme.CHAR_STATUS
self.addstr(y, 0, char, to_curses_attr(show_col))
def resize(self, height: int, width: int, y: int, x: int) -> None:
@@ -138,17 +139,18 @@ class Topic(Win):
def refresh(self, topic: Optional[str] = None) -> None:
log.debug('Refresh: %s', self.__class__.__name__)
+ theme = get_theme()
self._win.erase()
if topic is not None:
msg = topic[:self.width - 1]
else:
msg = self._message[:self.width - 1]
- self.addstr(0, 0, msg, to_curses_attr(get_theme().COLOR_TOPIC_BAR))
+ self.addstr(0, 0, msg, to_curses_attr(theme.COLOR_TOPIC_BAR))
_, x = self._win.getyx()
remaining_size = self.width - x
if remaining_size:
self.addnstr(' ' * remaining_size, remaining_size,
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
+ to_curses_attr(theme.COLOR_INFORMATION_BAR))
self._refresh()
def set_message(self, message) -> None:
diff --git a/poezio/windows/roster_win.py b/poezio/windows/roster_win.py
index 3c62ea0a..dfdc9b9b 100644
--- a/poezio/windows/roster_win.py
+++ b/poezio/windows/roster_win.py
@@ -6,11 +6,10 @@ import logging
log = logging.getLogger(__name__)
from datetime import datetime
-from typing import Optional, List, Union, Dict
+from typing import Optional, List, Union
from poezio.windows.base_wins import Win
-from poezio import common
from poezio.config import config
from poezio.contact import Contact, Resource
from poezio.roster import Roster, RosterGroup
@@ -26,8 +25,8 @@ class RosterWin(Win):
Win.__init__(self)
self.pos = 0 # cursor position in the contact list
self.start_pos = 1 # position of the start of the display
- self.selected_row = None # type: Optional[Row]
- self.roster_cache = [] # type: List[Row]
+ self.selected_row: Optional[Row] = None
+ self.roster_cache: List[Row] = []
@property
def roster_len(self) -> int:
@@ -99,13 +98,13 @@ class RosterWin(Win):
# This is a search
if roster.contact_filter is not roster.DEFAULT_FILTER:
self.roster_cache = []
- sort = config.get('roster_sort', 'jid:show') or 'jid:show'
+ sort = config.getstr('roster_sort') or 'jid:show'
for contact in roster.get_contacts_sorted_filtered(sort):
self.roster_cache.append(contact)
else:
- show_offline = config.get('roster_show_offline')
- sort = config.get('roster_sort') or 'jid:show'
- group_sort = config.get('roster_group_sort') or 'name'
+ show_offline = config.getbool('roster_show_offline')
+ sort = config.getstr('roster_sort') or 'jid:show'
+ group_sort = config.getstr('roster_group_sort') or 'name'
self.roster_cache = []
# build the cache
for group in roster.get_groups(group_sort):
@@ -155,9 +154,9 @@ class RosterWin(Win):
self.height]
options = {
- 'show_roster_sub': config.get('show_roster_subscriptions'),
- 'show_s2s_errors': config.get('show_s2s_errors'),
- 'show_roster_jids': config.get('show_roster_jids')
+ 'show_roster_sub': config.getbool('show_roster_subscriptions'),
+ 'show_s2s_errors': config.getbool('show_s2s_errors'),
+ 'show_roster_jids': config.getbool('show_roster_jids')
}
for item in roster_view:
@@ -171,7 +170,7 @@ class RosterWin(Win):
group = item.name
elif isinstance(item, Contact):
self.draw_contact_line(y, item, draw_selected, group,
- **options)
+ **options) # type: ignore
elif isinstance(item, Resource):
self.draw_resource_line(y, item, draw_selected)
@@ -195,18 +194,20 @@ class RosterWin(Win):
"""
The header at the top
"""
+ color = get_theme().COLOR_INFORMATION_BAR
self.addstr(
'Roster: %s/%s contacts' % (roster.get_nb_connected_contacts(),
len(roster)),
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
- self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ to_curses_attr(color))
+ self.finish_line(color)
def draw_group(self, y: int, group: RosterGroup, colored: bool) -> None:
"""
Draw a groupname on a line
"""
+ color = to_curses_attr(get_theme().COLOR_SELECTED_ROW)
if colored:
- self._win.attron(to_curses_attr(get_theme().COLOR_SELECTED_ROW))
+ self._win.attron(color)
if group.folded:
self.addstr(y, 0, '[+] ')
else:
@@ -217,7 +218,7 @@ class RosterWin(Win):
self.truncate_name(group.name,
len(contacts) + 4) + contacts)
if colored:
- self._win.attroff(to_curses_attr(get_theme().COLOR_SELECTED_ROW))
+ self._win.attroff(color)
self.finish_line()
def truncate_name(self, name, added):
@@ -263,17 +264,9 @@ class RosterWin(Win):
added += 4
if contact.ask:
- added += len(get_theme().CHAR_ROSTER_ASKED)
+ added += len(theme.CHAR_ROSTER_ASKED)
if show_s2s_errors and contact.error:
- added += len(get_theme().CHAR_ROSTER_ERROR)
- if contact.tune:
- added += len(get_theme().CHAR_ROSTER_TUNE)
- if contact.mood:
- added += len(get_theme().CHAR_ROSTER_MOOD)
- if contact.activity:
- added += len(get_theme().CHAR_ROSTER_ACTIVITY)
- if contact.gaming:
- added += len(get_theme().CHAR_ROSTER_GAMING)
+ added += len(theme.CHAR_ROSTER_ERROR)
if show_roster_sub in ('all', 'incomplete', 'to', 'from', 'both',
'none'):
added += len(
@@ -285,13 +278,13 @@ class RosterWin(Win):
elif contact.name and contact.name != contact.bare_jid:
display_name = '%s (%s)' % (contact.name, contact.bare_jid)
else:
- display_name = contact.bare_jid
+ display_name = str(contact.bare_jid)
display_name = self.truncate_name(display_name, added) + nb
if colored:
self.addstr(display_name,
- to_curses_attr(get_theme().COLOR_SELECTED_ROW))
+ to_curses_attr(theme.COLOR_SELECTED_ROW))
else:
self.addstr(display_name)
@@ -302,34 +295,23 @@ class RosterWin(Win):
contact.subscription, keep=show_roster_sub),
to_curses_attr(theme.COLOR_ROSTER_SUBSCRIPTION))
if contact.ask:
- self.addstr(get_theme().CHAR_ROSTER_ASKED,
- to_curses_attr(get_theme().COLOR_IMPORTANT_TEXT))
+ self.addstr(theme.CHAR_ROSTER_ASKED,
+ to_curses_attr(theme.COLOR_IMPORTANT_TEXT))
if show_s2s_errors and contact.error:
- self.addstr(get_theme().CHAR_ROSTER_ERROR,
- to_curses_attr(get_theme().COLOR_ROSTER_ERROR))
- if contact.tune:
- self.addstr(get_theme().CHAR_ROSTER_TUNE,
- to_curses_attr(get_theme().COLOR_ROSTER_TUNE))
- if contact.activity:
- self.addstr(get_theme().CHAR_ROSTER_ACTIVITY,
- to_curses_attr(get_theme().COLOR_ROSTER_ACTIVITY))
- if contact.mood:
- self.addstr(get_theme().CHAR_ROSTER_MOOD,
- to_curses_attr(get_theme().COLOR_ROSTER_MOOD))
- if contact.gaming:
- self.addstr(get_theme().CHAR_ROSTER_GAMING,
- to_curses_attr(get_theme().COLOR_ROSTER_GAMING))
+ self.addstr(theme.CHAR_ROSTER_ERROR,
+ to_curses_attr(theme.COLOR_ROSTER_ERROR))
self.finish_line()
def draw_resource_line(self, y: int, resource: Resource, colored: bool) -> None:
"""
Draw a specific resource line
"""
- color = get_theme().color_show(resource.presence)
- self.addstr(y, 4, get_theme().CHAR_STATUS, to_curses_attr(color))
+ theme = get_theme()
+ color = theme.color_show(resource.presence)
+ self.addstr(y, 4, theme.CHAR_STATUS, to_curses_attr(color))
if colored:
self.addstr(y, 8, self.truncate_name(str(resource.jid), 6),
- to_curses_attr(get_theme().COLOR_SELECTED_ROW))
+ to_curses_attr(theme.COLOR_SELECTED_ROW))
else:
self.addstr(y, 8, self.truncate_name(str(resource.jid), 6))
self.finish_line()
@@ -350,6 +332,7 @@ class ContactInfoWin(Win):
"""
draw the contact information
"""
+ theme = get_theme()
resource = contact.get_highest_priority_resource()
if contact:
jid = str(contact.bare_jid)
@@ -365,8 +348,8 @@ class ContactInfoWin(Win):
self.addstr(0, 0, '%s (%s)' % (
jid,
presence,
- ), to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
- self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ ), to_curses_attr(theme.COLOR_INFORMATION_BAR))
+ self.finish_line(theme.COLOR_INFORMATION_BAR)
i += 1
self.addstr(i, 0, 'Subscription: %s' % (contact.subscription, ))
self.finish_line()
@@ -374,7 +357,7 @@ class ContactInfoWin(Win):
if contact.ask:
if contact.ask == 'asked':
self.addstr(i, 0, 'Ask: %s' % (contact.ask, ),
- to_curses_attr(get_theme().COLOR_IMPORTANT_TEXT))
+ to_curses_attr(theme.COLOR_IMPORTANT_TEXT))
else:
self.addstr(i, 0, 'Ask: %s' % (contact.ask, ))
self.finish_line()
@@ -386,33 +369,7 @@ class ContactInfoWin(Win):
if contact.error:
self.addstr(i, 0, 'Error: %s' % contact.error,
- to_curses_attr(get_theme().COLOR_ROSTER_ERROR))
- self.finish_line()
- i += 1
-
- if contact.tune:
- self.addstr(i, 0,
- 'Tune: %s' % common.format_tune_string(contact.tune),
- to_curses_attr(get_theme().COLOR_NORMAL_TEXT))
- self.finish_line()
- i += 1
-
- if contact.mood:
- self.addstr(i, 0, 'Mood: %s' % contact.mood,
- to_curses_attr(get_theme().COLOR_NORMAL_TEXT))
- self.finish_line()
- i += 1
-
- if contact.activity:
- self.addstr(i, 0, 'Activity: %s' % contact.activity,
- to_curses_attr(get_theme().COLOR_NORMAL_TEXT))
- self.finish_line()
- i += 1
-
- if contact.gaming:
- self.addstr(
- i, 0, 'Game: %s' % common.format_gaming_string(contact.gaming),
- to_curses_attr(get_theme().COLOR_NORMAL_TEXT))
+ to_curses_attr(theme.COLOR_ROSTER_ERROR))
self.finish_line()
i += 1
@@ -420,9 +377,10 @@ class ContactInfoWin(Win):
"""
draw the group information
"""
+ theme = get_theme()
self.addstr(0, 0, group.name,
- to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
- self.finish_line(get_theme().COLOR_INFORMATION_BAR)
+ to_curses_attr(theme.COLOR_INFORMATION_BAR))
+ self.finish_line(theme.COLOR_INFORMATION_BAR)
def refresh(self, selected_row: Row) -> None:
log.debug('Refresh: %s', self.__class__.__name__)
diff --git a/poezio/windows/text_win.py b/poezio/windows/text_win.py
index d0669b26..12d90e7d 100644
--- a/poezio/windows/text_win.py
+++ b/poezio/windows/text_win.py
@@ -4,50 +4,50 @@ Can be locked, scrolled, has a separator, etc…
"""
import logging
-import curses
-from math import ceil, log10
from typing import Optional, List, Union
-from poezio.windows.base_wins import Win, FORMAT_CHAR
-from poezio.windows.funcs import truncate_nick, parse_attrs
+from poezio.windows.base_wins import Win
+from poezio.text_buffer import TextBuffer
-from poezio import poopt
from poezio.config import config
-from poezio.theming import to_curses_attr, get_theme, dump_tuple
-from poezio.text_buffer import Message
+from poezio.theming import to_curses_attr, get_theme
+from poezio.ui.types import Message, BaseMessage
+from poezio.ui.render import Line, build_lines, write_pre
log = logging.getLogger(__name__)
-# msg is a reference to the corresponding Message object. text_start and
-# text_end are the position delimiting the text in this line.
-class Line:
- __slots__ = ('msg', 'start_pos', 'end_pos', 'prepend')
-
- def __init__(self, msg: Message, start_pos: int, end_pos: int, prepend: str) -> None:
- self.msg = msg
- self.start_pos = start_pos
- self.end_pos = end_pos
- self.prepend = prepend
-
-
-class BaseTextWin(Win):
+class TextWin(Win):
__slots__ = ('lines_nb_limit', 'pos', 'built_lines', 'lock', 'lock_buffer',
- 'separator_after')
+ 'separator_after', 'highlights', 'hl_pos',
+ 'nb_of_highlights_after_separator')
+
+ hl_pos: Optional[int]
def __init__(self, lines_nb_limit: Optional[int] = None) -> None:
- if lines_nb_limit is None:
- lines_nb_limit = config.get('max_lines_in_memory')
Win.__init__(self)
- self.lines_nb_limit = lines_nb_limit # type: int
+ if lines_nb_limit is None:
+ lines_nb_limit = config.getint('max_lines_in_memory')
+ self.lines_nb_limit: int = lines_nb_limit
self.pos = 0
# Each new message is built and kept here.
# on resize, we rebuild all the messages
- self.built_lines = [] # type: List[Union[None, Line]]
+ self.built_lines: List[Union[None, Line]] = []
self.lock = False
- self.lock_buffer = [] # type: List[Union[None, Line]]
- self.separator_after = None # type: Optional[Line]
+ self.lock_buffer: List[Union[None, Line]] = []
+ self.separator_after: Optional[BaseMessage] = None
+ # the Lines of the highlights in that buffer
+ self.highlights: List[Line] = []
+ # the current HL position in that list NaN means that we’re not on
+ # an hl. -1 is a valid position (it's before the first hl of the
+ # list. i.e the separator, in the case where there’s no hl before
+ # it.)
+ self.hl_pos = None
+
+ # Keep track of the number of hl after the separator.
+ # This is useful to make “go to next highlight“ work after a “move to separator”.
+ self.nb_of_highlights_after_separator = 0
def toggle_lock(self) -> bool:
if self.lock:
@@ -80,12 +80,9 @@ class BaseTextWin(Win):
self.pos = 0
return self.pos != pos
- # TODO: figure out the type of history.
def build_new_message(self,
- message: Message,
- history=None,
+ message: BaseMessage,
clean: bool = True,
- highlight: bool = False,
timestamp: bool = False,
nick_size: int = 10) -> int:
"""
@@ -93,29 +90,55 @@ class BaseTextWin(Win):
Return the number of lines that are built for the given
message.
"""
- #pylint: disable=assignment-from-no-return
- lines = self.build_message(
- message, timestamp=timestamp, nick_size=nick_size)
+ lines = build_lines(
+ message, self.width, timestamp=timestamp, nick_size=nick_size
+ )
if self.lock:
self.lock_buffer.extend(lines)
else:
self.built_lines.extend(lines)
if not lines or not lines[0]:
return 0
+ if isinstance(message, Message) and message.highlight:
+ self.highlights.append(lines[0])
+ self.nb_of_highlights_after_separator += 1
+ log.debug("Number of highlights after separator is now %s",
+ self.nb_of_highlights_after_separator)
if clean:
while len(self.built_lines) > self.lines_nb_limit:
self.built_lines.pop(0)
return len(lines)
- def build_message(self, message: Message, timestamp: bool = False, nick_size: int = 10) -> List[Union[None, Line]]:
- """
- Build a list of lines from a message, without adding it
- to a list
- """
- return []
-
def refresh(self) -> None:
- pass
+ log.debug('Refresh: %s', self.__class__.__name__)
+ if self.height <= 0:
+ return
+ if self.pos == 0:
+ lines = self.built_lines[-self.height:]
+ else:
+ lines = self.built_lines[-self.height - self.pos:-self.pos]
+ with_timestamps = config.getbool("show_timestamps")
+ nick_size = config.getint("max_nick_length")
+ self._win.move(0, 0)
+ self._win.erase()
+ offset = 0
+ for y, line in enumerate(lines):
+ if line:
+ msg = line.msg
+ if line.start_pos == 0:
+ offset = write_pre(msg, self, with_timestamps, nick_size)
+ elif y == 0:
+ offset = msg.compute_offset(with_timestamps,
+ nick_size)
+ self.write_text(
+ y, offset,
+ line.prepend + line.msg.txt[line.start_pos:line.end_pos])
+ else:
+ self.write_line_separator(y)
+ if y != self.height - 1:
+ self.addstr('\n')
+ self._win.attrset(0)
+ self._refresh()
def write_text(self, y: int, x: int, txt: str) -> None:
"""
@@ -123,28 +146,15 @@ class BaseTextWin(Win):
"""
self.addstr_colored(txt, y, x)
- def write_time(self, time: str) -> int:
- """
- Write the date on the yth line of the window
- """
- if time:
- color = get_theme().COLOR_TIME_STRING
- curses_color = to_curses_attr(color)
- self._win.attron(curses_color)
- self.addstr(time)
- self._win.attroff(curses_color)
- self.addstr(' ')
- return poopt.wcswidth(time) + 1
- return 0
-
- # TODO: figure out the type of room.
- def resize(self, height: int, width: int, y: int, x: int, room=None) -> None:
+ def resize(self, height: int, width: int, y: int, x: int,
+ room: Optional[TextBuffer] = None, force: bool = False) -> None:
+ old_width: Optional[int]
if hasattr(self, 'width'):
old_width = self.width
else:
old_width = None
self._resize(height, width, y, x)
- if room and self.width != old_width:
+ if room and (self.width != old_width or force):
self.rebuild_everything(room)
# reposition the scrolling after resize
@@ -155,11 +165,10 @@ class BaseTextWin(Win):
if self.pos < 0:
self.pos = 0
- # TODO: figure out the type of room.
- def rebuild_everything(self, room) -> None:
+ def rebuild_everything(self, room: TextBuffer) -> None:
self.built_lines = []
- with_timestamps = config.get('show_timestamps')
- nick_size = config.get('max_nick_length')
+ with_timestamps = config.getbool('show_timestamps')
+ nick_size = config.getint('max_nick_length')
for message in room.messages:
self.build_new_message(
message,
@@ -167,34 +176,43 @@ class BaseTextWin(Win):
timestamp=with_timestamps,
nick_size=nick_size)
if self.separator_after is message:
- self.build_new_message(None)
+ self.built_lines.append(None)
while len(self.built_lines) > self.lines_nb_limit:
self.built_lines.pop(0)
+ def remove_line_separator(self) -> None:
+ """
+ Remove the line separator
+ """
+ log.debug('remove_line_separator')
+ if None in self.built_lines:
+ self.built_lines.remove(None)
+ self.separator_after = None
+
+ def add_line_separator(self, room: TextBuffer = None) -> None:
+ """
+ add a line separator at the end of messages list
+ room is a textbuffer that is needed to get the previous message
+ (in case of resize)
+ """
+ if None not in self.built_lines:
+ self.built_lines.append(None)
+ self.nb_of_highlights_after_separator = 0
+ log.debug("Resetting number of highlights after separator")
+ if room and room.messages:
+ self.separator_after = room.messages[-1]
+
+ def write_line_separator(self, y) -> None:
+ theme = get_theme()
+ char = theme.CHAR_NEW_TEXT_SEPARATOR
+ self.addnstr(y, 0, char * (self.width // len(char) - 1), self.width,
+ to_curses_attr(theme.COLOR_NEW_TEXT_SEPARATOR))
+
def __del__(self) -> None:
log.debug('** TextWin: deleting %s built lines',
(len(self.built_lines)))
del self.built_lines
-
-class TextWin(BaseTextWin):
- __slots__ = ('highlights', 'hl_pos', 'nb_of_highlights_after_separator')
-
- def __init__(self, lines_nb_limit: Optional[int] = None) -> None:
- BaseTextWin.__init__(self, lines_nb_limit)
-
- # the Lines of the highlights in that buffer
- self.highlights = [] # type: List[Line]
- # the current HL position in that list NaN means that we’re not on
- # an hl. -1 is a valid position (it's before the first hl of the
- # list. i.e the separator, in the case where there’s no hl before
- # it.)
- self.hl_pos = float('nan')
-
- # Keep track of the number of hl after the separator.
- # This is useful to make “go to next highlight“ work after a “move to separator”.
- self.nb_of_highlights_after_separator = 0
-
def next_highlight(self) -> None:
"""
Go to the next highlight in the buffer.
@@ -203,13 +221,13 @@ class TextWin(BaseTextWin):
highlights, scroll to the end of the buffer.
"""
log.debug('Going to the next highlight…')
- if (not self.highlights or self.hl_pos != self.hl_pos
+ if (not self.highlights or self.hl_pos is None
or self.hl_pos >= len(self.highlights) - 1):
- self.hl_pos = float('nan')
+ self.hl_pos = None
self.pos = 0
return
hl_size = len(self.highlights) - 1
- if self.hl_pos < hl_size:
+ if self.hl_pos is not None and self.hl_pos < hl_size:
self.hl_pos += 1
else:
self.hl_pos = hl_size
@@ -220,9 +238,10 @@ class TextWin(BaseTextWin):
try:
pos = self.built_lines.index(hl)
except ValueError:
- self.highlights = self.highlights[self.hl_pos + 1:]
+ if isinstance(self.hl_pos, int):
+ del self.highlights[self.hl_pos]
if not self.highlights:
- self.hl_pos = float('nan')
+ self.hl_pos = None
self.pos = 0
return
self.hl_pos = 0
@@ -239,11 +258,11 @@ class TextWin(BaseTextWin):
highlights, scroll to the end of the buffer.
"""
log.debug('Going to the previous highlight…')
- if not self.highlights or self.hl_pos <= 0:
- self.hl_pos = float('nan')
+ if not self.highlights or self.hl_pos and self.hl_pos <= 0:
+ self.hl_pos = None
self.pos = 0
return
- if self.hl_pos != self.hl_pos:
+ if self.hl_pos is None:
self.hl_pos = len(self.highlights) - 1
else:
self.hl_pos -= 1
@@ -254,9 +273,10 @@ class TextWin(BaseTextWin):
try:
pos = self.built_lines.index(hl)
except ValueError:
- self.highlights = self.highlights[self.hl_pos + 1:]
+ if self.hl_pos is not None:
+ del self.highlights[self.hl_pos]
if not self.highlights:
- self.hl_pos = float('nan')
+ self.hl_pos = None
self.pos = 0
return
self.hl_pos = 0
@@ -267,8 +287,8 @@ class TextWin(BaseTextWin):
def scroll_to_separator(self) -> None:
"""
- Scroll until separator is centered. If no separator is
- present, scroll at the top of the window
+ Scroll to the first message after the separator. If no
+ separator is present, scroll to the first message of the window
"""
if None in self.built_lines:
self.pos = len(self.built_lines) - self.built_lines.index(
@@ -286,371 +306,31 @@ class TextWin(BaseTextWin):
self.highlights) - self.nb_of_highlights_after_separator - 1
log.debug("self.hl_pos = %s", self.hl_pos)
- def remove_line_separator(self) -> None:
- """
- Remove the line separator
- """
- log.debug('remove_line_separator')
- if None in self.built_lines:
- self.built_lines.remove(None)
- self.separator_after = None
-
- # TODO: figure out the type of room.
- def add_line_separator(self, room=None) -> None:
- """
- add a line separator at the end of messages list
- room is a textbuffer that is needed to get the previous message
- (in case of resize)
- """
- if None not in self.built_lines:
- self.built_lines.append(None)
- self.nb_of_highlights_after_separator = 0
- log.debug("Resetting number of highlights after separator")
- if room and room.messages:
- self.separator_after = room.messages[-1]
-
- # TODO: figure out the type of history.
- def build_new_message(self,
- message: Message,
- history=None,
- clean: bool = True,
- highlight: bool = False,
- timestamp: bool = False,
- nick_size: int = 10) -> int:
- """
- Take one message, build it and add it to the list
- Return the number of lines that are built for the given
- message.
- """
- lines = self.build_message(
- message, timestamp=timestamp, nick_size=nick_size)
- if self.lock:
- self.lock_buffer.extend(lines)
- else:
- self.built_lines.extend(lines)
- if not lines or not lines[0]:
- return 0
- if highlight:
- self.highlights.append(lines[0])
- self.nb_of_highlights_after_separator += 1
- log.debug("Number of highlights after separator is now %s",
- self.nb_of_highlights_after_separator)
- if clean:
- while len(self.built_lines) > self.lines_nb_limit:
- self.built_lines.pop(0)
- return len(lines)
-
- def build_message(self, message: Optional[Message], timestamp: bool = False, nick_size: int = 10) -> List[Union[None, Line]]:
- """
- Build a list of lines from a message, without adding it
- to a list
- """
- if message is None: # line separator
- return [None]
- txt = message.txt
- if not txt:
- return []
- if len(message.str_time) > 8:
- default_color = (
- FORMAT_CHAR + dump_tuple(get_theme().COLOR_LOG_MSG) + '}') # type: Optional[str]
- else:
- default_color = None
- ret = [] # type: List[Union[None, Line]]
- nick = truncate_nick(message.nickname, nick_size)
- offset = 0
- if message.ack:
- if message.ack > 0:
- offset += poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1
- else:
- offset += poopt.wcswidth(get_theme().CHAR_NACK) + 1
- if nick:
- offset += poopt.wcswidth(nick) + 2 # + nick + '> ' length
- if message.revisions > 0:
- offset += ceil(log10(message.revisions + 1))
- if message.me:
- offset += 1 # '* ' before and ' ' after
- if timestamp:
- if message.str_time:
- offset += 1 + len(message.str_time)
- if get_theme().CHAR_TIME_LEFT and message.str_time:
- offset += 1
- if get_theme().CHAR_TIME_RIGHT and message.str_time:
- offset += 1
- lines = poopt.cut_text(txt, self.width - offset - 1)
- prepend = default_color if default_color else ''
- attrs = [] # type: List[str]
- for line in lines:
- saved = Line(
- msg=message,
- start_pos=line[0],
- end_pos=line[1],
- prepend=prepend)
- attrs = parse_attrs(message.txt[line[0]:line[1]], attrs)
- if attrs:
- prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs)
- else:
- if default_color:
- prepend = default_color
- else:
- prepend = ''
- ret.append(saved)
- return ret
-
- def refresh(self) -> None:
- log.debug('Refresh: %s', self.__class__.__name__)
- if self.height <= 0:
- return
- if self.pos == 0:
- lines = self.built_lines[-self.height:]
- else:
- lines = self.built_lines[-self.height - self.pos:-self.pos]
- with_timestamps = config.get("show_timestamps")
- nick_size = config.get("max_nick_length")
- self._win.move(0, 0)
- self._win.erase()
- offset = 0
- for y, line in enumerate(lines):
- if line:
- msg = line.msg
- if line.start_pos == 0:
- offset = self.write_pre_msg(msg, with_timestamps,
- nick_size)
- elif y == 0:
- offset = self.compute_offset(msg, with_timestamps,
- nick_size)
- self.write_text(
- y, offset,
- line.prepend + line.msg.txt[line.start_pos:line.end_pos])
- else:
- self.write_line_separator(y)
- if y != self.height - 1:
- self.addstr('\n')
- self._win.attrset(0)
- self._refresh()
-
- def compute_offset(self, msg, with_timestamps, nick_size) -> int:
- offset = 0
- if with_timestamps and msg.str_time:
- offset += poopt.wcswidth(msg.str_time) + 1
-
- if not msg.nickname: # not a message, nothing to do afterwards
- return offset
-
- nick = truncate_nick(msg.nickname, nick_size)
- offset += poopt.wcswidth(nick)
- if msg.ack:
- if msg.ack > 0:
- offset += poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1
- else:
- offset += poopt.wcswidth(get_theme().CHAR_NACK) + 1
- if msg.me:
- offset += 3
- else:
- offset += 2
- if msg.revisions:
- offset += ceil(log10(msg.revisions + 1))
- offset += self.write_revisions(msg)
- return offset
-
- def write_pre_msg(self, msg, with_timestamps, nick_size) -> int:
- offset = 0
- if with_timestamps:
- offset += self.write_time(msg.str_time)
-
- if not msg.nickname: # not a message, nothing to do afterwards
- return offset
-
- nick = truncate_nick(msg.nickname, nick_size)
- offset += poopt.wcswidth(nick)
- if msg.nick_color:
- color = msg.nick_color
- elif msg.user:
- color = msg.user.color
- else:
- color = None
- if msg.ack:
- if msg.ack > 0:
- offset += self.write_ack()
- else:
- offset += self.write_nack()
- if msg.me:
- self._win.attron(to_curses_attr(get_theme().COLOR_ME_MESSAGE))
- self.addstr('* ')
- self.write_nickname(nick, color, msg.highlight)
- offset += self.write_revisions(msg)
- self.addstr(' ')
- offset += 3
- else:
- self.write_nickname(nick, color, msg.highlight)
- offset += self.write_revisions(msg)
- self.addstr('> ')
- offset += 2
- return offset
-
- def write_revisions(self, msg) -> int:
- if msg.revisions:
- self._win.attron(
- to_curses_attr(get_theme().COLOR_REVISIONS_MESSAGE))
- self.addstr('%d' % msg.revisions)
- self._win.attrset(0)
- return ceil(log10(msg.revisions + 1))
- return 0
-
- def write_line_separator(self, y) -> None:
- char = get_theme().CHAR_NEW_TEXT_SEPARATOR
- self.addnstr(y, 0, char * (self.width // len(char) - 1), self.width,
- to_curses_attr(get_theme().COLOR_NEW_TEXT_SEPARATOR))
-
- def write_ack(self) -> int:
- color = get_theme().COLOR_CHAR_ACK
- self._win.attron(to_curses_attr(color))
- self.addstr(get_theme().CHAR_ACK_RECEIVED)
- self._win.attroff(to_curses_attr(color))
- self.addstr(' ')
- return poopt.wcswidth(get_theme().CHAR_ACK_RECEIVED) + 1
-
- def write_nack(self) -> int:
- color = get_theme().COLOR_CHAR_NACK
- self._win.attron(to_curses_attr(color))
- self.addstr(get_theme().CHAR_NACK)
- self._win.attroff(to_curses_attr(color))
- self.addstr(' ')
- return poopt.wcswidth(get_theme().CHAR_NACK) + 1
-
- def write_nickname(self, nickname, color, highlight=False) -> None:
- """
- Write the nickname, using the user's color
- and return the number of written characters
- """
- if not nickname:
- return
- if highlight:
- hl_color = get_theme().COLOR_HIGHLIGHT_NICK
- if hl_color == "reverse":
- self._win.attron(curses.A_REVERSE)
- else:
- color = hl_color
- if color:
- self._win.attron(to_curses_attr(color))
- self.addstr(nickname)
- if color:
- self._win.attroff(to_curses_attr(color))
- if highlight and hl_color == "reverse":
- self._win.attroff(curses.A_REVERSE)
-
def modify_message(self, old_id, message) -> None:
"""
Find a message, and replace it with a new one
(instead of rebuilding everything in order to correct a message)
"""
- with_timestamps = config.get('show_timestamps')
- nick_size = config.get('max_nick_length')
+ with_timestamps = config.getbool('show_timestamps')
+ nick_size = config.getint('max_nick_length')
for i in range(len(self.built_lines) - 1, -1, -1):
- if self.built_lines[i] and self.built_lines[i].msg.identifier == old_id:
+ current = self.built_lines[i]
+ if current is not None and current.msg.identifier == old_id:
index = i
- while index >= 0 and self.built_lines[index] and self.built_lines[index].msg.identifier == old_id:
+ while (
+ index >= 0
+ and current is not None
+ and current.msg.identifier == old_id
+ ):
self.built_lines.pop(index)
index -= 1
+ if index >= 0:
+ current = self.built_lines[index]
index += 1
- lines = self.build_message(
- message, timestamp=with_timestamps, nick_size=nick_size)
+ lines = build_lines(
+ message, self.width, timestamp=with_timestamps, nick_size=nick_size
+ )
for line in lines:
self.built_lines.insert(index, line)
index += 1
break
-
- def __del__(self) -> None:
- log.debug('** TextWin: deleting %s built lines',
- (len(self.built_lines)))
- del self.built_lines
-
-
-class XMLTextWin(BaseTextWin):
- __slots__ = ()
-
- def __init__(self) -> None:
- BaseTextWin.__init__(self)
-
- def refresh(self) -> None:
- log.debug('Refresh: %s', self.__class__.__name__)
- theme = get_theme()
- if self.height <= 0:
- return
- if self.pos == 0:
- lines = self.built_lines[-self.height:]
- else:
- lines = self.built_lines[-self.height - self.pos:-self.pos]
- self._win.move(0, 0)
- self._win.erase()
- for y, line in enumerate(lines):
- if line:
- msg = line.msg
- if line.start_pos == 0:
- if msg.nickname == theme.CHAR_XML_OUT:
- color = theme.COLOR_XML_OUT
- elif msg.nickname == theme.CHAR_XML_IN:
- color = theme.COLOR_XML_IN
- self.write_time(msg.str_time)
- self.write_prefix(msg.nickname, color)
- self.addstr(' ')
- if y != self.height - 1:
- self.addstr('\n')
- self._win.attrset(0)
- for y, line in enumerate(lines):
- offset = 0
- # Offset for the timestamp (if any) plus a space after it
- offset += len(line.msg.str_time)
- # space
- offset += 1
-
- # Offset for the prefix
- offset += poopt.wcswidth(truncate_nick(line.msg.nickname))
- # space
- offset += 1
-
- self.write_text(
- y, offset,
- line.prepend + line.msg.txt[line.start_pos:line.end_pos])
- if y != self.height - 1:
- self.addstr('\n')
- self._win.attrset(0)
- self._refresh()
-
- def build_message(self, message: Message, timestamp: bool = False, nick_size: int = 10) -> List[Line]:
- txt = message.txt
- ret = []
- default_color = None
- nick = truncate_nick(message.nickname, nick_size)
- offset = 0
- if nick:
- offset += poopt.wcswidth(nick) + 1 # + nick + ' ' length
- if message.str_time:
- offset += 1 + len(message.str_time)
- if get_theme().CHAR_TIME_LEFT and message.str_time:
- offset += 1
- if get_theme().CHAR_TIME_RIGHT and message.str_time:
- offset += 1
- lines = poopt.cut_text(txt, self.width - offset - 1)
- prepend = default_color if default_color else ''
- attrs = [] # type: List[str]
- for line in lines:
- saved = Line(
- msg=message,
- start_pos=line[0],
- end_pos=line[1],
- prepend=prepend)
- attrs = parse_attrs(message.txt[line[0]:line[1]], attrs)
- if attrs:
- prepend = FORMAT_CHAR + FORMAT_CHAR.join(attrs)
- else:
- if default_color:
- prepend = default_color
- else:
- prepend = ''
- ret.append(saved)
- return ret
-
- def write_prefix(self, nickname, color) -> None:
- self._win.attron(to_curses_attr(color))
- self.addstr(truncate_nick(nickname))
- self._win.attroff(to_curses_attr(color))
diff --git a/poezio/xdg.py b/poezio/xdg.py
index 0b63998c..d7ff9d73 100644
--- a/poezio/xdg.py
+++ b/poezio/xdg.py
@@ -3,7 +3,7 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Implements the XDG base directory specification.
@@ -15,11 +15,11 @@ from os import environ
from typing import Dict
# $HOME has already been checked to not be None in test_env().
-DEFAULT_PATHS = {
+DEFAULT_PATHS: Dict[str, Path] = {
'XDG_CONFIG_HOME': Path.home() / '.config',
'XDG_DATA_HOME': Path.home() / '.local' / 'share',
'XDG_CACHE_HOME': Path.home() / '.cache',
-} # type: Dict[str, Path]
+}
def _get_directory(variable: str) -> Path:
diff --git a/poezio/xhtml.py b/poezio/xhtml.py
index 899985ef..2875f1a1 100644
--- a/poezio/xhtml.py
+++ b/poezio/xhtml.py
@@ -3,7 +3,7 @@
# This file is part of Poezio.
#
# Poezio is free software: you can redistribute it and/or modify
-# it under the terms of the zlib license. See the COPYING file.
+# it under the terms of the GPL-3.0+ license. See the COPYING file.
"""
Various methods to convert
shell colors to poezio colors,
@@ -21,6 +21,7 @@ from pathlib import Path
from io import BytesIO
from xml import sax
from xml.sax import saxutils
+from xml.sax.handler import ContentHandler
from typing import List, Dict, Optional, Union, Tuple
from slixmpp.xmlstream import ET
@@ -32,7 +33,7 @@ digits = '0123456789' # never trust the modules
XHTML_NS = 'http://www.w3.org/1999/xhtml'
# HTML named colors
-colors = {
+colors: Dict[str, int] = {
'aliceblue': 231,
'antiquewhite': 231,
'aqua': 51,
@@ -180,7 +181,7 @@ colors = {
'whitesmoke': 255,
'yellow': 226,
'yellowgreen': 149
-} # type: Dict[str, int]
+}
whitespace_re = re.compile(r'\s+')
@@ -299,21 +300,21 @@ def get_hash(data: bytes) -> str:
b'/', b'-').decode()
-class XHTMLHandler(sax.ContentHandler):
+class XHTMLHandler(ContentHandler):
def __init__(self, force_ns=False,
tmp_image_dir: Optional[Path] = None) -> None:
- self.builder = [] # type: List[str]
- self.formatting = [] # type: List[str]
- self.attrs = [] # type: List[Dict[str, str]]
- self.list_state = [] # type: List[Union[str, int]]
- self.cids = {} # type: Dict[str, Optional[str]]
+ self.builder: List[str] = []
+ self.formatting: List[str] = []
+ self.attrs: List[Dict[str, str]] = []
+ self.list_state: List[Union[str, int]] = []
+ self.cids: Dict[str, Optional[str]] = {}
self.is_pre = False
self.a_start = 0
# do not care about xhtml-in namespace
self.force_ns = force_ns
self.tmp_image_dir = Path(tmp_image_dir) if tmp_image_dir else None
- self.enable_css_parsing = config.get('enable_css_parsing')
+ self.enable_css_parsing = config.getbool('enable_css_parsing')
@property
def result(self) -> str:
@@ -430,7 +431,7 @@ class XHTMLHandler(sax.ContentHandler):
if 'href' in attrs and attrs['href'] != link_text:
builder.append(' (%s)' % _trim(attrs['href']))
elif name == 'blockquote':
- builder.append('”')
+ builder.append('”\n')
elif name in ('cite', 'em', 'strong'):
self.pop_formatting()
elif name in ('ol', 'p', 'ul'):
@@ -488,7 +489,7 @@ def convert_simple_to_full_colors(text: str) -> str:
a \x19n} formatted one.
"""
# TODO, have a single list of this. This is some sort of
- # duplicate from windows.format_chars
+ # duplicate from ui.consts.FORMAT_CHARS
mapping = str.maketrans({
'\x0E': '\x19b',
'\x0F': '\x19o',
@@ -512,7 +513,7 @@ def convert_simple_to_full_colors(text: str) -> str:
return re.sub(xhtml_simple_attr_re, add_curly_bracket, text)
-number_to_color_names = {
+number_to_color_names: Dict[int, str] = {
1: 'red',
2: 'green',
3: 'yellow',
@@ -520,7 +521,7 @@ number_to_color_names = {
5: 'violet',
6: 'turquoise',
7: 'white'
-} # type: Dict[int, str]
+}
def format_inline_css(_dict: Dict[str, str]) -> str:
@@ -535,7 +536,7 @@ def poezio_colors_to_html(string: str) -> str:
# Maintain a list of the current css attributes used
# And check if a tag is open (by design, we only open
# spans tag, and they cannot be nested.
- current_attrs = {} # type: Dict[str, str]
+ current_attrs: Dict[str, str] = {}
tag_open = False
next_attr_char = string.find('\x19')
build = ["<body xmlns='http://www.w3.org/1999/xhtml'><p>"]
diff --git a/requirements-plugins.txt b/requirements-plugins.txt
index c50dbf31..d3c041e2 100644
--- a/requirements-plugins.txt
+++ b/requirements-plugins.txt
@@ -3,3 +3,4 @@ pyinotify
python-mpd2
aiohttp
pygments
+qrcode
diff --git a/requirements.txt b/requirements.txt
index e865ed37..403cc355 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,3 +4,4 @@ aiodns==1.1.1
pycares==2.3.0
pyasn1==0.4.2
pyasn1-modules==0.2.1
+typing_extensions
diff --git a/scripts/poezio_logs b/scripts/poezio_logs
index c97b304f..b2374a6d 100755
--- a/scripts/poezio_logs
+++ b/scripts/poezio_logs
@@ -3,12 +3,11 @@
A simple script to parse and output logs from a poezio logfile
"""
-from poezio.logger import LogInfo, LogMessage, parse_log_line
+import argparse
from functools import singledispatch
+from typing import List, Optional, IO
from poezio import poopt
-import argparse
-import datetime
-import sys
+from poezio.logger import LogInfo, LogMessage, parse_log_line
INFO_COLOR = '\033[35;2m'
NICK_COLOR = '\033[36;2m'
@@ -18,12 +17,17 @@ TIME_COLOR = '\033[33;2m'
SHOW_INFO = True
SHOW_TIME = True
+
@singledispatch
-def print_log(log_object, additional_lines=None):
+def print_log(log_object: LogMessage,
+ additional_lines: Optional[List[str]] = None):
time = log_object.time.strftime('%Y-%m-%d %H:%M:%S')
nick = log_object.nick
- offset = ((poopt.wcswidth(time) +1) if SHOW_TIME else 0) + 2 + poopt.wcswidth(nick)
+ offset = ((
+ (poopt.wcswidth(time) + 1) if SHOW_TIME else 0)
+ + 2 + poopt.wcswidth(nick)
+ )
pad = ' ' * offset
if additional_lines:
@@ -32,17 +36,29 @@ def print_log(log_object, additional_lines=None):
more = ''
if SHOW_TIME:
- print(('%s%s%s %s%s%s> %s\n' % (TIME_COLOR, time, NO_COLOR, NICK_COLOR, nick, NO_COLOR, log_object.text)) + more, end='')
+ print(
+ f'{TIME_COLOR}{time}{NO_COLOR} {NICK_COLOR}{nick}{NO_COLOR}> '
+ f'{log_object.text}\n' + more,
+ end='',
+ )
else:
- print(('%s%s%s> %s\n' % (NICK_COLOR, nick, NO_COLOR, log_object.text)) + more, end='')
+ print(
+ f'{NICK_COLOR}{nick}{NO_COLOR}> '
+ f'{log_object.text}\n' + more,
+ end='',
+ )
+
@print_log.register(type(None))
-def _(log_object, additional_lines=None):
+def print_log_none(log_object, additional_lines=None):
return
+
@print_log.register(LogInfo)
-def _(log_object, additional_lines=None):
- if not SHOW_INFO: return
+def print_log_loginfo(log_object: LogInfo,
+ additional_lines: Optional[List[str]] = None):
+ if not SHOW_INFO:
+ return
time = log_object.time.strftime('%Y-%m-%d %H:%M:%S') + ' '
offset = (poopt.wcswidth(time) + 1) if SHOW_TIME else 0
@@ -54,13 +70,17 @@ def _(log_object, additional_lines=None):
more = ''
if SHOW_TIME:
- print(('%s%s%s %s%s\n' % (TIME_COLOR, time, NO_COLOR, INFO_COLOR, log_object.text)) + more, end='')
+ print(
+ f'{TIME_COLOR}{time}{NO_COLOR}{log_object.text}\n' + more,
+ end=''
+ )
else:
- print(('%s%s\n' % (INFO_COLOR, log_object.text)) + more, end='')
+ print(f'{INFO_COLOR}{log_object.text}\n' + more, end='')
-def parse_messages(fd):
+
+def parse_messages(fd: IO[str]):
in_text = False
- more_lines = []
+ more_lines: List[str] = []
current_log = None
for line in fd:
if in_text and not line.startswith(' '):
@@ -71,10 +91,11 @@ def parse_messages(fd):
elif in_text:
more_lines.append(line[1:])
continue
- current_log = parse_log_line(line)
+ current_log = parse_log_line(line, fd.name)
in_text = True
print_log(current_log, more_lines)
+
if __name__ == '__main__':
parser = argparse.ArgumentParser('poezio_logs', description="""
Show the logs stored in poezio format in a more human-readable way.
diff --git a/setup.py b/setup.py
index 24eb26f8..8c9fa723 100755
--- a/setup.py
+++ b/setup.py
@@ -1,18 +1,26 @@
#!/usr/bin/env python3
+import os
+import subprocess
+import sys
+from tempfile import TemporaryFile
try:
from setuptools import setup, Extension
except ImportError:
print('\nSetuptools was not found. Install setuptools for python 3.\n')
- import sys
sys.exit(1)
-import os
-import subprocess
-from tempfile import TemporaryFile
+cmdclass = {}
+try:
+ from sphinx.setup_command import BuildDoc
+ cmdclass = {'build_man': BuildDoc}
+except ImportError:
+ print('\nSphinx not found, the build_man command will be unavailable.\n')
current_dir = os.path.dirname(__file__)
+from poezio.version import __version__
+
def get_relative_dir(folder, stopper):
"""
Find the path from a directory to a pseudo-root in order to recreate
@@ -57,8 +65,30 @@ def check_include(library_name, header):
print('%s headers not found.' % library_name)
return False
+def sphinx_man():
+ expected_sphinx_files = [
+ 'build/sphinx/man/poezio.cfg.7',
+ 'build/sphinx/man/poezio.keys.7',
+ 'build/sphinx/man/poezio.commands.7'
+ ]
+ found = []
+ for item in expected_sphinx_files:
+ if os.path.exists(item):
+ found.append(item)
+ if found:
+ return [('share/man/man7/', found)]
+ return []
+
+
+sphinx_files_found = sphinx_man()
+if not sphinx_files_found:
+ print(
+ '\nSphinx-built manpages not found. Only the '
+ 'short handwritten manpages will be installed\n'
+ )
+
+
if not check_include('python3', 'Python.h'):
- import sys
sys.exit(2)
module_poopt = Extension('poezio.poopt',
@@ -83,58 +113,69 @@ if os.path.exists(git_dir):
except:
version = '.dev1'
else:
- version = '.dev1'
+ version = ''
with open('README.rst', encoding='utf-8') as readme_fd:
LONG_DESCRIPTION = readme_fd.read()
-setup(name="poezio",
- version="0.13" + version,
- description="A console XMPP client",
- long_description=LONG_DESCRIPTION,
- ext_modules=[module_poopt],
- url='https://poez.io/',
- license='zlib',
- download_url='https://dev.louiz.org/projects/poezio/files',
-
- author='Florent Le Coz',
- author_email='louiz@louiz.org',
-
- maintainer='Mathieu Pasquet',
- maintainer_email='mathieui@mathieui.net',
-
- classifiers=['Development Status :: 5 - Production/Stable',
- 'Topic :: Communications :: Chat',
- 'Topic :: Internet :: XMPP',
- 'Environment :: Console :: Curses',
- 'Intended Audience :: End Users/Desktop',
- 'License :: OSI Approved :: zlib/libpng License',
- 'Natural Language :: English',
- 'Operating System :: Unix',
- 'Programming Language :: Python :: 3.7',
- 'Programming Language :: Python :: 3.6',
- 'Programming Language :: Python :: 3.5',
- 'Programming Language :: Python :: 3 :: Only'],
- keywords=['jabber', 'xmpp', 'client', 'chat', 'im', 'console'],
- packages=['poezio', 'poezio.core', 'poezio.tabs', 'poezio.windows',
- 'poezio_plugins', 'poezio_themes'],
- package_dir={'poezio': 'poezio',
- 'poezio_plugins': 'plugins',
- 'poezio_themes': 'data/themes'},
- package_data={'poezio': ['default_config.cfg']},
- scripts=['scripts/poezio_logs'],
- entry_points={'console_scripts': ['poezio = poezio.__main__:run']},
- data_files=([('share/man/man1/', ['data/poezio.1',
- 'data/poezio_logs.1']),
- ('share/poezio/', ['README.rst', 'COPYING', 'CHANGELOG']),
- ('share/applications/', ['data/io.poez.Poezio.desktop']),
- ('share/metainfo/', ['data/io.poez.Poezio.appdata.xml'])]
- + find_doc('share/doc/poezio/source', 'source')
- + find_doc('share/doc/poezio/html', 'build/html')),
- install_requires=['slixmpp>=1.3.0', 'aiodns', 'pyasn1_modules', 'pyasn1'],
- extras_require={'OTR plugin': 'python-potr>=1.0',
- 'Screen autoaway plugin': 'pyinotify==0.9.4',
- 'Avoiding cython': 'cffi'})
+setup(
+ name="poezio",
+ version=__version__ + version,
+ description="A console XMPP client",
+ long_description=LONG_DESCRIPTION,
+ ext_modules=[module_poopt],
+ url='https://poez.io/',
+ license='GPL-3.0-or-later',
+ download_url='https://dev.louiz.org/projects/poezio/files',
+
+ author='Florent Le Coz',
+ author_email='louiz@louiz.org',
+
+ maintainer='Mathieu Pasquet',
+ maintainer_email='mathieui@mathieui.net',
+
+ classifiers=['Development Status :: 5 - Production/Stable',
+ 'Topic :: Communications :: Chat',
+ 'Topic :: Internet :: XMPP',
+ 'Environment :: Console :: Curses',
+ 'Intended Audience :: End Users/Desktop',
+ 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
+ 'Natural Language :: English',
+ 'Operating System :: Unix',
+ 'Programming Language :: Python :: 3.9',
+ 'Programming Language :: Python :: 3.8',
+ 'Programming Language :: Python :: 3.7',
+ 'Programming Language :: Python :: 3 :: Only'],
+ keywords=['jabber', 'xmpp', 'client', 'chat', 'im', 'console'],
+ packages=['poezio', 'poezio.core', 'poezio.tabs', 'poezio.windows',
+ 'poezio.ui', 'poezio_plugins', 'poezio_themes'],
+ package_dir={'poezio': 'poezio',
+ 'poezio_plugins': 'plugins',
+ 'poezio_themes': 'data/themes'},
+ package_data={'poezio': ['default_config.cfg', 'py.typed']},
+ scripts=['scripts/poezio_logs'],
+ entry_points={'console_scripts': ['poezio = poezio.__main__:run']},
+ data_files=([
+ ('share/man/man1/', ['data/poezio.1', 'data/poezio_logs.1']),
+ ('share/poezio/', ['README.rst', 'COPYING', 'CHANGELOG']),
+ ('share/applications/', ['data/io.poez.Poezio.desktop']),
+ ('share/metainfo/', ['data/io.poez.Poezio.appdata.xml'])
+ ]
+ + find_doc('share/doc/poezio/source', 'source')
+ + find_doc('share/doc/poezio/html', 'build/html')
+ + sphinx_files_found
+ ),
+ install_requires=['slixmpp>=1.6.0', 'aiodns', 'pyasn1_modules', 'pyasn1', 'typing_extensions', 'setuptools'],
+ extras_require={'OTR plugin': 'python-potr>=1.0',
+ 'Screen autoaway plugin': 'pyinotify==0.9.4',
+ 'Avoiding cython': 'cffi'},
+ cmdclass=cmdclass,
+ command_options={
+ 'build_man' : {
+ 'builder': ('setup.py', 'man'),
+ }
+ },
+)
# Remove the link afterwards
if (os.path.exists(os.path.join(current_dir, 'poezio', 'default_config.cfg')) and
diff --git a/test/test_common.py b/test/test_common.py
index d7bc2b8b..3235632d 100644
--- a/test/test_common.py
+++ b/test/test_common.py
@@ -11,7 +11,7 @@ from poezio.common import (_datetime_tuple as datetime_tuple, get_utc_time,
get_local_time, shell_split, _find_argument_quoted
as find_argument_quoted, _find_argument_unquoted as
find_argument_unquoted, parse_str_to_secs,
- parse_secs_to_str, safeJID)
+ parse_secs_to_str, unique_prefix_of)
def test_utc_time():
delta = timedelta(seconds=-3600)
@@ -60,6 +60,21 @@ def test_parse_secs_to_str():
with pytest.raises(TypeError):
parse_secs_to_str('toto')
-def test_safeJID():
- assert safeJID('toto@titi/tata') == JID('toto@titi/tata')
- assert safeJID('toto@…') == JID('')
+def test_unique_prefix_of__no_shared_prefix():
+ assert unique_prefix_of("a", "b") == "a"
+ assert unique_prefix_of("foo", "bar") == "f"
+ assert unique_prefix_of("foo", "") == "f"
+
+def test_unique_prefix_of__equal():
+ assert unique_prefix_of("foo", "foo") == "foo"
+
+def test_unique_prefix_of__a_prefix():
+ assert unique_prefix_of("foo", "foobar") == "foo"
+
+def test_unique_prefix_of__b_prefix():
+ assert unique_prefix_of("foobar", "foo") == "foob"
+
+def test_unique_prefix_of__normal_shared_prefix():
+ assert unique_prefix_of("foobar", "foobaz") == "foobar"
+ assert unique_prefix_of("fnord", "funky") == "fn"
+ assert unique_prefix_of("asbestos", "aspergers") == "asb"
diff --git a/test/test_completion.py b/test/test_completion.py
index 620b5658..1a970470 100644
--- a/test/test_completion.py
+++ b/test/test_completion.py
@@ -5,15 +5,19 @@ Test the completions methods on an altered input object.
import string
import pytest
import random
+from poezio import config
+from poezio.windows import Input
+
class ConfigShim(object):
def get(self, *args, **kwargs):
return ''
+ def getbool(self, *args, **kwargs):
+ return False
+
-from poezio import config
config.config = ConfigShim()
-from poezio.windows import Input
class SubInput(Input):
def resize(self, *args, **kwargs):
@@ -26,6 +30,8 @@ class SubInput(Input):
@pytest.fixture(scope="function")
def input_obj():
+ from poezio.windows import base_wins
+ base_wins.TAB_WIN = True
obj = SubInput()
obj.reset_completion()
return obj
diff --git a/test/test_config.py b/test/test_config.py
index 4632410b..8e92bf3f 100644
--- a/test/test_config.py
+++ b/test/test_config.py
@@ -9,8 +9,10 @@ from pathlib import Path
import pytest
from poezio import config
+from slixmpp import JID
-@pytest.yield_fixture(scope="module")
+
+@pytest.fixture(scope="module")
def config_obj():
file_ = tempfile.NamedTemporaryFile(delete=False)
conf = config.Config(file_name=Path(file_.name))
@@ -18,6 +20,7 @@ def config_obj():
del conf
os.unlink(file_.name)
+
class TestConfigSimple(object):
def test_get_set(self, config_obj):
config_obj.set_and_save('test', value='coucou')
@@ -97,16 +100,15 @@ class TestConfigSections(object):
class TestTabNames(object):
def test_get_tabname(self, config_obj):
- config.post_logging_setup()
config_obj.set_and_save('test', value='value.toto@toto.com',
section='toto@toto.com')
config_obj.set_and_save('test2', value='value2@toto.com',
section='@toto.com')
- assert config_obj.get_by_tabname('test', 'toto@toto.com') == 'value.toto@toto.com'
- assert config_obj.get_by_tabname('test2', 'toto@toto.com') == 'value2@toto.com'
- assert config_obj.get_by_tabname('test2', 'toto@toto.com', fallback=False) == 'value2@toto.com'
- assert config_obj.get_by_tabname('test2', 'toto@toto.com', fallback_server=False) == 'true'
- assert config_obj.get_by_tabname('test_int', 'toto@toto.com', fallback=False) == ''
+ assert config_obj.get_by_tabname('test', JID('toto@toto.com')) == 'value.toto@toto.com'
+ assert config_obj.get_by_tabname('test2', JID('toto@toto.com')) == 'value2@toto.com'
+ assert config_obj.get_by_tabname('test2', JID('toto@toto.com'), fallback=False) == 'value2@toto.com'
+ assert config_obj.get_by_tabname('test2', JID('toto@toto.com'), fallback_server=False) == 'true'
+ assert config_obj.get_by_tabname('test_int', JID('toto@toto.com'), fallback=False) == ''
diff --git a/test/test_logger.py b/test/test_logger.py
index f1851d60..a1caad52 100644
--- a/test/test_logger.py
+++ b/test/test_logger.py
@@ -2,32 +2,110 @@
Test the functions in the `logger` module
"""
import datetime
-from poezio.logger import LogMessage, parse_log_line, parse_log_lines, build_log_message
-from poezio.common import get_utc_time, get_local_time
+from pathlib import Path
+from random import sample
+from shutil import rmtree
+from string import hexdigits
+from poezio import logger
+from poezio.logger import (
+ LogMessage, parse_log_line, parse_log_lines, build_log_message
+)
+from poezio.ui.types import Message
+from poezio.common import get_utc_time
+import pytest
+
+
+class ConfigShim:
+ def __init__(self, value):
+ self.value = value
+
+ def get_by_tabname(self, name, *args, **kwargs):
+ return self.value
+
+
+logger.config = ConfigShim(True)
+
+
+@pytest.fixture
+def log_dir():
+ name = 'tmplog-' + ''.join(sample(hexdigits, 16))
+ path = Path('/tmp', name)
+ try:
+ yield path
+ finally:
+ rmtree(path, ignore_errors=True)
+
+
+def read_file(logger, name):
+ if '/' in name:
+ name = name.replace('/', '\\')
+ filename = logger.log_dir / f'{name}'
+ with open(filename) as fd:
+ return fd.read()
+
+
+def test_log_roster(log_dir):
+ instance = logger.Logger()
+ instance.log_dir = log_dir
+ instance.log_roster_change('toto@example.com', 'test test')
+ content = read_file(instance, 'roster.log')
+ assert content[:3] == 'MI '
+ assert content[-32:] == ' 000 toto@example.com test test\n'
+
+
+def test_log_message(log_dir):
+ instance = logger.Logger()
+ instance.log_dir = log_dir
+ msg = Message('content', 'toto')
+ instance.log_message('toto@example.com', msg)
+ content = read_file(instance, 'toto@example.com')
+ line = parse_log_lines(content.split('\n'), '')[0]
+ assert line['nickname'] == 'toto'
+ assert line['txt'] == 'content'
+ msg2 = Message('content\ncontent2', 'titi')
+ instance.log_message('toto@example.com', msg2)
+ content = read_file(instance, 'toto@example.com')
+ lines = parse_log_lines(content.split('\n'), '')
+
+ assert lines[0]['nickname'] == 'toto'
+ assert lines[0]['txt'] == 'content'
+ assert lines[1]['nickname'] == 'titi'
+ assert lines[1]['txt'] == 'content\ncontent2'
+
def test_parse_message():
line = 'MR 20170909T09:09:09Z 000 <nick>  body'
- assert vars(parse_log_line(line)) == vars(LogMessage('2017', '09', '09', '09', '09', '09', '0', 'nick', 'body'))
+ assert vars(parse_log_line(line, 'user@domain')) == vars(LogMessage('2017', '09', '09', '09', '09', '09', '0', 'nick', 'body'))
line = '<>'
- assert parse_log_line(line) is None
+ assert parse_log_line(line, 'user@domain') is None
line = 'MR 20170908T07:05:04Z 003 <nick>  '
- assert vars(parse_log_line(line)) == vars(LogMessage('2017', '09', '08', '07', '05', '04', '003', 'nick', ''))
+ assert vars(parse_log_line(line, 'user@domain')) == vars(LogMessage('2017', '09', '08', '07', '05', '04', '003', 'nick', ''))
def test_log_and_parse_messages():
- msg1 = {'nick': 'toto', 'msg': 'coucou', 'date': datetime.datetime.now().replace(microsecond=0)}
+ msg1 = {
+ 'nick': 'toto',
+ 'msg': 'coucou',
+ 'date': datetime.datetime.now().replace(microsecond=0),
+ 'prefix': 'MR',
+ }
msg1_utc = get_utc_time(msg1['date'])
built_msg1 = build_log_message(**msg1)
assert built_msg1 == 'MR %s 000 <toto>  coucou\n' % (msg1_utc.strftime('%Y%m%dT%H:%M:%SZ'))
- msg2 = {'nick': 'toto', 'msg': 'coucou\ncoucou', 'date': datetime.datetime.now().replace(microsecond=0)}
+ msg2 = {
+ 'nick': 'toto',
+ 'msg': 'coucou\ncoucou',
+ 'date': datetime.datetime.now().replace(microsecond=0),
+ 'prefix': 'MR',
+ }
built_msg2 = build_log_message(**msg2)
msg2_utc = get_utc_time(msg2['date'])
assert built_msg2 == 'MR %s 001 <toto>  coucou\n coucou\n' % (msg2_utc.strftime('%Y%m%dT%H:%M:%SZ'))
- assert parse_log_lines((built_msg1 + built_msg2).split('\n')) == [
- {'time': msg1['date'], 'history': True, 'txt': '\x195,-1}coucou', 'nickname': 'toto'},
- {'time': msg2['date'], 'history': True, 'txt': '\x195,-1}coucou\ncoucou', 'nickname': 'toto'},
+ assert parse_log_lines((built_msg1 + built_msg2).split('\n'), 'user@domain') == [
+ {'time': msg1['date'], 'history': True, 'txt': 'coucou', 'nickname': 'toto', 'type': 'message'},
+ {'time': msg2['date'], 'history': True, 'txt': 'coucou\ncoucou', 'nickname': 'toto', 'type': 'message'},
]
diff --git a/test/test_tabs.py b/test/test_tabs.py
index 0a6930d4..d2503df2 100644
--- a/test/test_tabs.py
+++ b/test/test_tabs.py
@@ -12,16 +12,24 @@ class DummyTab(Tab):
count = 0
def __init__(self):
- self.name = 'dummy%s' % self.count
+ self._name = 'dummy%s' % self.count
DummyTab.count += 1
+ @property
+ def name(self):
+ return self._name
+
+ @name.setter
+ def name(self, value):
+ self._name = value
+
@staticmethod
def reset():
DummyTab.count = 0
def test_append():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
tabs.append(dummy)
assert tabs[0] is dummy
@@ -38,7 +46,7 @@ def test_append():
def test_delete():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
tabs.append(dummy)
@@ -53,7 +61,7 @@ def test_delete():
def test_delete_restore_previous():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -74,7 +82,7 @@ def test_delete_restore_previous():
def test_delete_other_tab():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -94,7 +102,7 @@ def test_delete_other_tab():
def test_insert_and_gaps():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -117,7 +125,7 @@ def test_insert_and_gaps():
def test_replace_tabs():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -132,7 +140,7 @@ def test_replace_tabs():
def test_prev_next():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -158,7 +166,7 @@ def test_prev_next():
def test_set_current():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -174,7 +182,7 @@ def test_set_current():
def test_slice():
DummyTab.reset()
- tabs = Tabs(h)
+ tabs = Tabs(h, None)
dummy = DummyTab()
dummy2 = DummyTab()
dummy3 = DummyTab()
@@ -183,3 +191,25 @@ def test_slice():
tabs.append(dummy3)
assert tabs[1:2][0] is dummy2
+
+def test_find_by_unique_prefix():
+ DummyTab.reset()
+ tabs = Tabs(h, None)
+ t1 = DummyTab()
+ t2 = DummyTab()
+ t3 = DummyTab()
+ tabs.append(t1)
+ tabs.append(t2)
+ tabs.append(t3)
+
+ t1.name = "foo"
+ t2.name = "bar"
+ t3.name = "fnord"
+
+ assert tabs.find_by_unique_prefix("f") == (True, None)
+ assert tabs.find_by_unique_prefix("b") == (True, t2)
+ assert tabs.find_by_unique_prefix("fo") == (True, t1)
+ assert tabs.find_by_unique_prefix("fn") == (True, t3)
+ assert tabs.find_by_unique_prefix("fx") == (False, None)
+ assert tabs.find_by_unique_prefix("x") == (False, None)
+ assert tabs.find_by_unique_prefix("") == (True, None)
diff --git a/test/test_text_buffer.py b/test/test_text_buffer.py
new file mode 100644
index 00000000..3daf54d4
--- /dev/null
+++ b/test/test_text_buffer.py
@@ -0,0 +1,200 @@
+"""
+Tests for the TextBuffer class
+"""
+from pytest import fixture
+
+from poezio.text_buffer import (
+ TextBuffer,
+ HistoryGap,
+)
+
+from poezio.ui.types import (
+ Message,
+ BaseMessage,
+ MucOwnJoinMessage,
+ MucOwnLeaveMessage,
+)
+
+
+@fixture(scope='function')
+def buf2048():
+ return TextBuffer(2048)
+
+
+@fixture(scope='function')
+def msgs_nojoin():
+ msg1 = Message('1', 'q')
+ msg2 = Message('2', 's')
+ leave = MucOwnLeaveMessage('leave')
+ return [msg1, msg2, leave]
+
+
+@fixture(scope='function')
+def msgs_noleave():
+ join = MucOwnJoinMessage('join')
+ msg3 = Message('3', 'd')
+ msg4 = Message('4', 'f')
+ return [join, msg3, msg4]
+
+
+@fixture(scope='function')
+def msgs_doublejoin():
+ join = MucOwnJoinMessage('join')
+ msg1 = Message('1', 'd')
+ msg2 = Message('2', 'f')
+ join2 = MucOwnJoinMessage('join')
+ return [join, msg1, msg2, join2]
+
+
+def test_last_message(buf2048):
+ msg = BaseMessage('toto')
+ buf2048.add_message(BaseMessage('titi'))
+ buf2048.add_message(msg)
+ assert buf2048.last_message is msg
+
+
+def test_message_nb_limit():
+ buf = TextBuffer(5)
+ for i in range(10):
+ buf.add_message(BaseMessage("%s" % i))
+ assert len(buf.messages) == 5
+
+
+def test_find_gap(buf2048, msgs_noleave):
+ msg1 = Message('1', 'q')
+ msg2 = Message('2', 's')
+ leave = MucOwnLeaveMessage('leave')
+ join = MucOwnJoinMessage('join')
+ msg3 = Message('3', 'd')
+ msg4 = Message('4', 'f')
+ msgs = [msg1, msg2, leave, join, msg3, msg4]
+ for msg in msgs:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert gap.leave_message == leave
+ assert gap.join_message == join
+ assert gap.last_timestamp_before_leave == msg2.time
+ assert gap.first_timestamp_after_join == msg3.time
+
+
+def test_find_gap_doublejoin(buf2048, msgs_doublejoin):
+ for msg in msgs_doublejoin:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert gap.leave_message == msgs_doublejoin[2]
+ assert gap.join_message == msgs_doublejoin[3]
+
+
+def test_find_gap_doublejoin_no_msg(buf2048):
+ join1 = MucOwnJoinMessage('join')
+ join2 = MucOwnJoinMessage('join')
+ for msg in [join1, join2]:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert gap.leave_message is join1
+ assert gap.join_message is join2
+
+
+def test_find_gap_already_filled(buf2048):
+ msg1 = Message('1', 'q')
+ msg2 = Message('2', 's')
+ leave = MucOwnLeaveMessage('leave')
+ msg5 = Message('5', 'g')
+ msg6 = Message('6', 'h')
+ join = MucOwnJoinMessage('join')
+ msg3 = Message('3', 'd')
+ msg4 = Message('4', 'f')
+ msgs = [msg1, msg2, leave, msg5, msg6, join, msg3, msg4]
+ for msg in msgs:
+ buf2048.add_message(msg)
+ assert buf2048.find_last_gap_muc() is None
+
+
+def test_find_gap_noleave(buf2048, msgs_noleave):
+ for msg in msgs_noleave:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert gap.leave_message is None
+ assert gap.last_timestamp_before_leave is None
+ assert gap.join_message == msgs_noleave[0]
+ assert gap.first_timestamp_after_join == msgs_noleave[1].time
+
+
+def test_find_gap_nojoin(buf2048, msgs_nojoin):
+ for msg in msgs_nojoin:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert gap.leave_message == msgs_nojoin[-1]
+ assert gap.join_message is None
+ assert gap.last_timestamp_before_leave == msgs_nojoin[1].time
+
+
+def test_get_gap_index(buf2048):
+ msg1 = Message('1', 'q')
+ msg2 = Message('2', 's')
+ leave = MucOwnLeaveMessage('leave')
+ join = MucOwnJoinMessage('join')
+ msg3 = Message('3', 'd')
+ msg4 = Message('4', 'f')
+ msgs = [msg1, msg2, leave, join, msg3, msg4]
+ for msg in msgs:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert buf2048.get_gap_index(gap) == 3
+
+
+def test_get_gap_index_doublejoin(buf2048, msgs_doublejoin):
+ for msg in msgs_doublejoin:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert buf2048.get_gap_index(gap) == 3
+
+
+def test_get_gap_index_doublejoin_no_msg(buf2048):
+ join1 = MucOwnJoinMessage('join')
+ join2 = MucOwnJoinMessage('join')
+ for msg in [join1, join2]:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert buf2048.get_gap_index(gap) == 1
+
+
+def test_get_gap_index_nojoin(buf2048, msgs_nojoin):
+ for msg in msgs_nojoin:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert buf2048.get_gap_index(gap) == 3
+
+
+def test_get_gap_index_noleave(buf2048, msgs_noleave):
+ for msg in msgs_noleave:
+ buf2048.add_message(msg)
+ gap = buf2048.find_last_gap_muc()
+ assert buf2048.get_gap_index(gap) == 0
+
+
+def test_add_history_messages(buf2048):
+ msg1 = Message('1', 'q')
+ msg2 = Message('2', 's')
+ leave = MucOwnLeaveMessage('leave')
+ join = MucOwnJoinMessage('join')
+ msg3 = Message('3', 'd')
+ msg4 = Message('4', 'f')
+ msgs = [msg1, msg2, leave, join, msg3, msg4]
+ for msg in msgs:
+ buf2048.add_message(msg)
+ msg5 = Message('5', 'g')
+ msg6 = Message('6', 'h')
+ gap = buf2048.find_last_gap_muc()
+ buf2048.add_history_messages([msg5, msg6], gap=gap)
+ assert buf2048.messages == [msg1, msg2, leave, msg5, msg6, join, msg3, msg4]
+
+
+def test_add_history_empty(buf2048):
+ msg1 = Message('1', 'q')
+ msg2 = Message('2', 's')
+ msg3 = Message('3', 'd')
+ msg4 = Message('4', 'f')
+ buf2048.add_message(msg1)
+ buf2048.add_history_messages([msg2, msg3, msg4])
+ assert buf2048.messages == [msg2, msg3, msg4, msg1]
diff --git a/test/test_ui/test_funcs.py b/test/test_ui/test_funcs.py
new file mode 100644
index 00000000..0e61549c
--- /dev/null
+++ b/test/test_ui/test_funcs.py
@@ -0,0 +1,46 @@
+from poezio.ui.funcs import (
+ find_first_format_char,
+ parse_attrs,
+ truncate_nick,
+)
+
+
+def test_find_char_not_present():
+ assert find_first_format_char("toto") == -1
+
+
+def test_find_char():
+ assert find_first_format_char('a \x1A 1') == 2
+
+
+def test_truncate_nick():
+ assert truncate_nick("toto") == "toto"
+
+
+def test_truncate_nick_wrong_size():
+ assert truncate_nick("toto", -10) == "t…"
+
+
+def test_truncate_nick_too_long():
+ nick = "012345678901234567"
+ assert truncate_nick(nick) == nick[:10] + "…"
+
+
+def test_truncate_nick_no_nick():
+ assert truncate_nick('') == ''
+
+
+def test_parse_attrs():
+ text = "\x19o\x19u\x19b\x19i\x191}\x19o\x194}"
+ assert parse_attrs(text) == ['4}']
+
+
+def test_parse_attrs_broken_char():
+ text = "coucou\x19"
+ assert parse_attrs(text) == []
+
+
+def test_parse_attrs_previous():
+ text = "coucou"
+ previous = ['u']
+ assert parse_attrs(text, previous=previous) == previous
diff --git a/test/test_ui/test_render.py b/test/test_ui/test_render.py
new file mode 100644
index 00000000..bdfc1be6
--- /dev/null
+++ b/test/test_ui/test_render.py
@@ -0,0 +1,144 @@
+import pytest
+from contextlib import contextmanager
+from datetime import datetime
+from poezio.theming import get_theme
+from poezio.ui.render import build_lines, Line, write_pre
+from poezio.ui.types import BaseMessage, Message, StatusMessage, XMLLog
+
+def test_simple_build_basemsg():
+ msg = BaseMessage(txt='coucou')
+ line = build_lines(msg, 100, True, 10)[0]
+ assert (line.start_pos, line.end_pos) == (0, 6)
+
+
+def test_simple_render_message():
+ msg = Message(txt='coucou', nickname='toto')
+ line = build_lines(msg, 100, True, 10)[0]
+ assert (line.start_pos, line.end_pos) == (0, 6)
+
+
+def test_simple_render_xmllog():
+ msg = XMLLog(txt='coucou', incoming=True)
+ line = build_lines(msg, 100, True, 10)[0]
+ assert (line.start_pos, line.end_pos) == (0, 6)
+
+
+def test_simple_render_separator():
+ line = build_lines(None, 100, True, 10)[0]
+ assert line is None
+
+
+def test_simple_render_status():
+ class Obj:
+ name = 'toto'
+ msg = StatusMessage("Coucou {name}", {'name': lambda: Obj.name})
+ assert msg.txt == "Coucou toto"
+ Obj.name = 'titi'
+ build_lines(msg, 100, True, 10)[0]
+ assert msg.txt == "Coucou titi"
+
+
+class FakeBuffer:
+ def __init__(self):
+ self.text = ''
+
+ @contextmanager
+ def colored_text(self, *args, **kwargs):
+ yield None
+
+ def addstr(self, txt):
+ self.text += txt
+
+@pytest.fixture(scope='function')
+def buffer():
+ return FakeBuffer()
+
+@pytest.fixture
+def time():
+ return datetime.strptime('2019-09-27 10:11:12', '%Y-%m-%d %H:%M:%S')
+
+def test_write_pre_basemsg(buffer):
+ str_time = '10:11:12'
+ time = datetime.strptime(str_time, '%H:%M:%S')
+ msg = BaseMessage(txt='coucou', time=time)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '10:11:12 '
+ assert size == len(buffer.text)
+
+def test_write_pre_message_simple(buffer, time):
+ msg = Message(txt='coucou', nickname='toto', time=time)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '10:11:12 toto> '
+ assert size == len(buffer.text)
+
+
+def test_write_pre_message_simple_history(buffer, time):
+ msg = Message(txt='coucou', nickname='toto', time=time, history=True)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '2019-09-27 10:11:12 toto> '
+ assert size == len(buffer.text)
+
+
+def test_write_pre_message_highlight(buffer, time):
+ msg = Message(txt='coucou', nickname='toto', time=time, highlight=True)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '10:11:12 toto> '
+ assert size == len(buffer.text)
+
+def test_write_pre_message_no_timestamp(buffer):
+ msg = Message(txt='coucou', nickname='toto')
+ size = write_pre(msg, buffer, False, 10)
+ assert buffer.text == 'toto> '
+ assert size == len(buffer.text)
+
+
+def test_write_pre_message_me(buffer, time):
+ msg = Message(txt='/me coucou', nickname='toto', time=time)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '10:11:12 * toto '
+ assert size == len(buffer.text)
+
+
+def test_write_pre_message_revisions(buffer, time):
+ msg = Message(txt='coucou', nickname='toto', time=time, revisions=5)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '10:11:12 toto5> '
+ assert size == len(buffer.text)
+
+def test_write_pre_message_revisions_me(buffer, time):
+ msg = Message(txt='/me coucou', nickname='toto', time=time, revisions=5)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '10:11:12 * toto5 '
+ assert size == len(buffer.text)
+
+
+def test_write_pre_message_ack(buffer, time):
+ ack = get_theme().CHAR_ACK_RECEIVED
+ expected = '10:11:12 %s toto> ' % ack
+ msg = Message(txt='coucou', nickname='toto', time=time, ack=1)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == expected
+ assert size == len(buffer.text)
+
+
+def test_write_pre_message_nack(buffer, time):
+ nack = get_theme().CHAR_NACK
+ expected = '10:11:12 %s toto> ' % nack
+ msg = Message(txt='coucou', nickname='toto', time=time, ack=-1)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == expected
+ assert size == len(buffer.text)
+
+
+def test_write_pre_xmllog_in(buffer):
+ msg = XMLLog(txt="coucou", incoming=True)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '%s IN ' % msg.time.strftime('%H:%M:%S')
+ assert size == len(buffer.text)
+
+
+def test_write_pre_xmllog_out(buffer):
+ msg = XMLLog(txt="coucou", incoming=False)
+ size = write_pre(msg, buffer, True, 10)
+ assert buffer.text == '%s OUT ' % msg.time.strftime('%H:%M:%S')
+ assert size == len(buffer.text)
diff --git a/test/test_ui/test_types.py b/test/test_ui/test_types.py
new file mode 100644
index 00000000..e4c6c010
--- /dev/null
+++ b/test/test_ui/test_types.py
@@ -0,0 +1,126 @@
+import pytest
+from datetime import datetime
+
+from poezio.ui.types import BaseMessage, Message, XMLLog
+
+
+def test_create_message():
+ now = datetime.now()
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ )
+ assert now < msg.time < datetime.now()
+
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ time=now,
+ )
+ assert msg.time == now
+
+
+def test_message_offset_simple():
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ )
+ example = "10:10:10 toto> "
+ assert msg.compute_offset(True, 10) == len(example)
+
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ history=True,
+ )
+ example = "2019:09:01 10:10:10 toto> "
+ assert msg.compute_offset(True, 10) == len(example)
+
+def test_message_offset_no_nick():
+ msg = Message(
+ txt="coucou",
+ nickname="",
+ )
+ example = "10:10:10 "
+ assert msg.compute_offset(True, 10) == len(example)
+
+def test_message_offset_ack():
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ ack=1,
+ )
+ example = "10:10:10 V toto> "
+ assert msg.compute_offset(True, 10) == len(example)
+
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ ack=-1,
+ )
+ example = "10:10:10 X toto> "
+ assert msg.compute_offset(True, 10) == len(example)
+
+
+def test_message_offset_me():
+ msg = Message(
+ txt="/me coucou",
+ nickname="toto",
+ )
+ example = "10:10:10 * toto "
+ assert msg.compute_offset(True, 10) == len(example)
+
+
+def test_message_offset_revisions():
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ revisions=3,
+ )
+ example = "10:10:10 toto3> "
+ assert msg.compute_offset(True, 10) == len(example)
+
+ msg = Message(
+ txt="coucou",
+ nickname="toto",
+ revisions=250,
+ )
+ example = "10:10:10 toto250> "
+ assert msg.compute_offset(True, 10) == len(example)
+
+
+def test_message_repr_works():
+ msg1 = Message(
+ txt="coucou",
+ nickname="toto",
+ revisions=250,
+ )
+ msg2 = Message(
+ txt="coucou",
+ nickname="toto",
+ old_message=msg1
+ )
+
+ assert repr(msg2) is not None
+
+def test_xmllog_offset():
+ msg = XMLLog(
+ txt='toto',
+ incoming=True,
+ )
+ example = '10:10:10 IN '
+ assert msg.compute_offset(True, 10) == len(example)
+
+ msg = XMLLog(
+ txt='toto',
+ incoming=False,
+ )
+ example = '10:10:10 OUT '
+ assert msg.compute_offset(True, 10) == len(example)
+
+def test_basemessage_offset():
+ msg = BaseMessage(
+ txt='coucou',
+ )
+ example = '10:10:10 '
+ assert msg.compute_offset(True, 10) == len(example)
diff --git a/test/test_user.py b/test/test_user.py
new file mode 100644
index 00000000..cda85577
--- /dev/null
+++ b/test/test_user.py
@@ -0,0 +1,44 @@
+"""
+Tests for the User class
+"""
+
+import pytest
+from datetime import datetime
+from slixmpp import JID
+from poezio.user import User
+
+
+@pytest.fixture
+def user1():
+ return User(
+ nick='nick1',
+ affiliation='member',
+ show='xa',
+ status='My Status!',
+ role='moderator',
+ jid=JID('foo@muc/nick1'),
+ color='red',
+ )
+
+
+def test_new_user(user1):
+ assert user1.last_talked == datetime(1, 1, 1)
+ assert user1.jid == JID('foo@muc/nick1')
+ assert user1.chatstate is None
+ assert user1.affiliation == 'member'
+ assert user1.show == 'xa'
+ assert user1.status == 'My Status!'
+ assert user1.role == 'moderator'
+ assert user1.nick == 'nick1'
+ assert user1.color == (196, -1)
+ assert str(user1) == '>nick1<'
+
+
+def test_change_nick(user1):
+ user1.change_nick('nick2')
+ assert user1.nick == 'nick2'
+
+
+def test_change_color(user1):
+ user1.change_color('blue')
+ assert user1.color == (21, -1)
diff --git a/test/test_windows.py b/test/test_windows.py
index af1b9d4a..3be96172 100644
--- a/test/test_windows.py
+++ b/test/test_windows.py
@@ -1,22 +1,32 @@
import pytest
+from poezio.windows import Input, HistoryInput, MessageInput
+from poezio import config
+
-class ConfigShim(object):
+class ConfigShim:
def get(self, *args, **kwargs):
return ''
-from poezio import config
+ def getbool(self, *args, **kwargs):
+ return True
+
+
config.config = ConfigShim()
-from poezio.windows import Input, HistoryInput, MessageInput
class SubInput(Input):
+
def rewrite_text(self, *args, **kwargs):
return None
+
@pytest.fixture
def input():
+ from poezio.windows import base_wins
+ base_wins.TAB_WIN = True # The value is not relevant
return SubInput()
+
class TestInput(object):
def test_do_command(self, input):
@@ -29,9 +39,9 @@ class TestInput(object):
assert input.text == 'acoucou'
def test_empty(self, input):
- assert input.is_empty() == True
+ assert input.is_empty()
input.do_command('a')
- assert input.is_empty() == False
+ assert not input.is_empty()
def test_key_left(self, input):
for char in 'this is a line':
diff --git a/test/test_xhtml.py b/test/test_xhtml.py
index 472991c6..333a10c3 100644
--- a/test/test_xhtml.py
+++ b/test/test_xhtml.py
@@ -8,11 +8,15 @@ import poezio.xhtml
from poezio.xhtml import (poezio_colors_to_html, xhtml_to_poezio_colors,
_parse_css as parse_css, clean_text)
-class ConfigShim(object):
+
+class ConfigShim:
def __init__(self):
self.value = True
def get(self, *args, **kwargs):
return self.value
+ def getbool(self, *args, **kwargs):
+ return self.value
+
config = ConfigShim()
poezio.xhtml.config = config
diff --git a/tools/sticker-picker/Cargo.toml b/tools/sticker-picker/Cargo.toml
new file mode 100644
index 00000000..476ceea6
--- /dev/null
+++ b/tools/sticker-picker/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "poezio-sticker-picker"
+version = "0.1.0"
+edition = "2021"
+authors = ["Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>"]
+license = "GPL-3.0-or-later"
+description = "Helper tool for selecting a sticker inside a pack"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+gtk = { package = "gtk4", version = "0.4", features = ["v4_6"] }
+gdk = { package = "gdk4", version = "0.4", features = ["v4_6"] }
+glib = "0.15"
+gio = "0.15"
+once_cell = "1.9.0"
diff --git a/tools/sticker-picker/src/main.rs b/tools/sticker-picker/src/main.rs
new file mode 100644
index 00000000..429043e3
--- /dev/null
+++ b/tools/sticker-picker/src/main.rs
@@ -0,0 +1,93 @@
+// This file is part of Poezio.
+//
+// Poezio is free software: you can redistribute it and/or modify
+// it under the terms of the GPL-3.0-or-later license. See the COPYING file.
+
+mod sticker;
+
+use gtk::prelude::*;
+use sticker::StickerType as Sticker;
+
+fn main() {
+ let app = gtk::Application::builder()
+ .application_id("io.poez.StickerPicker")
+ .flags(gio::ApplicationFlags::HANDLES_OPEN)
+ .build();
+
+ let quit = gio::SimpleAction::new("quit", None);
+ app.set_accels_for_action("app.quit", &["<Control>q"]);
+ app.add_action(&quit);
+ quit.connect_activate(glib::clone!(@weak app => move |_, _| app.quit()));
+
+ app.connect_open(move |app, directories, _| {
+ let path = match directories {
+ [directory] => directory.path().unwrap(),
+ _ => {
+ eprintln!("Only a single directory is allowed!");
+ std::process::exit(1);
+ }
+ };
+
+ let window = gtk::ApplicationWindow::builder()
+ .application(app)
+ .default_width(1280)
+ .default_height(720)
+ .title("Poezio Sticker Picker")
+ .build();
+
+ let sw = gtk::ScrolledWindow::builder()
+ .has_frame(true)
+ .hscrollbar_policy(gtk::PolicyType::Always)
+ .vscrollbar_policy(gtk::PolicyType::Always)
+ .vexpand(true)
+ .build();
+ window.set_child(Some(&sw));
+
+ let store = gio::ListStore::new(Sticker::static_type());
+
+ for dir_entry in std::fs::read_dir(path).unwrap() {
+ let dir_entry = dir_entry.unwrap();
+ let file_name = dir_entry.file_name().into_string().unwrap();
+ let sticker = Sticker::new(file_name, &dir_entry.path());
+ store.append(&sticker);
+ }
+
+ let factory = gtk::SignalListItemFactory::new();
+ factory.connect_setup(|_, item| {
+ let picture = gtk::Picture::builder()
+ .alternative_text("Sticker")
+ .can_shrink(false)
+ .build();
+ item.set_child(Some(&picture));
+ });
+ factory.connect_bind(|_, list_item| {
+ if let Some(child) = list_item.child() {
+ if let Some(item) = list_item.item() {
+ let picture: gtk::Picture = child.downcast().unwrap();
+ let sticker: Sticker = item.downcast().unwrap();
+ picture.set_paintable(sticker.texture().as_ref());
+ }
+ }
+ });
+
+ let selection = gtk::SingleSelection::new(Some(&store));
+ let grid_view = gtk::GridView::builder()
+ .single_click_activate(true)
+ .model(&selection)
+ .factory(&factory)
+ .build();
+ grid_view.connect_activate(move |_, position| {
+ let item = store.item(position).unwrap();
+ let sticker: Sticker = item.downcast().unwrap();
+ if let Some(filename) = sticker.filename() {
+ println!("{}", filename);
+ std::process::exit(0);
+ }
+ });
+ sw.set_child(Some(&grid_view));
+
+ window.show();
+ });
+
+ app.run();
+}
diff --git a/tools/sticker-picker/src/sticker.rs b/tools/sticker-picker/src/sticker.rs
new file mode 100644
index 00000000..309b21fa
--- /dev/null
+++ b/tools/sticker-picker/src/sticker.rs
@@ -0,0 +1,106 @@
+// This file is part of Poezio.
+//
+// Poezio is free software: you can redistribute it and/or modify
+// it under the terms of the GPL-3.0-or-later license. See the COPYING file.
+
+use gtk::prelude::*;
+use gtk::subclass::prelude::*;
+use std::cell::RefCell;
+use std::path::Path;
+
+#[derive(Debug, Default)]
+pub struct Sticker {
+ filename: RefCell<Option<String>>,
+ texture: RefCell<Option<gdk::Texture>>,
+}
+
+#[glib::object_subclass]
+impl ObjectSubclass for Sticker {
+ const NAME: &'static str = "Sticker";
+ type Type = StickerType;
+}
+
+impl ObjectImpl for Sticker {
+ fn properties() -> &'static [glib::ParamSpec] {
+ use once_cell::sync::Lazy;
+ static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+ vec![
+ glib::ParamSpecString::new(
+ "filename",
+ "Filename",
+ "Filename",
+ None,
+ glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+ ),
+ glib::ParamSpecObject::new(
+ "texture",
+ "Texture",
+ "Texture",
+ gdk::Texture::static_type(),
+ glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+ ),
+ ]
+ });
+ PROPERTIES.as_ref()
+ }
+
+ fn set_property(
+ &self,
+ _obj: &StickerType,
+ _id: usize,
+ value: &glib::Value,
+ pspec: &glib::ParamSpec,
+ ) {
+ match pspec.name() {
+ "filename" => {
+ let filename = value.get().unwrap();
+ self.filename.replace(filename);
+ }
+ "texture" => {
+ let texture = value.get().unwrap();
+ self.texture.replace(texture);
+ }
+ _ => unimplemented!(),
+ }
+ }
+
+ fn property(&self, _obj: &StickerType, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+ match pspec.name() {
+ "filename" => self.filename.borrow().to_value(),
+ "texture" => self.texture.borrow().to_value(),
+ _ => unimplemented!(),
+ }
+ }
+}
+
+glib::wrapper! {
+ pub struct StickerType(ObjectSubclass<Sticker>);
+}
+
+impl StickerType {
+ pub fn new(filename: String, path: &Path) -> StickerType {
+ let texture = gdk::Texture::from_filename(path).unwrap();
+ glib::Object::new(&[("filename", &filename), ("texture", &texture)])
+ .expect("Failed to create Sticker")
+ }
+
+ pub fn filename(&self) -> Option<String> {
+ let imp = self.imp();
+ let filename = imp.filename.borrow();
+ if let Some(filename) = filename.as_ref() {
+ Some(filename.clone())
+ } else {
+ None
+ }
+ }
+
+ pub fn texture(&self) -> Option<gdk::Texture> {
+ let imp = self.imp();
+ let texture = imp.texture.borrow();
+ if let Some(texture) = texture.as_ref() {
+ Some(texture.clone())
+ } else {
+ None
+ }
+ }
+}
diff --git a/update.sh b/update.sh
index 9f8bf382..e8ba0d0a 100755
--- a/update.sh
+++ b/update.sh
@@ -23,12 +23,12 @@ command -v "$POEZIO_PYTHON" > /dev/null 2>&1 || {
}
$POEZIO_PYTHON -c 'import venv' &> /dev/null || {
- echo "'$POEZIO_PYTHON' venv module not found. Check that you have python (>= 3.5) installed,"
+ echo "'$POEZIO_PYTHON' venv module not found. Check that you have python (>= 3.7) installed,"
exit 1
}
echo 'Updating poezio'
-git pull --ff-only origin master || {
+git pull --ff-only origin main || {
echo "The script failed to update poezio."
exit 1
}
@@ -43,7 +43,7 @@ then
. "$POEZIO_VENV/bin/activate"
echo 'Updating the in-venv pip'
pip install --upgrade pip
- python3 -c 'import sys;(print("Python 3.5 or newer is required") and exit(1)) if sys.version_info < (3, 5) else exit(0)' || exit 1
+ python3 -c 'import sys;(print("Python 3.7 or newer is required") and exit(1)) if sys.version_info < (3, 7) else exit(0)' || exit 1
echo 'Updating the poezio dependencies'
pip install -r requirements.txt --upgrade
echo 'Updating the poezio plugin dependencies'
@@ -55,7 +55,7 @@ else
. "$POEZIO_VENV/bin/activate"
cd "$POEZIO_VENV" # needed to download slixmpp inside the venv
- python3 -c 'import sys;(print("Python 3.5 or newer is required") and exit(1)) if sys.version_info < (3, 5) else exit(0)' || exit 1
+ python3 -c 'import sys;(print("Python 3.7 or newer is required") and exit(1)) if sys.version_info < (3, 7) else exit(0)' || exit 1
echo 'Installing the poezio dependencies using pip'
pip install -r "../requirements.txt"