summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab-ci.yml27
-rw-r--r--CHANGELOG138
-rw-r--r--COPYING684
-rw-r--r--MANIFEST.in1
-rw-r--r--README.rst4
-rw-r--r--data/default_config.cfg6
-rw-r--r--data/doap.xml23
-rw-r--r--data/io.poez.Poezio.appdata.xml5
-rw-r--r--data/poezio_logo.svg2
-rw-r--r--data/poezio_logs.12
-rw-r--r--data/scripts-manpages.xml2
-rw-r--r--doc/source/conf.py16
-rw-r--r--doc/source/configuration.rst30
-rw-r--r--doc/source/plugins/index.rst6
-rw-r--r--doc/source/plugins/sticker.rst6
-rwxr-xr-xlaunch.sh14
-rw-r--r--plugins/amsg.py4
-rw-r--r--plugins/b64.py27
-rw-r--r--plugins/contact.py21
-rw-r--r--plugins/disco.py7
-rw-r--r--plugins/embed.py4
-rw-r--r--plugins/exec.py2
-rw-r--r--plugins/irc.py227
-rw-r--r--plugins/lastlog.py2
-rw-r--r--plugins/marquee.py7
-rw-r--r--plugins/ping.py6
-rw-r--r--plugins/reorder.py2
-rw-r--r--plugins/send_delayed.py3
-rw-r--r--plugins/sticker.py97
-rw-r--r--plugins/tell.py3
-rw-r--r--plugins/upload.py23
-rw-r--r--plugins/user_extras.py9
-rw-r--r--plugins/vcard.py8
-rw-r--r--poezio/colors.py1
-rw-r--r--poezio/common.py6
-rw-r--r--poezio/config.py57
-rw-r--r--poezio/connection.py13
-rw-r--r--poezio/contact.py18
-rw-r--r--poezio/core/commands.py63
-rw-r--r--poezio/core/core.py108
-rw-r--r--poezio/core/handlers.py334
-rwxr-xr-xpoezio/daemon.py2
-rw-r--r--poezio/events.py26
-rw-r--r--poezio/fixes.py22
-rwxr-xr-xpoezio/keyboard.py2
-rw-r--r--poezio/log_loader.py13
-rw-r--r--poezio/logger.py48
-rw-r--r--poezio/mam.py25
-rw-r--r--poezio/multiuserchat.py4
-rw-r--r--poezio/plugin.py3
-rw-r--r--poezio/plugin_e2ee.py231
-rw-r--r--poezio/poezio.py9
-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.py6
-rw-r--r--poezio/size_manager.py12
-rw-r--r--poezio/tabs/basetabs.py59
-rw-r--r--poezio/tabs/bookmarkstab.py2
-rw-r--r--poezio/tabs/conversationtab.py45
-rw-r--r--poezio/tabs/muclisttab.py3
-rw-r--r--poezio/tabs/muctab.py170
-rw-r--r--poezio/tabs/privatetab.py21
-rw-r--r--poezio/tabs/rostertab.py4
-rw-r--r--poezio/tabs/xmltab.py1
-rwxr-xr-xpoezio/theming.py17
-rw-r--r--poezio/timed_events.py2
-rw-r--r--poezio/ui/consts.py2
-rw-r--r--poezio/ui/render.py4
-rw-r--r--poezio/user.py3
-rw-r--r--poezio/utils.py21
-rw-r--r--poezio/version.py4
-rw-r--r--poezio/windows/bookmark_forms.py2
-rw-r--r--poezio/windows/data_forms.py7
-rw-r--r--poezio/windows/image.py5
-rw-r--r--poezio/windows/info_bar.py20
-rw-r--r--poezio/windows/info_wins.py40
-rw-r--r--poezio/windows/inputs.py14
-rw-r--r--poezio/windows/roster_win.py5
-rw-r--r--poezio/xdg.py2
-rw-r--r--poezio/xhtml.py4
-rwxr-xr-xsetup.py8
-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
86 files changed, 2275 insertions, 1020 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 83518f72..e8bd5415 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -79,7 +79,7 @@ pytest-3.9:
pytest-3.10:
stage: test
- image: python:3.10-rc
+ image: python:3.10
script:
- apt-get update && apt-get install -y libidn11-dev
- git clone https://lab.louiz.org/poezio/slixmpp.git
@@ -90,18 +90,19 @@ pytest-3.10:
- python3 setup.py install
- py.test -v test/
-pylint-latest:
- stage: lint
- image: python:3
+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 pylint pyasn1-modules cffi --upgrade
+ - pip3 install pytest pyasn1-modules cffi --upgrade
- cd slixmpp
- python3 setup.py install
- cd ..
- python3 setup.py install
- - pylint -E poezio
+ - py.test -v test/
pylint-plugins:
stage: lint
@@ -115,11 +116,19 @@ pylint-plugins:
- python3 setup.py install
- pylint -E plugins
-mypy:
+mypy-fixed:
stage: lint
image: python:3
script:
- - pip3 install mypy
- - pip install "typed_ast>=1.4.0,<1.5.0" types-pkg_resources
+ - 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/CHANGELOG b/CHANGELOG
index 77ac1411..9480db1b 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,16 +1,142 @@
This file describes the new features in each poezio release.
-* Poezio 0.14 - dev
-
-# Minor Changes
+* 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.
-- Ensure bookmark is present before removing it in /close.
+- 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.
-# Bug fixes
+# Under the hood
-- Reorder: Fix traceback on serialized gap tabs.
+- 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
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/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 3028c5f5..d377c82a 100644
--- a/README.rst
+++ b/README.rst
@@ -95,7 +95,7 @@ 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
@@ -148,7 +148,7 @@ Thanks
.. |python versions| image:: https://img.shields.io/pypi/pyversions/poezio.svg
-.. |license| image:: https://img.shields.io/badge/license-zlib-blue.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
diff --git a/data/default_config.cfg b/data/default_config.cfg
index 9f284f07..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
diff --git a/data/doap.xml b/data/doap.xml
index 656b4d8e..6a1330b7 100644
--- a/data/doap.xml
+++ b/data/doap.xml
@@ -20,7 +20,7 @@
<developer-forum rdf:resource="xmpp:poezio@muc.poez.io?join"/>
<support-forum rdf:resource="xmpp:poezio@muc.poez.io?join"/>
- <license rdf:resource="https://git.poez.io/poezio/plain/COPYING"/>
+ <license rdf:resource="https://lab.louiz.org/poezio/poezio/-/raw/main/COPYING"/>
<!-- See https://github.com/ewilderj/doap/issues/49 -->
<language>en</language>
@@ -454,9 +454,9 @@
<implements>
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0319.html"/>
- <xmpp:status>complete</xmpp:status>
+ <xmpp:status>removed</xmpp:status>
<xmpp:version>1.0</xmpp:version>
- <xmpp:since>0.10</xmpp:since>
+ <xmpp:since>0.14</xmpp:since>
</xmpp:SupportedXep>
</implements>
<implements>
@@ -515,7 +515,7 @@
<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. 1:1 only</xmpp:note>
+ <xmpp:note>Available at https://lab.louiz.org/poezio/poezio-omemo. UI largely missing, trust management missing</xmpp:note>
</xmpp:SupportedXep>
</implements>
<implements>
@@ -526,9 +526,24 @@
<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"/>
diff --git a/data/io.poez.Poezio.appdata.xml b/data/io.poez.Poezio.appdata.xml
index a408a980..d6f479a3 100644
--- a/data/io.poez.Poezio.appdata.xml
+++ b/data/io.poez.Poezio.appdata.xml
@@ -4,7 +4,7 @@
<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>
@@ -34,7 +34,7 @@
<p>Features</p>
<ul>
- <li>Encryption: TLS, OTR, always chat with encryption.</li>
+ <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>
@@ -68,6 +68,7 @@
</provides>
<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"/>
diff --git a/data/poezio_logo.svg b/data/poezio_logo.svg
index 7848f8c6..30a93907 100644
--- a/data/poezio_logo.svg
+++ b/data/poezio_logo.svg
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
-<svg width="162.31mm" height="155.71mm" version="1.1" viewBox="0 0 162.31 155.71" xmlns="http://www.w3.org/2000/svg">
+<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"/>
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/doc/source/conf.py b/doc/source/conf.py
index af30d8ba..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.1'
+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 56f546d8..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]``
@@ -617,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``
diff --git a/doc/source/plugins/index.rst b/doc/source/plugins/index.rst
index 42578be8..c1222c84 100644
--- a/doc/source/plugins/index.rst
+++ b/doc/source/plugins/index.rst
@@ -211,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>`
@@ -342,6 +347,7 @@ Plugin index
simple_notify
spam
status
+ sticker
tell
time_marker
uptime
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/launch.sh b/launch.sh
index 57537631..b9d59cb5 100755
--- a/launch.sh
+++ b/launch.sh
@@ -1,20 +1,20 @@
#!/bin/sh
-cd $(dirname "$(readlink -f "$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.13.1-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"
@@ -25,5 +25,5 @@ else
fi
$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
-exec "$PYTHON3" -m poezio --custom-version "$args" "$@"
+PYTHONPATH="$proj_dir:$PYTHONPATH" exec "$PYTHON3" -m poezio --custom-version "$args" "$@"
diff --git a/plugins/amsg.py b/plugins/amsg.py
index 4cd6c055..3b81085a 100644
--- a/plugins/amsg.py
+++ b/plugins/amsg.py
@@ -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
index 1fec6123..82300a0f 100644
--- a/plugins/b64.py
+++ b/plugins/b64.py
@@ -4,7 +4,7 @@
#
# Copyright © 2019 Maxime “pep” Buquet <pep@bouah.net>
#
-# Distributed under terms of the zlib license.
+# Distributed under terms of the GPL-3.0+ license.
"""
Usage
@@ -23,10 +23,17 @@ This plugin also respects security guidelines listed in XEP-0419.
"""
from base64 import b64decode, b64encode
-from poezio.plugin_e2ee import E2EEPlugin
-from poezio.tabs import ChatTab
+from typing import List, Optional
from slixmpp import Message, JID
-from typing import Optional
+
+from poezio.plugin_e2ee import E2EEPlugin
+from poezio.tabs import (
+ ChatTab,
+ MucTab,
+ PrivateTab,
+ DynamicConversationTab,
+ StaticConversationTab,
+)
class Plugin(E2EEPlugin):
@@ -39,14 +46,22 @@ class Plugin(E2EEPlugin):
# This encryption mechanism is using <body/> as a container
replace_body_with_eme = False
- async def decrypt(self, message: Message, jid: Optional[JID], _tab: ChatTab) -> None:
+ # 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[JID], _tab: ChatTab) -> None:
+ async def encrypt(self, message: Message, _jid: Optional[List[JID]], _tab: ChatTab) -> None:
"""
Encrypt to base64
"""
diff --git a/plugins/contact.py b/plugins/contact.py
index a3a0514b..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,12 +26,6 @@ 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']
contacts = []
# iterate all data forms, in case there are multiple
@@ -45,15 +40,21 @@ class Plugin(BasePlugin):
field_value = values[var]
if field_value:
value = sep.join(field_value) if isinstance(field_value, list) else field_value
- contacts.append('%s: %s' % (title, value))
+ contacts.append(f'{title}: {value}')
if contacts:
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')
async def command_disco(self, jid):
try:
iq = await self.core.xmpp.plugin['xep_0030'].get_info(jid=jid, cached=False)
self.on_disco(iq)
- except InvalidJID as e:
- self.api.information('Invalid JID “%s”: %s' % (jid, e), 'Error')
+ 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/disco.py b/plugins/disco.py
index 52963d39..d15235f6 100644
--- a/plugins/disco.py
+++ b/plugins/disco.py
@@ -18,6 +18,7 @@ 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):
@@ -94,8 +95,12 @@ class Plugin(BasePlugin):
self.on_info(iq)
elif type_ == 'items':
iq = await self.core.xmpp.plugin['xep_0030'].get_items(
- jid=jid, node=node, cached=False
+ 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/embed.py b/plugins/embed.py
index 3ce2a9d3..4a68f035 100644
--- a/plugins/embed.py
+++ b/plugins/embed.py
@@ -29,8 +29,8 @@ class Plugin(BasePlugin):
help='Embed an image url into the contact\'s client',
usage='<image_url>')
- def embed_image_url(self, url):
- tab = self.api.current_tab()
+ 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
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/irc.py b/plugins/irc.py
index a7650ec4..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
@@ -131,32 +112,28 @@ 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):
- asyncio.ensure_future(
+ 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_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)
-
self.api.add_command(
'irc_join',
self.command_irc_join,
@@ -183,11 +160,11 @@ class Plugin(BasePlugin):
'example.com "hi there"`'),
short='Open a private conversation with an IRC user')
- async 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:
@@ -196,12 +173,25 @@ class Plugin(BasePlugin):
await asyncio.gather(*joins)
- async def initial_connect(self):
- gateway = self.config.get('gateway', 'irc.poez.io')
- sections = self.config.sections()
+ async def initial_connect(self) -> None:
+ gateway: str = self.config.getstr('gateway')
+ sections: List[str] = self.config.sections()
- for section in (s for s in sections if s != 'irc'):
+ 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
@@ -210,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:
+ if not already_opened:
await 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)
-
@command_args_parser.quoted(1, 1)
- async 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]):
- await 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:
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
@@ -343,14 +248,14 @@ class Plugin(BasePlugin):
else:
self.core.command.message('{}'.format(jid))
- async 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:
@@ -360,7 +265,7 @@ class Plugin(BasePlugin):
for room in rooms:
await self.core.command.join(room + suffix)
- async def join_room(self, name):
+ async def join_room(self, name: str) -> None:
"""
Join a room with only its name and the current tab
"""
@@ -368,20 +273,24 @@ 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)
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 = current.jid
if not current_jid.server == gateway:
@@ -404,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
index 70ea53c1..1c48fa06 100644
--- a/plugins/lastlog.py
+++ b/plugins/lastlog.py
@@ -5,7 +5,7 @@
# Copyright © 2018 Maxime “pep” Buquet
# Copyright © 2019 Madhur Garg
#
-# Distributed under terms of the zlib license. See the COPYING file.
+# 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
diff --git a/plugins/marquee.py b/plugins/marquee.py
index 9319a7f6..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
@@ -62,10 +63,12 @@ class Plugin(BasePlugin):
'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.jid
diff --git a/plugins/ping.py b/plugins/ping.py
index 46ce4efc..cc987bf0 100644
--- a/plugins/ping.py
+++ b/plugins/ping.py
@@ -123,7 +123,7 @@ class Plugin(BasePlugin):
jid = arg
if not arg:
jid = self.api.current_tab().jid
- asyncio.ensure_future(
+ asyncio.create_task(
self.command_ping(jid)
)
@@ -140,7 +140,7 @@ class Plugin(BasePlugin):
jid = JID(arg)
except InvalidJID:
return self.api.information('Invalid JID: %s' % arg, 'Error')
- asyncio.ensure_future(
+ asyncio.create_task(
self.command_ping(jid.full)
)
@@ -156,7 +156,7 @@ class Plugin(BasePlugin):
res = current.get_highest_priority_resource()
if res is not None:
jid =res.jid
- asyncio.ensure_future(
+ asyncio.create_task(
self.command_ping(jid)
)
diff --git a/plugins/reorder.py b/plugins/reorder.py
index d4ab464b..158b89bb 100644
--- a/plugins/reorder.py
+++ b/plugins/reorder.py
@@ -118,7 +118,7 @@ def parse_runtime_tablist(tablist):
i += 1
result = check_tab(tab)
# Don't serialize gap tabs as they're recreated automatically
- if result != 'empty':
+ if result != 'empty' and isinstance(tab, tuple(TEXT_TO_TAB.values())):
props.append((i, '%s:%s' % (result, tab.jid.full)))
return props
diff --git a/plugins/send_delayed.py b/plugins/send_delayed.py
index e8b00027..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
@@ -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/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/tell.py b/plugins/tell.py
index 614c1ef5..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
diff --git a/plugins/upload.py b/plugins/upload.py
index c702dc49..6926c075 100644
--- a/plugins/upload.py
+++ b/plugins/upload.py
@@ -40,6 +40,12 @@ class Plugin(BasePlugin):
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,
@@ -50,9 +56,12 @@ class Plugin(BasePlugin):
short='Upload a file',
completion=self.completion_filename)
- async def upload(self, filename) -> Optional[str]:
+ 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 None
@@ -66,10 +75,10 @@ class Plugin(BasePlugin):
return None
return url
- async def send_upload(self, filename):
- url = await self.upload(filename)
+ 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)
+ self.embed.embed_image_url(url, tab)
@command_args_parser.quoted(1)
def command_upload(self, args):
@@ -78,7 +87,9 @@ class Plugin(BasePlugin):
return
filename, = args
filename = expanduser(filename)
- asyncio.ensure_future(self.send_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/user_extras.py b/plugins/user_extras.py
index ad49a142..96559111 100644
--- a/plugins/user_extras.py
+++ b/plugins/user_extras.py
@@ -91,10 +91,7 @@ Configuration
"""
-from asyncio import (
- ensure_future,
- gather,
-)
+import asyncio
from functools import reduce
from typing import Dict
@@ -174,10 +171,10 @@ class Plugin(BasePlugin):
]
for name, handler in handlers:
self.core.xmpp.del_event_handler(name, handler)
- ensure_future(self._stop())
+ asyncio.create_task(self._stop())
async def _stop(self):
- await gather(
+ await asyncio.gather(
self.core.xmpp.plugin['xep_0108'].stop(),
self.core.xmpp.plugin['xep_0107'].stop(),
self.core.xmpp.plugin['xep_0196'].stop(),
diff --git a/plugins/vcard.py b/plugins/vcard.py
index ef70dc79..b0c8e396 100644
--- a/plugins/vcard.py
+++ b/plugins/vcard.py
@@ -266,7 +266,7 @@ class Plugin(BasePlugin):
self.api.information('Invalid JID: %s' % arg, 'Error')
return
- asyncio.ensure_future(
+ asyncio.create_task(
self._get_vcard(jid)
)
@@ -290,7 +290,7 @@ class Plugin(BasePlugin):
jid = JID(arg)
except InvalidJID:
return self.api.information('Invalid JID: %s' % arg, 'Error')
- asyncio.ensure_future(
+ asyncio.create_task(
self._get_vcard(jid)
)
@@ -301,11 +301,11 @@ class Plugin(BasePlugin):
return
current = self.api.current_tab().selected_row
if isinstance(current, Resource):
- asyncio.ensure_future(
+ asyncio.create_task(
self._get_vcard(JID(current.jid).bare)
)
elif isinstance(current, Contact):
- asyncio.ensure_future(
+ asyncio.create_task(
self._get_vcard(current.bare_jid)
)
diff --git a/poezio/colors.py b/poezio/colors.py
index 346e1fd0..62566c77 100644
--- a/poezio/colors.py
+++ b/poezio/colors.py
@@ -1,7 +1,6 @@
from typing import Tuple, Dict, List, Union
import curses
import hashlib
-import math
from . import hsluv
diff --git a/poezio/common.py b/poezio/common.py
index d4d09f9f..6b7d2bfe 100644
--- a/poezio/common.py
+++ b/poezio/common.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 useful functions.
"""
@@ -14,7 +14,7 @@ from datetime import (
timezone,
)
from pathlib import Path
-from typing import Dict, List, Optional, Tuple, Union, Any
+from typing import Dict, List, Optional, Tuple, Union
import os
import subprocess
@@ -23,7 +23,7 @@ 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__)
diff --git a/poezio/config.py b/poezio/config.py
index 9c2201e7..4eb43cad 100644
--- a/poezio/config.py
+++ b/poezio/config.py
@@ -28,7 +28,7 @@ ConfigValue = Union[str, int, float, bool]
ConfigDict = Dict[str, Dict[str, ConfigValue]]
-DEFSECTION = "Poezio"
+USE_DEFAULT_SECTION = '__DEFAULT SECTION PLACEHOLDER__'
DEFAULT_CONFIG: ConfigDict = {
'Poezio': {
@@ -37,6 +37,7 @@ DEFAULT_CONFIG: ConfigDict = {
'after_completion': ',',
'alternative_nickname': '',
'auto_reconnect': True,
+ 'autocolor_tab_names': False,
'autorejoin_delay': '5',
'autorejoin': False,
'beep_on': 'highlight private invite disconnect',
@@ -173,6 +174,7 @@ class Config:
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()
@@ -196,13 +198,15 @@ class Config:
def get(self,
option: str,
default: Optional[ConfigValue] = None,
- section: str = DEFSECTION) -> Any:
+ 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:
default = self.default.get(section, {}).get(option, '')
@@ -229,16 +233,16 @@ class Config:
else:
return ''
- def sections(self, *args, **kwargs):
+ 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):
+ def has_option(self, *args, **kwargs) -> bool:
return self.configparser.has_option(*args, **kwargs)
- def has_section(self, *args, **kwargs):
+ def has_section(self, *args, **kwargs) -> bool:
return self.configparser.has_section(*args, **kwargs)
def add_section(self, *args, **kwargs):
@@ -260,7 +264,7 @@ class Config:
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
@@ -288,10 +292,12 @@ class Config:
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
"""
+ if section == USE_DEFAULT_SECTION:
+ section = self.default_section
return self.configparser.get(section, option, **kwargs)
def _get(self, section, conv, option, **kwargs):
@@ -300,44 +306,53 @@ class Config:
"""
return conv(self.__get(option, section, **kwargs))
- def getstr(self, option, section=DEFSECTION) -> str:
+ def getstr(self, option, section=USE_DEFAULT_SECTION) -> str:
"""
get a value and returns it as a string
"""
+ 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) -> int:
+ def getint(self, option, section=USE_DEFAULT_SECTION) -> int:
"""
get a value and returns it as an int
"""
+ 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) -> float:
+ def getfloat(self, option, section=USE_DEFAULT_SECTION) -> float:
"""
get a value and returns it as a float
"""
+ 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 getbool(self, option, section=DEFSECTION) -> bool:
+ def getbool(self, option, section=USE_DEFAULT_SECTION) -> bool:
"""
get a value and returns it as a boolean
"""
+ 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=DEFSECTION) -> List[str]:
+ 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,
@@ -471,7 +486,7 @@ class Config:
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
@@ -479,6 +494,8 @@ class Config:
# 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 section == USE_DEFAULT_SECTION:
+ section = self.default_section
if isinstance(value, str) and value == "toggle":
current = self.getbool(option, section)
if isinstance(current, bool):
@@ -506,20 +523,24 @@ class Config:
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):
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):
self.configparser.set(section, option, str(value))
else:
@@ -527,10 +548,12 @@ class Config:
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:
self.configparser.set(section, option, str(value))
except NoSectionError:
diff --git a/poezio/connection.py b/poezio/connection.py
index c24dd913..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,6 +16,7 @@ import subprocess
import sys
import base64
import random
+from pathlib import Path
import slixmpp
from slixmpp import JID, InvalidJID
@@ -117,7 +118,10 @@ class Connection(slixmpp.ClientXMPP):
self.ciphers = config.getstr(
'ciphers', 'HIGH+kEDH:HIGH+kEECDH:HIGH:!PSK'
':!SRP:!3DES:!aNULL')
- self.ca_certs = config.getstr('ca_cert_path') or None
+ 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)
@@ -200,6 +204,11 @@ class Connection(slixmpp.ClientXMPP):
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):
diff --git a/poezio/contact.py b/poezio/contact.py
index 8359e031..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.
@@ -21,6 +21,7 @@ from typing import (
)
from slixmpp import InvalidJID, JID
+from slixmpp.roster import RosterItem
log = logging.getLogger(__name__)
@@ -74,7 +75,7 @@ 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
"""
@@ -96,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 ''
@@ -106,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
diff --git a/poezio/core/commands.py b/poezio/core/commands.py
index 915f9d90..fe91ca67 100644
--- a/poezio/core/commands.py
+++ b/poezio/core/commands.py
@@ -8,7 +8,7 @@ from xml.etree import ElementTree as ET
from typing import List, Optional, Tuple
import logging
-from slixmpp import Iq, JID, InvalidJID
+from slixmpp import JID, InvalidJID
from slixmpp.exceptions import XMPPError, IqError, IqTimeout
from slixmpp.xmlstream.xmlstream import NotConnectedError
from slixmpp.xmlstream.stanzabase import StanzaBase
@@ -298,7 +298,7 @@ class CommandCore:
jid = self.core.tabs.current_tab.jid
if jid is None or not jid.domain:
return None
- asyncio.ensure_future(
+ asyncio.create_task(
self._list_async(jid)
)
@@ -446,14 +446,19 @@ 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
- asyncio.ensure_future(
+ 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,
@@ -468,8 +473,8 @@ class CommandCore:
"""
/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
room, nick = self._parse_join_jid(args[0] if args else '')
password = args[2] if len(args) > 2 else None
@@ -478,13 +483,18 @@ class CommandCore:
autojoin = (method == 'local' or
(len(args) > 1 and args[1].lower() == 'true'))
- asyncio.ensure_future(
+ 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: Optional[str],
+ room: str,
nick: Optional[str],
autojoin: bool,
password: str,
@@ -503,16 +513,8 @@ class CommandCore:
method: 'local' or 'remote'.
'''
- # No room Jid was specified. A nick may have been specified. Set the
- # room Jid to be bookmarked to the current tab bare jid.
- if not room:
- tab = self.core.tabs.current_tab
- if not isinstance(tab, tabs.MucTab):
- return
- room = tab.jid.bare
- if password is None and tab.password is not None:
- password = tab.password
- elif room == '*':
+
+ if room == '*':
return await self._add_wildcard_bookmarks(method)
# Once we found which room to bookmark, find corresponding tab if it
@@ -524,13 +526,14 @@ class CommandCore:
# Validate / Normalize
try:
- if nick is None:
+ 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
bookmark = self.core.bookmarks[room]
@@ -596,7 +599,7 @@ class CommandCore:
else:
jid = args[0]
- asyncio.ensure_future(
+ asyncio.create_task(
self._remove_bookmark_routine(jid)
)
@@ -981,10 +984,9 @@ class CommandCore:
bare = JID(jid).bare
except InvalidJID:
return self.core.information('Invalid JID for /impromptu: %s' % args[0], 'Error')
- jids.add(bare)
+ 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):
@@ -1043,7 +1045,7 @@ class CommandCore:
if jid is None:
self.core.information('No specified JID to block', 'Error')
else:
- asyncio.ensure_future(self._block_async(jid))
+ asyncio.create_task(self._block_async(jid))
async def _block_async(self, jid: JID):
"""Block a JID, asynchronously"""
@@ -1096,7 +1098,7 @@ class CommandCore:
jid = JID(current_tab.jid.bare)
if jid is not None:
- asyncio.ensure_future(
+ asyncio.create_task(
self._unblock_async(jid)
)
else:
@@ -1153,7 +1155,7 @@ class CommandCore:
"""
/destroy_room [JID [reason [alternative room JID]]]
"""
- async def do_destroy(room: JID, reason: str, altroom: 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:
@@ -1178,6 +1180,7 @@ class CommandCore:
return
reason = args[1]
+ altroom = None
if args[2]:
try:
altroom = JID(args[2])
@@ -1185,7 +1188,7 @@ class CommandCore:
self.core.information('Invalid alternative room JID: "%s"' % args[2], 'Error')
return
- asyncio.ensure_future(do_destroy(room, reason, altroom))
+ asyncio.create_task(do_destroy(room, reason, altroom))
@command_args_parser.quoted(1, 1, [''])
def bind(self, args):
@@ -1280,7 +1283,7 @@ 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]
"""
@@ -1310,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):
diff --git a/poezio/core/core.py b/poezio/core/core.py
index 81ac6e8a..6582402d 100644
--- a/poezio/core/core.py
+++ b/poezio/core/core.py
@@ -15,7 +15,6 @@ import pipes
import sys
import shutil
import time
-import uuid
from collections import defaultdict
from typing import (
Any,
@@ -30,9 +29,11 @@ from typing import (
TYPE_CHECKING,
)
from xml.etree import ElementTree as ET
+from pathlib import Path
-from slixmpp import JID, InvalidJID
+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, XMPPError
@@ -42,6 +43,7 @@ from poezio import events
from poezio import theming
from poezio import timed_events
from poezio import windows
+from poezio import utils
from poezio.bookmarks import (
BookmarkList,
@@ -274,7 +276,7 @@ class Core:
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),
@@ -444,7 +446,7 @@ class Core:
if value not in ('pep', 'privatexml'):
return
self.bookmarks.preferred = value
- asyncio.ensure_future(
+ asyncio.create_task(
self.bookmarks.save(self.xmpp, core=self)
)
@@ -674,6 +676,26 @@ class Core:
self.do_command(''.join(char_list), True)
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
@@ -906,7 +928,9 @@ class Core:
"""
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
async def invite(self, jid: JID, room: JID, reason: Optional[str] = None, force_mediated: bool = False) -> bool:
@@ -943,7 +967,7 @@ class Core:
)
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),
@@ -1004,31 +1028,65 @@ class Core:
)
return
- nick = self.own_nick
- localpart = uuid.uuid4().hex
- room_str = '{!s}@{!s}'.format(localpart, default_muc)
- try:
- room = JID(room_str)
- except InvalidJID:
+ # 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
+
+ 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(
- 'The generated XMPP address is invalid: {!s}'.format(room_str),
- 'Error'
+ 'Couldn\'t generate a room name that isn\'t already used.',
+ '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.
- return None
+ self.open_new_room(room, self.own_nick).join()
+
+ 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
+
+ self.information(f'Room {room} created', 'Info')
- self.information('Room %s created' % room, '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')
- for jid in jids:
- await self.invite(jid, room, force_mediated=True)
+ self.xmpp.add_event_handler(
+ f'muc::{room.bare}::groupchat_subject',
+ configure_and_invite,
+ disposable=True,
+ )
####################### Tab logic-related things ##############################
diff --git a/poezio/core/handlers.py b/poezio/core/handlers.py
index d4625b4b..e92e4aac 100644
--- a/poezio/core/handlers.py
+++ b/poezio/core/handlers.py
@@ -8,25 +8,20 @@ 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, JID, Message
+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 tabs
from poezio import xhtml
from poezio import multiuserchat as muc
@@ -36,12 +31,10 @@ 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,
- Message as PMessage,
- BaseMessage,
InfoMessage,
PersistentInfoMessage,
)
@@ -90,8 +83,6 @@ class HandlerCore:
"""
Enable carbons & blocking on session start if wanted and possible
"""
-
-
iq = await self.core.xmpp.plugin['xep_0030'].get_info(
jid=self.core.xmpp.boundjid.domain
)
@@ -108,7 +99,7 @@ class HandlerCore:
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(),
)
@@ -159,67 +150,57 @@ class HandlerCore:
return None
- def on_carbon_received(self, message):
+ async def on_carbon_received(self, message: Message):
"""
Carbon <received/> received
"""
-
- def ignore_message(recv):
- log.debug('%s has category conference, ignoring carbon',
- recv['from'].server)
-
- def receive_message(recv):
- recv['to'] = self.core.xmpp.boundjid.full
- if recv['receipt']:
- return self.on_receipt(recv)
- self.on_normal_message(recv)
-
recv = message['carbon_received']
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'])
- return
- if is_muc_pm is None:
- fixes.has_identity(
- self.core.xmpp,
+ elif is_muc_pm is None:
+ is_muc = await self.core.xmpp.plugin['xep_0030'].has_identity(
recv['from'].bare,
- identity='conference',
- on_true=functools.partial(ignore_message, recv),
- on_false=functools.partial(receive_message, recv))
- return
+ 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']
is_muc_pm = self.is_known_muc_pm(sent, sent['to'])
if is_muc_pm:
- groupchat_private_message(sent)
- return
- if is_muc_pm is None:
- fixes.has_identity(
- self.core.xmpp,
+ 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,
- identity='conference',
- on_true=functools.partial(groupchat_private_message, sent),
- on_false=functools.partial(send_message, sent))
+ 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
"""
@@ -243,11 +224,11 @@ class HandlerCore:
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
"""
@@ -280,7 +261,7 @@ 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)
@@ -291,19 +272,19 @@ class HandlerCore:
return
# Differentiate both type of messages, and call the appropriate handler.
if self.is_known_muc_pm(message, message['from']):
- self.on_groupchat_private_message(message, sent=False)
- return
- self.on_normal_message(message)
+ await self.on_groupchat_private_message(message, sent=False)
+ else:
+ await self.on_normal_message(message)
- def on_encrypted_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.
- self.on_message(message)
+ await self.on_message(message)
- def on_error_message(self, message):
+ async def on_error_message(self, message: Message):
"""
When receiving any message with type="error"
"""
@@ -332,7 +313,7 @@ class HandlerCore:
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)
@@ -373,7 +354,7 @@ class HandlerCore:
)
self.core.tabs.append(conversation)
else:
- conversation.handle_message(message)
+ await conversation.handle_message(message)
if not own and 'private' in config.getstr('beep_on').split():
if not config.get_by_tabname('disable_beep', conv_jid.bare):
@@ -388,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:
@@ -438,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:
@@ -474,7 +455,7 @@ 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 a user nickname
is received
@@ -488,7 +469,7 @@ class HandlerCore:
else:
contact.name = ''
- 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.
"""
@@ -505,116 +486,26 @@ 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)
-
- # 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
-
- old_state = tab.state
- delayed, date = common.find_delayed_tag(message)
- is_history = not tab.joined and delayed
-
- replaced = False
- if message.xml.find('{urn:xmpp:message-correct:0}replace') is not None:
- replaced_id = message['replace']['id']
- if replaced_id != '' 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,
- delayed=delayed,
- 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:
- # Messages coming from MUC barejid (Server maintenance, IRC mode
- # changes from biboumi, etc.) are displayed as info messages.
- highlight = False
- if message['from'].resource:
- highlight = tab.message_is_highlight(body, nick_from, is_history)
- ui_msg = PMessage(
- txt=body,
- time=date,
- nickname=nick_from,
- history=is_history,
- delayed=delayed,
- identifier=message['id'],
- jid=message['from'],
- user=user,
- highlight=highlight,
- )
- typ = 1
- else:
- ui_msg = InfoMessage(
- txt=body,
- time=date,
- identifier=message['id'],
- )
- typ = 2
- tab.add_message(ui_msg)
- if highlight:
- self.core.events.trigger('highlight', message, tab)
-
- if message['from'].resource == tab.own_nick:
- tab.set_last_sent_message(message, correct=replaced)
-
- 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.getstr('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
@@ -630,7 +521,7 @@ class HandlerCore:
tabs.PrivateTab) # get the tab with the private conversation
ignore = config.get_by_tabname('ignore_private', room_from)
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(
@@ -647,7 +538,7 @@ class HandlerCore:
self.core.tabs.append(tab)
tab.parent_muc.privates.append(tab)
else:
- tab.handle_message(message)
+ await tab.handle_message(message)
if not sent and 'private' in config.getstr('beep_on').split():
if not config.get_by_tabname('disable_beep', jid.full):
@@ -660,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()
@@ -702,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
"""
@@ -710,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()
@@ -719,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
"""
@@ -727,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:
@@ -745,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.
"""
@@ -762,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.
"""
@@ -774,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.
"""
@@ -793,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]
@@ -816,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]
@@ -831,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]
@@ -844,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]
@@ -865,7 +756,7 @@ class HandlerCore:
### Presence-related handlers ###
- def on_presence(self, presence):
+ async def on_presence(self, presence: Presence):
if presence.match('presence/muc'):
return
jid = presence['from']
@@ -880,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(
@@ -892,7 +783,7 @@ 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:
@@ -901,7 +792,7 @@ class HandlerCore:
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
"""
@@ -933,7 +824,7 @@ 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
"""
@@ -954,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)
@@ -972,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
@@ -981,19 +872,19 @@ 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')
- def on_session_end(self, event):
+ 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)
"""
@@ -1002,7 +893,7 @@ class HandlerCore:
for tab in self.core.get_tabs(tabs.MucTab):
tab.disconnect()
- def on_session_resumed(self, event):
+ async def on_session_resumed(self, event):
"""
Called when a session is successfully resumed by 0198
"""
@@ -1037,7 +928,7 @@ class HandlerCore:
"""
self.core.information("Reconnecting in %d seconds..." % (event), 'Info')
- def on_stream_error(self, event):
+ async def on_stream_error(self, event):
"""
When we receive a stream error
"""
@@ -1046,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
"""
@@ -1054,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)
"""
@@ -1062,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
"""
@@ -1088,7 +979,7 @@ class HandlerCore:
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
@@ -1097,13 +988,13 @@ class HandlerCore:
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.
@@ -1182,7 +1073,7 @@ class HandlerCore:
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.
"""
@@ -1239,7 +1130,7 @@ class HandlerCore:
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)
"""
@@ -1261,13 +1152,13 @@ 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.
"""
@@ -1284,30 +1175,31 @@ class HandlerCore:
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)
+ 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))):
+ 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.strip() == '':
+ if stanza_str.strip() == '':
return None
log.debug('', exc_info=True)
@@ -1315,7 +1207,7 @@ class HandlerCore:
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.
"""
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/events.py b/poezio/events.py
index 63782836..0ba97d56 100644
--- a/poezio/events.py
+++ b/poezio/events.py
@@ -2,16 +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:
"""
@@ -75,6 +79,20 @@ class EventHandler:
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.
@@ -84,7 +102,11 @@ class EventHandler:
return
for priority in callbacks.values():
for callback in priority:
- callback(*args, **kwargs)
+ 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):
"""
diff --git a/poezio/fixes.py b/poezio/fixes.py
index 62e67f43..c2db4332 100644
--- a/poezio/fixes.py
+++ b/poezio/fixes.py
@@ -5,31 +5,15 @@ upstream.
TODO: Check that they are fixed and remove those hacks
"""
-import asyncio
-from typing import Callable, Any
-from slixmpp import Message, Iq, ClientXMPP
-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()
-
- asyncio.ensure_future(
- xmpp.plugin['xep_0030'].get_info(jid=jid, 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/keyboard.py b/poezio/keyboard.py
index 23da2e37..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
diff --git a/poezio/log_loader.py b/poezio/log_loader.py
index 146bc9b4..2e3b27c2 100644
--- a/poezio/log_loader.py
+++ b/poezio/log_loader.py
@@ -76,10 +76,17 @@ class LogLoader:
mam_only: bool
def __init__(self, logger: Logger, tab: tabs.ChatTab,
- local_logs: bool = True):
+ 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"""
@@ -104,6 +111,7 @@ class LogLoader:
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.
@@ -238,6 +246,7 @@ class LogLoader:
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.
@@ -321,7 +330,7 @@ class MAMFiller:
self.tab = tab
self.logger = logger
logger.fd_busy(tab.jid)
- self.future = asyncio.ensure_future(self.fetch_routine())
+ self.future = asyncio.create_task(self.fetch_routine())
self.done = asyncio.Event()
self.limit = limit
self.result = 0
diff --git a/poezio/logger.py b/poezio/logger.py
index 6e4a6ff0..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
@@ -157,11 +157,11 @@ class Logger:
def close(self, jid: str) -> None:
"""Close the log file for a JID."""
- jid = str(jid).replace('/', '\\')
- if jid in self._fds:
- self._fds[jid].close()
+ 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]
+ del self._fds[jidstr]
def reload_all(self) -> None:
"""Close and reload all the file handles (on SIGHUP)"""
@@ -184,7 +184,7 @@ class Logger:
self._check_and_create_log_dir(room)
log.debug('Log handle for %s re-created', room)
- def _check_and_create_log_dir(self, jid: str,
+ 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
@@ -196,6 +196,8 @@ class Logger:
"""
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:
self.log_dir.mkdir(parents=True, exist_ok=True)
except OSError:
@@ -205,7 +207,7 @@ class Logger:
return None
if not open_fd:
return None
- filename = self.log_dir / jid
+ filename = self.get_file_path(jid)
try:
fd = filename.open('a', encoding='utf-8')
self._fds[jid] = fd
@@ -251,18 +253,18 @@ class Logger:
:param force: Bypass the buffered fd check
:returns: True if no error was encountered
"""
- jid = str(jid).replace('/', '\\')
- if jid in self._fds.keys():
- fd = self._fds[jid]
+ 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 = self.log_dir / jid
+ filename = self.get_file_path(jid)
try:
- if not force and self._busy_fds.get(jid):
- self._buffered_fds[jid].append(logged_msg)
+ if not force and self._busy_fds.get(jidstr):
+ self._buffered_fds[jidstr].append(logged_msg)
return True
fd.write(logged_msg)
except OSError:
@@ -398,26 +400,6 @@ def iterate_messages_reverse(filepath: Path) -> Generator[LogDict, None, None]:
pass
-def _get_lines_from_fd(fd: IO[Any], nb: int = 10) -> List[str]:
- """
- Get the last log lines from a fileno with mmap
-
- :param fd: File descriptor on the log file
- :param nb: number of messages to fetch
- :returns: A list of message lines
- """
- 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], jid: str = '') -> List[LogDict]:
"""
Parse raw log lines into poezio log objects
diff --git a/poezio/mam.py b/poezio/mam.py
index 180f1b2e..7cb1d369 100644
--- a/poezio/mam.py
+++ b/poezio/mam.py
@@ -5,7 +5,6 @@
from __future__ import annotations
-import asyncio
import logging
from datetime import datetime, timedelta, timezone
from hashlib import md5
@@ -131,6 +130,20 @@ def _parse_message(msg: SMessage) -> Dict:
}
+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]:
@@ -138,11 +151,17 @@ async def retrieve_messages(tab: tabs.ChatTab,
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']:
- if msg['mam_result']['forwarded']['stanza'] \
- .xml.find('{%s}%s' % ('jabber:client', 'body')) is not None:
+ 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):
diff --git a/poezio/multiuserchat.py b/poezio/multiuserchat.py
index 366f6d78..3278e1bd 100644
--- a/poezio/multiuserchat.py
+++ b/poezio/multiuserchat.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.
"""
Implementation of the XEP-0045: Multi-User Chat.
Add some facilities that are not available on the XEP_0045
@@ -109,7 +109,7 @@ def join_groupchat(
xmpp.plugin['xep_0045'].rooms[jid] = {}
xmpp.plugin['xep_0045'].our_nicks[jid] = to.resource
- asyncio.ensure_future(
+ asyncio.create_task(
xmpp.plugin['xep_0030'].get_info(jid=jid, callback=on_disco)
)
diff --git a/poezio/plugin.py b/poezio/plugin.py
index 4af27cbd..f38e47e2 100644
--- a/poezio/plugin.py
+++ b/poezio/plugin.py
@@ -26,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):
@@ -425,7 +426,7 @@ class BasePlugin(object, metaclass=SafetyMetaclass):
self.init()
@property
- def name(self):
+ def name(self) -> str:
"""
Get the name (module name) of the plugin.
"""
diff --git a/poezio/plugin_e2ee.py b/poezio/plugin_e2ee.py
index 3ac88eb9..49f7b067 100644
--- a/poezio/plugin_e2ee.py
+++ b/poezio/plugin_e2ee.py
@@ -4,7 +4,7 @@
#
# Copyright © 2019 Maxime “pep” Buquet <pep@bouah.net>
#
-# Distributed under terms of the zlib license. See COPYING file.
+# Distributed under terms of the GPL-3.0+ license. See COPYING file.
"""
Interface for E2EE (End-to-end Encryption) plugins.
@@ -23,19 +23,23 @@ from typing import (
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 get_theme, dump_tuple
+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
@@ -118,7 +122,9 @@ class E2EEPlugin(BasePlugin):
#: Used to figure out what messages to attempt decryption for. Also used
#: in combination with `tag_whitelist` to avoid removing encrypted tags
- #: before sending.
+ #: 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
@@ -126,7 +132,7 @@ class E2EEPlugin(BasePlugin):
_enabled_tabs: Dict[JID, Callable] = {}
# Tabs that support this encryption mechanism
- supported_tab_types: Tuple[Type[ChatTabs], ...] = tuple()
+ supported_tab_types: Tuple[Type[ChatTab], ...] = tuple()
# States for each remote entity
trust_states: Dict[str, Set[str]] = {'accepted': set(), 'rejected': set()}
@@ -146,11 +152,24 @@ class E2EEPlugin(BasePlugin):
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, priority=0)
- self.api.add_event_handler('conversation_msg', self._decrypt, priority=0)
- self.api.add_event_handler('private_msg', self._decrypt, priority=0)
+ 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.
@@ -191,8 +210,8 @@ class E2EEPlugin(BasePlugin):
self.encryption_short_name + '_fingerprint',
self._command_show_fingerprints,
usage='[jid]',
- short='Show %s fingerprint(s) for a JID.' % self.encryption_short_name,
- help='Show %s fingerprint(s) for a JID.' % self.encryption_short_name,
+ 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(
@@ -215,7 +234,8 @@ class E2EEPlugin(BasePlugin):
for section in config.sections():
value = config.getstr('encryption', section=section)
if value and value == self.encryption_short_name:
- self._enabled_tabs[section] = self.encrypt
+ section_jid = JID(section)
+ self._enabled_tabs[section_jid] = self.encrypt
def cleanup(self):
ConversationTab.remove_information_element(self.encryption_short_name)
@@ -238,56 +258,73 @@ class E2EEPlugin(BasePlugin):
return ""
def _toggle_tab(self, _input: str) -> None:
- jid: JID = self.api.current_tab().jid
+ 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(
- '{} encryption disabled for {}'.format(self.encryption_name, jid),
+ 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(
- '{} encryption enabled for {}'.format(self.encryption_name, jid),
+ f'{self.encryption_name} encryption enabled for {jid}',
'Info',
)
- def _show_fingerprints(self, jid: JID) -> None:
+ @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."""
- fprs = self.get_fingerprints(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(
- 'Fingerprint for %s: %s' % (jid, fprs[0]),
+ 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\t%s' % (jid, '\n\t'.join(fprs)),
+ 'Fingerprints for %s:\n%s' % (jid, '\n\n'.join(fmt_fprs)),
'Info',
)
else:
self.api.information(
- 'No fingerprints to display',
+ f'{jid}: No fingerprints to display',
'Info',
)
@command_args_parser.quoted(0, 1)
def _command_show_fingerprints(self, args: List[str]) -> None:
- if not args and isinstance(self.api.current_tab(), self.supported_tab_types):
- jid = self.api.current_tab().jid
+ 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(
- '%s_fingerprint: Couldn\'t deduce JID from context' % (
- self.encryption_short_name),
+ f'{shortname}_fingerprint: Couldn\'t deduce JID from context',
'Error',
)
return None
- self._show_fingerprints(JID(jid))
+ asyncio.create_task(self._show_fingerprints(JID(jid)))
@command_args_parser.quoted(2)
def __command_set_state_global(self, args, state='') -> None:
@@ -299,9 +336,9 @@ class E2EEPlugin(BasePlugin):
return
jid, fpr = args
if state not in self._all_trust_states:
+ shortname = self.encryption_short_name
self.api.information(
- 'Unknown state for plugin %s: %s' % (
- self.encryption_short_name, state),
+ f'Unknown state for plugin {shortname}: {state}',
'Error'
)
return
@@ -324,9 +361,9 @@ class E2EEPlugin(BasePlugin):
return
fpr = args[0]
if state not in self._all_trust_states:
+ shortname = self.encryption_short_name
self.api.information(
- 'Unknown state for plugin %s: %s' % (
- self.encryption_short_name, state),
+ f'Unknown state for plugin {shortname}: {state}',
'Error',
)
return
@@ -345,6 +382,28 @@ class E2EEPlugin(BasePlugin):
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' % (
@@ -352,27 +411,57 @@ class E2EEPlugin(BasePlugin):
exc,
)
# XXX: check before commit. Do we not nack in MUCs?
- if not isinstance(tab, MucTab):
+ 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 encrypt:', exc_info=True)
+ 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
- return result
- def _decrypt(self, message: Message, tab: ChatTabs) -> None:
+ mfrom = stanza['from']
- has_eme = False
- if message.xml.find('{%s}%s' % (EME_NS, EME_TAG)) is not None and \
+ # 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 = False
+ 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:
- if message.xml.find('{%s}%s' % (namespace, tag)) is not None:
- # TODO: count all encrypted tags.
- has_encrypted_tag = True
- break
+ 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
@@ -397,26 +486,63 @@ class E2EEPlugin(BasePlugin):
if user is not None:
jid = user.jid or None
- self.decrypt(message, jid, tab)
+ # 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: # When does that ever happen?
- log.debug('Attempting to encrypt a message to \'%s\' '
- 'that is not attached to a Tab. ?! Aborting '
- 'encryption.', message['to'])
- return None
+ 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):
@@ -449,19 +575,22 @@ class E2EEPlugin(BasePlugin):
if user.jid.bare:
jids.append(user.jid)
- if not self._encryption_enabled(tab.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 '
- 'not doesn\'t do stanza encryption',
+ 'doesn\'t do stanza encryption',
self.encryption_name,
)
return None
@@ -495,7 +624,7 @@ class E2EEPlugin(BasePlugin):
if self.encrypted_tags is not None:
whitelist += self.encrypted_tags
- tag_whitelist = {'{%s}%s' % tag for tag in whitelist}
+ tag_whitelist = {f'{{{ns}}}{tag}' for (ns, tag) in whitelist}
for elem in message.xml[:]:
if elem.tag not in tag_whitelist:
@@ -506,15 +635,15 @@ class E2EEPlugin(BasePlugin):
def store_trust(self, jid: JID, state: str, fingerprint: str) -> None:
"""Store trust for a fingerprint and a jid."""
- option_name = '%s:%s' % (self.encryption_short_name, fingerprint)
+ 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 = '%s:%s' % (self.encryption_short_name, fingerprint)
+ 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: ChatTab):
+ 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
@@ -546,7 +675,7 @@ class E2EEPlugin(BasePlugin):
raise NotImplementedError
- def get_fingerprints(self, jid: JID) -> List[str]:
+ async def get_fingerprints(self, jid: JID) -> List[Tuple[str, bool]]:
"""Show fingerprint(s) for this encryption method and JID.
To overload in plugins.
diff --git a/poezio/poezio.py b/poezio/poezio.py
index 694130f0..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
"""
@@ -115,17 +115,12 @@ def main():
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/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 4534dd30..a52ea23e 100644
--- a/poezio/roster.py
+++ b/poezio/roster.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 Roster and RosterGroup classes
"""
@@ -71,7 +71,7 @@ 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):
@@ -133,7 +133,7 @@ class Roster:
return False
@property
- def jid(self):
+ def jid(self) -> JID:
"""Our JID"""
return self.__node.jid
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/basetabs.py b/poezio/tabs/basetabs.py
index 508465e3..793eae62 100644
--- a/poezio/tabs/basetabs.py
+++ b/poezio/tabs/basetabs.py
@@ -170,15 +170,15 @@ class Tab:
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
@@ -351,7 +351,7 @@ class Tab:
if hasattr(self.input, "reset_completion"):
self.input.reset_completion()
if asyncio.iscoroutinefunction(func):
- asyncio.ensure_future(func(arg))
+ asyncio.create_task(func(arg))
else:
func(arg)
return True
@@ -390,12 +390,6 @@ 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.
@@ -498,7 +492,7 @@ class GapTab(Tab):
return 0
@property
- def name(self):
+ def name(self) -> str:
return ''
def refresh(self):
@@ -520,6 +514,7 @@ class ChatTab(Tab):
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: Union[JID, str]):
Tab.__init__(self, core)
@@ -675,7 +670,9 @@ class ChatTab(Tab):
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
@@ -800,7 +797,7 @@ class ChatTab(Tab):
self.last_sent_message = msg
@command_args_parser.raw
- def command_correct(self, line: str) -> None:
+ async def command_correct(self, line: str) -> None:
"""
/correct <fixed message>
"""
@@ -810,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:
@@ -844,7 +841,7 @@ class ChatTab(Tab):
self.state = 'scrolled'
@command_args_parser.raw
- def command_say(self, line: str, attention: bool = False, correct: bool = False):
+ async def command_say(self, line: str, attention: bool = False, correct: bool = False):
pass
def goto_build_lines(self, new_date):
@@ -979,7 +976,7 @@ class ChatTab(Tab):
def on_scroll_up(self):
if not self.query_status:
from poezio.log_loader import LogLoader
- asyncio.ensure_future(
+ asyncio.create_task(
LogLoader(logger, self, config.getbool('use_log')).scroll_requested()
)
return self.text_win.scroll_up(self.text_win.height - 1)
@@ -1004,6 +1001,7 @@ class OneToOneTab(ChatTab):
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
@@ -1018,9 +1016,9 @@ class OneToOneTab(ChatTab):
shortdesc='Request the attention.',
desc='Attention: Request the attention of the contact. Can also '
'send a message along with the attention.')
- self.init_logs(initial=initial)
+ asyncio.create_task(self.init_logs(initial=initial))
- def init_logs(self, initial=None) -> None:
+ 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:
@@ -1031,19 +1029,16 @@ class OneToOneTab(ChatTab):
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
- async def fallback_no_mam():
- await mam_filler.done.wait()
- if mam_filler.result == 0:
- self.handle_message(initial)
-
- asyncio.ensure_future(fallback_no_mam())
+ await mam_filler.done.wait()
+ if mam_filler.result == 0:
+ await self.handle_message(initial)
elif use_log and initial:
- self.handle_message(initial, display=False)
- asyncio.ensure_future(
- LogLoader(logger, self, use_log).tab_open()
- )
+ 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()
- def handle_message(self, msg: SMessage, display: bool = True):
+ async def handle_message(self, msg: SMessage, display: bool = True):
pass
def remote_user_color(self):
@@ -1116,10 +1111,10 @@ class OneToOneTab(ChatTab):
self.refresh()
@command_args_parser.raw
- def command_attention(self, message):
+ async def command_attention(self, message):
"""/attention [message]"""
if message != '':
- self.command_say(message, attention=True)
+ await self.command_say(message, attention=True)
else:
msg = self.core.xmpp.make_message(self.get_dest_jid())
msg['type'] = 'chat'
@@ -1127,7 +1122,7 @@ class OneToOneTab(ChatTab):
msg.send()
@command_args_parser.raw
- def command_say(self, line: str, attention: bool = False, correct: bool = False):
+ async def command_say(self, line: str, attention: bool = False, correct: bool = False):
pass
@command_args_parser.ignored
diff --git a/poezio/tabs/bookmarkstab.py b/poezio/tabs/bookmarkstab.py
index 10c7c0ce..d21b5630 100644
--- a/poezio/tabs/bookmarkstab.py
+++ b/poezio/tabs/bookmarkstab.py
@@ -96,7 +96,7 @@ class BookmarksTab(Tab):
if bm in self.bookmarks:
self.bookmarks.remove(bm)
- asyncio.ensure_future(
+ asyncio.create_task(
self.save_routine()
)
diff --git a/poezio/tabs/conversationtab.py b/poezio/tabs/conversationtab.py
index 9ddb6fc1..de1f988a 100644
--- a/poezio/tabs/conversationtab.py
+++ b/poezio/tabs/conversationtab.py
@@ -11,6 +11,7 @@ There are two different instances of a ConversationTab:
the time.
"""
+import asyncio
import curses
import logging
from datetime import datetime
@@ -21,7 +22,6 @@ from slixmpp import JID, InvalidJID, Message as SMessage
from poezio.tabs.basetabs import OneToOneTab, Tab
from poezio import common
-from poezio import tabs
from poezio import windows
from poezio import xhtml
from poezio.config import config, get_image_cache
@@ -83,8 +83,8 @@ class ConversationTab(OneToOneTab):
self.update_keys()
@property
- def general_jid(self):
- return self.jid.bare
+ def general_jid(self) -> JID:
+ return JID(self.jid.bare)
def get_info_header(self):
raise NotImplementedError
@@ -105,16 +105,25 @@ class ConversationTab(OneToOneTab):
def completion(self):
self.complete_commands(self.input)
- def handle_message(self, message: SMessage, display: bool = True):
+ 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']
@@ -132,7 +141,7 @@ class ConversationTab(OneToOneTab):
else:
return
- self.core.events.trigger('conversation_msg', message, self)
+ await self.core.events.trigger_async('conversation_msg', message, self)
if not message['body']:
return
@@ -172,7 +181,8 @@ class ConversationTab(OneToOneTab):
@refresh_wrapper.always
@command_args_parser.raw
- def command_say(self, line: str, attention: bool = False, correct: bool = False):
+ 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
@@ -189,7 +199,6 @@ class ConversationTab(OneToOneTab):
self.core.events.trigger('conversation_say', msg, self)
if not msg['body']:
return
- replaced = False
if correct or msg['replace']['id']:
msg['replace']['id'] = self.last_sent_message['id'] # type: ignore
else:
@@ -209,10 +218,10 @@ class ConversationTab(OneToOneTab):
if not msg['body']:
return
self.set_last_sent_message(msg, correct=correct)
- self.core.handler.on_normal_message(msg)
- # Our receipts slixmpp hack
msg._add_receipt = True # type: ignore
msg.send()
+ await self.core.handler.on_normal_message(msg)
+ # Our receipts slixmpp hack
self.cancel_paused_delay()
@command_args_parser.quoted(0, 1)
@@ -277,16 +286,9 @@ class ConversationTab(OneToOneTab):
else:
resource = None
if resource:
- status = (
- 'Status: %s' % resource.status) if resource.status else ''
- self.add_message(
- InfoMessage(
- "Show: %(show)s, %(status)s" % {
- 'show': resource.presence or 'available',
- 'status': status,
- }
- ),
- )
+ 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"),
@@ -436,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):
@@ -543,7 +542,7 @@ class StaticConversationTab(ConversationTab):
self.update_commands()
self.update_keys()
- def init_logs(self, initial=None) -> None:
+ async def init_logs(self, initial=None) -> None:
# Disable local logs because…
pass
diff --git a/poezio/tabs/muclisttab.py b/poezio/tabs/muclisttab.py
index f6b3fc35..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
@@ -74,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 acc145af..e2d546c9 100644
--- a/poezio/tabs/muctab.py
+++ b/poezio/tabs/muctab.py
@@ -18,6 +18,7 @@ import random
import re
import functools
from copy import copy
+from dataclasses import dataclass
from datetime import datetime
from typing import (
cast,
@@ -44,12 +45,13 @@ from poezio import timed_events
from poezio import windows
from poezio import xhtml
from poezio.common import to_utc
-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, 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
@@ -73,6 +75,18 @@ NS_MUC_USER = 'http://jabber.org/protocol/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.
@@ -154,14 +168,14 @@ class MucTab(ChatTab):
"""
The user do not want to send their config, send an iq cancel
"""
- asyncio.ensure_future(self.core.xmpp['xep_0045'].cancel_config(self.jid))
+ asyncio.create_task(self.core.xmpp['xep_0045'].cancel_config(self.jid))
self.core.close_tab()
def send_config(self, form: Form) -> None:
"""
The user sends their config to the server
"""
- asyncio.ensure_future(self.core.xmpp['xep_0045'].set_room_config(self.jid, form))
+ asyncio.create_task(self.core.xmpp['xep_0045'].set_room_config(self.jid, form))
self.core.close_tab()
def join(self) -> None:
@@ -233,6 +247,8 @@ class MucTab(ChatTab):
message)
self.core.disable_private_tabs(self.jid.bare, reason=msg)
else:
+ self.presence_buffer = []
+ self.users = []
muc.leave_groupchat(self.core.xmpp, self.jid, self.own_nick,
message)
@@ -450,9 +466,6 @@ class MucTab(ChatTab):
# TODO: send the disco#info identity name here, if it exists.
return self.jid.node
- def get_text_window(self) -> windows.TextWin:
- return self.text_win
-
def on_lose_focus(self) -> None:
if self.joined:
if self.input.text:
@@ -480,6 +493,126 @@ class MucTab(ChatTab):
self.general_jid) and not self.input.get_text():
self.send_chat_state('active')
+ async def handle_message(self, message: SMessage) -> bool:
+ """Parse an incoming message
+
+ Returns False if the message was dropped silently.
+ """
+ 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()
@@ -610,7 +743,7 @@ class MucTab(ChatTab):
},
),
)
- asyncio.ensure_future(LogLoader(
+ asyncio.create_task(LogLoader(
logger, self, config.get_by_tabname('use_log', self.general_jid)
).tab_open())
@@ -662,6 +795,17 @@ class MucTab(ChatTab):
elif typ == 'unavailable':
self.on_user_leave_groupchat(user, jid, status, from_nick,
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,
@@ -1513,7 +1657,7 @@ class MucTab(ChatTab):
bookmark = self.core.bookmarks[self.jid]
if bookmark:
bookmark.autojoin = False
- asyncio.ensure_future(
+ asyncio.create_task(
self.core.bookmarks.save(self.core.xmpp)
)
self.core.close_tab(self)
@@ -1538,8 +1682,10 @@ class MucTab(ChatTab):
r = self.core.open_private_window(self.jid.bare, user.nick)
if r and len(args) == 2:
msg = args[1]
- r.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')
@@ -1712,7 +1858,7 @@ class MucTab(ChatTab):
return None
@command_args_parser.raw
- def command_say(self, line: str, attention: bool = False, correct: bool = False):
+ async def command_say(self, line: str, attention: bool = False, correct: bool = False):
"""
/say <message>
Or normal input + enter
@@ -2198,7 +2344,7 @@ class MucTab(ChatTab):
'shortdesc':
'Fix a color for a nick.',
'completion':
- self.completion_recolor
+ self.completion_color
}, {
'name':
'cycle',
diff --git a/poezio/tabs/privatetab.py b/poezio/tabs/privatetab.py
index fb89d8e6..1909e3c1 100644
--- a/poezio/tabs/privatetab.py
+++ b/poezio/tabs/privatetab.py
@@ -10,6 +10,7 @@ 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
@@ -47,7 +48,7 @@ class PrivateTab(OneToOneTab):
additional_information: Dict[str, Callable[[str], str]] = {}
def __init__(self, core, jid, nick, initial=None):
- OneToOneTab.__init__(self, core, jid)
+ OneToOneTab.__init__(self, core, jid, initial)
self.own_nick = nick
self.info_header = windows.PrivateInfoWin()
self.input = windows.MessageInput()
@@ -84,14 +85,14 @@ class PrivateTab(OneToOneTab):
return super().remote_user_color()
@property
- def general_jid(self):
+ def general_jid(self) -> JID:
return self.jid
- def get_dest_jid(self):
+ 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):
@@ -141,7 +142,7 @@ class PrivateTab(OneToOneTab):
and not self.input.get_text().startswith('//'))
self.send_composing_chat_state(empty_after)
- def handle_message(self, message: SMessage, display: bool = True):
+ 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
@@ -155,7 +156,7 @@ class PrivateTab(OneToOneTab):
)
tmp_dir = get_image_cache()
if not sent:
- self.core.events.trigger('private_msg', message, self)
+ 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:
@@ -201,9 +202,10 @@ class PrivateTab(OneToOneTab):
@refresh_wrapper.always
@command_args_parser.raw
- def command_say(self, line: str, attention: bool = False, correct: bool = False) -> None:
+ async def command_say(self, line: str, attention: bool = False, correct: bool = False) -> None:
if not self.on:
return
+ await self._initial_log.wait()
our_jid = JID(self.jid.bare)
our_jid.resource = self.own_nick
msg: SMessage = self.core.xmpp.make_message(
@@ -239,7 +241,7 @@ class PrivateTab(OneToOneTab):
if not msg['body']:
return
self.set_last_sent_message(msg, correct=correct)
- self.core.handler.on_groupchat_private_message(msg, sent=True)
+ await self.core.handler.on_groupchat_private_message(msg, sent=True)
# Our receipts slixmpp hack
msg._add_receipt = True # type: ignore
msg.send()
@@ -358,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):
"""
diff --git a/poezio/tabs/rostertab.py b/poezio/tabs/rostertab.py
index 66aff2b1..18334c20 100644
--- a/poezio/tabs/rostertab.py
+++ b/poezio/tabs/rostertab.py
@@ -14,7 +14,7 @@ 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
@@ -199,7 +199,7 @@ 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
diff --git a/poezio/tabs/xmltab.py b/poezio/tabs/xmltab.py
index 9501c6d3..939af67d 100644
--- a/poezio/tabs/xmltab.py
+++ b/poezio/tabs/xmltab.py
@@ -10,7 +10,6 @@ log = logging.getLogger(__name__)
import curses
import os
-from typing import Union, Optional
from slixmpp import JID, InvalidJID
from slixmpp.xmlstream import matcher, StanzaBase
from slixmpp.xmlstream.tostring import tostring
diff --git a/poezio/theming.py b/poezio/theming.py
index 7a13bb6d..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,7 +74,6 @@ except ImportError:
import curses
import functools
-import os
from typing import Dict, List, Union, Tuple, Optional, cast
from pathlib import Path
from os import path
@@ -234,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)
@@ -383,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
@@ -488,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
diff --git a/poezio/timed_events.py b/poezio/timed_events.py
index 3354443a..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.
diff --git a/poezio/ui/consts.py b/poezio/ui/consts.py
index 51febf22..91f19a82 100644
--- a/poezio/ui/consts.py
+++ b/poezio/ui/consts.py
@@ -1,5 +1,3 @@
-from datetime import datetime
-
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.
diff --git a/poezio/ui/render.py b/poezio/ui/render.py
index 2256dc14..aad482b5 100644
--- a/poezio/ui/render.py
+++ b/poezio/ui/render.py
@@ -265,14 +265,14 @@ class PreMessageHelpers:
"""
Write the date on the yth line of the window
"""
- theme = get_theme()
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 = get_theme().COLOR_TIME_STRING
+ color = theme.COLOR_TIME_STRING
with buffer.colored_text(color=color):
buffer.addstr(time_str)
buffer.addstr(' ')
diff --git a/poezio/user.py b/poezio/user.py
index 858c6d0e..602ee2c8 100644
--- a/poezio/user.py
+++ b/poezio/user.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.
"""
Define the user class.
A user is a MUC participant, not a roster contact (see contact.py)
@@ -12,7 +12,6 @@ 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
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
index 6defd704..2397b102 100644
--- a/poezio/version.py
+++ b/poezio/version.py
@@ -1,2 +1,2 @@
-__version__ = '0.13.1'
-__version_info__ = (0, 13, 1)
+__version__ = '0.14'
+__version_info__ = (0, 14, 0)
diff --git a/poezio/windows/bookmark_forms.py b/poezio/windows/bookmark_forms.py
index 4ec1259e..a0e57cc7 100644
--- a/poezio/windows/bookmark_forms.py
+++ b/poezio/windows/bookmark_forms.py
@@ -38,7 +38,7 @@ class BookmarkJIDInput(FieldInput, Input):
jid = JID(field.jid)
except InvalidJID:
jid = JID('')
- jid.resource = field.nick or ''
+ jid.resource = field.nick or None
self.text = jid.full
self.pos = 0
self.view_pos = 0
diff --git a/poezio/windows/data_forms.py b/poezio/windows/data_forms.py
index 1b22aa2c..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
@@ -397,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':
diff --git a/poezio/windows/image.py b/poezio/windows/image.py
index b721b859..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
@@ -9,9 +11,6 @@ try:
from PIL import Image
HAS_PIL = True
except ImportError:
- class Image: # type: ignore
- class Image:
- pass
HAS_PIL = False
try:
diff --git a/poezio/windows/info_bar.py b/poezio/windows/info_bar.py
index 67b68888..6e6c3bbd 100644
--- a/poezio/windows/info_bar.py
+++ b/poezio/windows/info_bar.py
@@ -15,6 +15,7 @@ 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__)
@@ -38,6 +39,7 @@ class GlobalInfoBar(Win):
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)
@@ -73,6 +75,24 @@ class GlobalInfoBar(Win):
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))
diff --git a/poezio/windows/info_wins.py b/poezio/windows/info_wins.py
index 23d28cc1..227dc115 100644
--- a/poezio/windows/info_wins.py
+++ b/poezio/windows/info_wins.py
@@ -16,6 +16,7 @@ from poezio.config import config
from poezio.windows.base_wins import Win
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
@@ -103,6 +104,9 @@ class PrivateInfoWin(InfoWin):
to_curses_attr(get_theme().COLOR_INFORMATION_BAR))
def write_room_name(self, 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:
@@ -223,13 +227,23 @@ class ConversationInfoWin(InfoWin):
"""
Write the information about the contact
"""
- color = 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') 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)", color)
return
display_name = contact.name
if display_name:
- self.addstr('%s ' % (display_name), color)
+ self.addstr('%s ' % (display_name), name_color)
def write_contact_jid(self, jid):
"""
@@ -237,9 +251,17 @@ class ConversationInfoWin(InfoWin):
"""
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(theme.COLOR_CONVERSATION_NAME))
+ self.addstr(jid.full, to_curses_attr(name_color))
self.addstr('] ', color)
def write_chatstate(self, state):
@@ -313,9 +335,17 @@ class MucInfoWin(InfoWin):
def write_room_name(self, room):
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(theme.COLOR_GROUPCHAT_NAME))
+ to_curses_attr(label_color))
self.addstr(']', color)
def write_participants_number(self, room):
diff --git a/poezio/windows/inputs.py b/poezio/windows/inputs.py
index 7ee8aa45..01b94ac0 100644
--- a/poezio/windows/inputs.py
+++ b/poezio/windows/inputs.py
@@ -5,7 +5,7 @@ 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
@@ -592,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: List[str] = []
+ global_history: ClassVar[List[str]] = []
def __init__(self) -> None:
Input.__init__(self)
@@ -604,8 +605,9 @@ class HistoryInput(Input):
self.key_func['^R'] = self.toggle_search
self.search = False
if config.getbool('separate_history'):
- # pylint: disable=assigning-non-slot
self.history: List[str] = []
+ else:
+ self.history = self.__class__.global_history
def toggle_search(self) -> None:
if self.help_message:
@@ -682,7 +684,7 @@ class MessageInput(HistoryInput):
Also letting the user enter colors or other text markups
"""
# The history is common to all MessageInput
- history: List[str] = []
+ global_history: ClassVar[List[str]] = []
def __init__(self) -> None:
HistoryInput.__init__(self)
@@ -728,7 +730,7 @@ class CommandInput(HistoryInput):
HelpMessage when a command is started
The on_input callback
"""
- history: 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/roster_win.py b/poezio/windows/roster_win.py
index b112689e..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
@@ -279,7 +278,7 @@ 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
diff --git a/poezio/xdg.py b/poezio/xdg.py
index d4ce0538..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.
diff --git a/poezio/xhtml.py b/poezio/xhtml.py
index 29df520d..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,
@@ -431,7 +431,7 @@ class XHTMLHandler(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'):
diff --git a/setup.py b/setup.py
index d1dde4d2..8c9fa723 100755
--- a/setup.py
+++ b/setup.py
@@ -125,8 +125,8 @@ setup(
long_description=LONG_DESCRIPTION,
ext_modules=[module_poopt],
url='https://poez.io/',
- license='zlib',
- download_url='https://poez.io/#download',
+ license='GPL-3.0-or-later',
+ download_url='https://dev.louiz.org/projects/poezio/files',
author='Florent Le Coz',
author_email='louiz@louiz.org',
@@ -139,7 +139,7 @@ setup(
'Topic :: Internet :: XMPP',
'Environment :: Console :: Curses',
'Intended Audience :: End Users/Desktop',
- 'License :: OSI Approved :: zlib/libpng License',
+ 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)',
'Natural Language :: English',
'Operating System :: Unix',
'Programming Language :: Python :: 3.9',
@@ -152,7 +152,7 @@ setup(
package_dir={'poezio': 'poezio',
'poezio_plugins': 'plugins',
'poezio_themes': 'data/themes'},
- package_data={'poezio': ['default_config.cfg']},
+ package_data={'poezio': ['default_config.cfg', 'py.typed']},
scripts=['scripts/poezio_logs'],
entry_points={'console_scripts': ['poezio = poezio.__main__:run']},
data_files=([
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
+ }
+ }
+}