summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--data/doap.xml2
-rw-r--r--doc/source/dev/plugin.rst11
-rw-r--r--plugins/display_corrections.py2
-rw-r--r--plugins/embed.py12
-rwxr-xr-xplugins/qr.py178
-rw-r--r--plugins/reorder.py29
-rw-r--r--plugins/upload.py22
-rw-r--r--poezio/core/commands.py4
-rw-r--r--poezio/core/core.py7
-rw-r--r--poezio/core/handlers.py23
-rw-r--r--poezio/multiuserchat.py2
-rw-r--r--poezio/plugin.py8
-rw-r--r--poezio/plugin_manager.py50
-rw-r--r--poezio/tabs/basetabs.py13
-rw-r--r--poezio/tabs/conversationtab.py2
-rw-r--r--poezio/tabs/muctab.py88
-rw-r--r--poezio/tabs/privatetab.py2
-rw-r--r--poezio/text_buffer.py38
-rwxr-xr-xpoezio/theming.py5
19 files changed, 434 insertions, 64 deletions
diff --git a/data/doap.xml b/data/doap.xml
index f41c9fea..f8317ef7 100644
--- a/data/doap.xml
+++ b/data/doap.xml
@@ -423,7 +423,7 @@
<xmpp:SupportedXep>
<xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0308.html"/>
<xmpp:status>complete</xmpp:status>
- <xmpp:version>1.0</xmpp:version>
+ <xmpp:version>1.1.0</xmpp:version>
<xmpp:since>0.8</xmpp:since>
</xmpp:SupportedXep>
</implements>
diff --git a/doc/source/dev/plugin.rst b/doc/source/dev/plugin.rst
index 6a7605b2..4614c761 100644
--- a/doc/source/dev/plugin.rst
+++ b/doc/source/dev/plugin.rst
@@ -27,7 +27,6 @@ BasePlugin
.. module:: poezio.plugin
.. autoclass:: BasePlugin
- :members: init, cleanup, api, core
.. method:: init(self)
@@ -49,6 +48,16 @@ BasePlugin
The :py:class:`~PluginAPI` instance for this plugin.
+ .. attribute:: dependencies
+
+ Dependencies on other plugins, as a set of strings. A reference
+ to each dependency will be added in ``refs``.
+
+ .. attribute:: refs
+
+ This attribute is not to be edited by the user. It will be
+ populated when the plugin is initialized with references on each
+ plugin specified in the ``dependencies`` attribute.
Each plugin inheriting :py:class:`~BasePlugin` has an ``api`` member variable, which refers
to a :py:class:`~PluginAPI` object.
diff --git a/plugins/display_corrections.py b/plugins/display_corrections.py
index e9e8a2e4..99982ec9 100644
--- a/plugins/display_corrections.py
+++ b/plugins/display_corrections.py
@@ -43,7 +43,7 @@ class Plugin(BasePlugin):
messages = self.api.get_conversation_messages()
if not messages:
return None
- for message in messages[::-1]:
+ for message in reversed(messages):
if message.old_message:
if nb == 1:
return message
diff --git a/plugins/embed.py b/plugins/embed.py
index 9895a927..0c4a4a2a 100644
--- a/plugins/embed.py
+++ b/plugins/embed.py
@@ -28,14 +28,13 @@ class Plugin(BasePlugin):
help='Embed an image url into the contact\'s client',
usage='<image_url>')
- def embed_image_url(self, args):
+ def embed_image_url(self, url):
tab = self.api.current_tab()
message = self.core.xmpp.make_message(tab.jid)
- message['body'] = args
- message['oob']['url'] = args
- if isinstance(tab, tabs.MucTab):
- message['type'] = 'groupchat'
- else:
+ message['body'] = url
+ message['oob']['url'] = url
+ message['type'] = 'groupchat'
+ if not isinstance(tab, tabs.MucTab):
message['type'] = 'chat'
tab.add_message(
message['body'],
@@ -46,3 +45,4 @@ class Plugin(BasePlugin):
typ=1,
)
message.send()
+ self.core.refresh_window()
diff --git a/plugins/qr.py b/plugins/qr.py
new file mode 100755
index 00000000..25530248
--- /dev/null
+++ b/plugins/qr.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+
+import io
+import logging
+import qrcode
+import sys
+
+from poezio import windows
+from poezio.tabs import Tab
+from poezio.common import safeJID
+from poezio.core.structs import Command
+from poezio.decorators import command_args_parser
+from poezio.plugin import BasePlugin
+from poezio.theming import get_theme, to_curses_attr
+from poezio.windows.base_wins import Win
+
+log = logging.getLogger(__name__)
+
+class QrWindow(Win):
+ __slots__ = ('qr', 'invert', 'inverted')
+
+ str_invert = " Invert "
+ str_close = " Close "
+
+ def __init__(self, qr: str) -> None:
+ self.qr = qr
+ self.invert = True
+ self.inverted = True
+
+ def refresh(self) -> None:
+ self._win.erase()
+ # draw QR code
+ code = qrcode.QRCode()
+ code.add_data(self.qr)
+ out = io.StringIO()
+ code.print_ascii(out, invert=self.inverted)
+ self.addstr(" " + self.qr + "\n")
+ self.addstr(out.getvalue(), to_curses_attr((15, 0)))
+ self.addstr(" ")
+
+ col = to_curses_attr(get_theme().COLOR_TAB_NORMAL)
+
+ if self.invert:
+ self.addstr(self.str_invert, col)
+ else:
+ self.addstr(self.str_invert)
+
+ self.addstr(" ")
+
+ if self.invert:
+ self.addstr(self.str_close)
+ else:
+ self.addstr(self.str_close, col)
+
+ self._refresh()
+
+ def toggle_choice(self) -> None:
+ self.invert = not self.invert
+
+ def engage(self) -> bool:
+ if self.invert:
+ self.inverted = not self.inverted
+ return False
+ else:
+ return True
+
+class QrTab(Tab):
+ plugin_commands = {} # type: Dict[str, Command]
+ plugin_keys = {} # type: Dict[str, Callable]
+
+ def __init__(self, core, qr):
+ Tab.__init__(self, core)
+ self.state = 'highlight'
+ self.text = qr
+ self.name = qr
+ self.topic_win = windows.Topic()
+ self.topic_win.set_message(qr)
+ self.qr_win = QrWindow(qr)
+ self.help_win = windows.HelpText(
+ "Choose with arrow keys and press enter")
+ self.key_func['^I'] = self.toggle_choice
+ self.key_func[' '] = self.toggle_choice
+ self.key_func['KEY_LEFT'] = self.toggle_choice
+ self.key_func['KEY_RIGHT'] = self.toggle_choice
+ self.key_func['^M'] = self.engage
+ self.resize()
+ self.update_commands()
+ self.update_keys()
+
+ def resize(self):
+ self.need_resize = False
+ self.topic_win.resize(1, self.width, 0, 0)
+ self.qr_win.resize(self.height-3, self.width, 1, 0)
+ self.help_win.resize(1, self.width, self.height-1, 0)
+
+ def refresh(self):
+ if self.need_resize:
+ self.resize()
+ log.debug(' TAB Refresh: %s', self.__class__.__name__)
+ self.refresh_tab_win()
+ self.info_win.refresh()
+ self.topic_win.refresh()
+ self.qr_win.refresh()
+ self.help_win.refresh()
+
+ def on_input(self, key, raw):
+ if not raw and key in self.key_func:
+ return self.key_func[key]()
+
+ def toggle_choice(self):
+ log.debug(' TAB toggle_choice: %s', self.__class__.__name__)
+ self.qr_win.toggle_choice()
+ self.refresh()
+ self.core.doupdate()
+
+ def engage(self):
+ log.debug(' TAB engage: %s', self.__class__.__name__)
+ if self.qr_win.engage():
+ self.core.close_tab(self)
+ else:
+ self.refresh()
+ self.core.doupdate()
+
+class Plugin(BasePlugin):
+ def init(self):
+ self.api.add_command(
+ 'qr',
+ self.command_qr,
+ usage='<message>',
+ short='Display a QR code',
+ help='Display a QR code of <message> in a new tab')
+ self.api.add_command(
+ 'invitation',
+ self.command_invite,
+ usage='[<server>]',
+ short='Invite a user',
+ help='Generate a XEP-0401 invitation on your server or on <server> and display a QR code')
+
+ def command_qr(self, msg):
+ t = QrTab(self.core, msg)
+ self.core.add_tab(t, True)
+ self.core.doupdate()
+
+ def on_next(self, iq, adhoc_session):
+ status = iq['command']['status']
+ xform = iq.xml.find(
+ '{http://jabber.org/protocol/commands}command/{jabber:x:data}x')
+ if xform is not None:
+ form = self.core.xmpp.plugin['xep_0004'].build_form(xform)
+ else:
+ form = None
+ uri = None
+ if status == 'completed' and form:
+ for field in form:
+ log.debug(' field: %s -> %s', field['var'], field['value'])
+ if field['var'] == 'landing-url' and field['value']:
+ uri = field.get_value(convert=False)
+ if field['var'] == 'uri' and field['value'] and uri is None:
+ uri = field.get_value(convert=False)
+ if uri:
+ t = QrTab(self.core, uri)
+ self.core.add_tab(t, True)
+ self.core.doupdate()
+ else:
+ self.core.handler.next_adhoc_step(iq, adhoc_session)
+
+
+ @command_args_parser.quoted(0, 1, defaults=[])
+ def command_invite(self, args):
+ server = self.core.xmpp.boundjid.domain
+ if len(args) > 0:
+ server = safeJID(args[0])
+ session = {
+ 'next' : self.on_next,
+ 'error': self.core.handler.adhoc_error
+ }
+ self.core.xmpp.plugin['xep_0050'].start_command(server, 'urn:xmpp:invite#invite', session)
+
diff --git a/plugins/reorder.py b/plugins/reorder.py
index 8d9516f8..7be0b350 100644
--- a/plugins/reorder.py
+++ b/plugins/reorder.py
@@ -59,6 +59,8 @@ And finally, the ``[tab name]`` must be:
- For a type ``static``, the full JID of the contact
"""
+from slixmpp import InvalidJID, JID
+
from poezio import tabs
from poezio.decorators import command_args_parser
from poezio.plugin import BasePlugin
@@ -162,21 +164,32 @@ class Plugin(BasePlugin):
new_tabs += [
tabs.GapTab(self.core) for i in range(pos - last - 1)
]
- cls, name = tabs_spec[pos]
- tab = self.core.tabs.by_name_and_class(name, cls=cls)
- if tab and tab in old_tabs:
- new_tabs.append(tab)
- old_tabs.remove(tab)
- else:
- self.api.information('Tab %s not found' % name, 'Warning')
+ cls, jid = tabs_spec[pos]
+ try:
+ jid = JID(jid)
+ tab = self.core.tabs.by_name_and_class(str(jid), cls=cls)
+ if tab and tab in old_tabs:
+ new_tabs.append(tab)
+ old_tabs.remove(tab)
+ else:
+ self.api.information('Tab %s not found. Creating it' % jid, 'Warning')
+ # TODO: Add support for MucTab. Requires nickname.
+ if cls in (tabs.DynamicConversationTab, tabs.StaticConversationTab):
+ new_tab = cls(self.core, jid)
+ new_tabs.append(new_tab)
+ except:
+ self.api.information('Failed to create tab \'%s\'.' % jid, 'Error')
if create_gaps:
new_tabs.append(tabs.GapTab(self.core))
- last = pos
+ finally:
+ last = pos
for tab in old_tabs:
if tab:
new_tabs.append(tab)
+ # TODO: Ensure we don't break poezio and call this with whatever
+ # tablist we have. The roster tab at least needs to be in there.
self.core.tabs.replace_tabs(new_tabs)
self.core.refresh_window()
diff --git a/plugins/upload.py b/plugins/upload.py
index 7e25070e..5e6dfb04 100644
--- a/plugins/upload.py
+++ b/plugins/upload.py
@@ -16,6 +16,9 @@ This plugin adds a command to the chat tabs.
"""
+
+from typing import Optional
+
import asyncio
import traceback
from os.path import expanduser
@@ -30,7 +33,11 @@ from poezio import tabs
class Plugin(BasePlugin):
+ dependencies = {'embed'}
+
def init(self):
+ self.embed = self.refs['embed']
+
if not self.core.xmpp['xep_0363']:
raise Exception('slixmpp XEP-0363 plugin failed to load')
for _class in (tabs.PrivateTab, tabs.StaticConversationTab, tabs.DynamicConversationTab, tabs.MucTab):
@@ -43,18 +50,23 @@ class Plugin(BasePlugin):
short='Upload a file',
completion=self.completion_filename)
- async def async_upload(self, filename):
+ async def upload(self, filename) -> Optional[str]:
try:
url = await self.core.xmpp['xep_0363'].upload_file(filename)
except UploadServiceNotFound:
self.api.information('HTTP Upload service not found.', 'Error')
- return
+ return None
except Exception:
exception = traceback.format_exc()
self.api.information('Failed to upload file: %s' % exception,
'Error')
- return
- self.core.insert_input_text(url)
+ return None
+ return url
+
+ async def send_upload(self, filename):
+ url = await self.upload(filename)
+ if url is not None:
+ self.embed.embed_image_url(url)
@command_args_parser.quoted(1)
def command_upload(self, args):
@@ -63,7 +75,7 @@ class Plugin(BasePlugin):
return
filename, = args
filename = expanduser(filename)
- asyncio.ensure_future(self.async_upload(filename))
+ asyncio.ensure_future(self.send_upload(filename))
@staticmethod
def completion_filename(the_input):
diff --git a/poezio/core/commands.py b/poezio/core/commands.py
index fca9a705..b00cf24a 100644
--- a/poezio/core/commands.py
+++ b/poezio/core/commands.py
@@ -3,7 +3,7 @@ Global commands which are to be linked to the Core class
"""
import asyncio
-from xml.etree import cElementTree as ET
+from xml.etree import ElementTree as ET
from typing import List, Optional, Tuple
import logging
@@ -1035,9 +1035,9 @@ class CommandCore:
self.core.xmpp.plugin['xep_0196'].stop()
self.core.save_config()
self.core.plugin_manager.disable_plugins()
- self.core.disconnect(msg)
self.core.xmpp.add_event_handler(
"disconnected", self.core.exit, disposable=True)
+ self.core.disconnect(msg)
@command_args_parser.quoted(0, 1, [''])
def destroy_room(self, args: List[str]) -> None:
diff --git a/poezio/core/core.py b/poezio/core/core.py
index fe6a9d78..14852ac2 100644
--- a/poezio/core/core.py
+++ b/poezio/core/core.py
@@ -16,7 +16,7 @@ import time
import uuid
from collections import defaultdict
from typing import Callable, Dict, List, Optional, Set, Tuple, Type
-from xml.etree import cElementTree as ET
+from xml.etree import ElementTree as ET
from functools import partial
from slixmpp import JID, InvalidJID
@@ -227,6 +227,7 @@ class Core:
('connected', self.handler.on_connected),
('connection_failed', self.handler.on_failed_connection),
('disconnected', self.handler.on_disconnected),
+ ('reconnect_delay', self.handler.on_reconnect_delay),
('failed_all_auth', self.handler.on_failed_all_auth),
('got_offline', self.handler.on_got_offline),
('got_online', self.handler.on_got_online),
@@ -517,10 +518,10 @@ class Core:
plugins = config.get('plugins_autoload')
if ':' in plugins:
for plugin in plugins.split(':'):
- self.plugin_manager.load(plugin)
+ self.plugin_manager.load(plugin, unload_first=False)
else:
for plugin in plugins.split():
- self.plugin_manager.load(plugin)
+ self.plugin_manager.load(plugin, unload_first=False)
self.plugins_autoloaded = True
def start(self):
diff --git a/poezio/core/handlers.py b/poezio/core/handlers.py
index cfdeb271..1078916f 100644
--- a/poezio/core/handlers.py
+++ b/poezio/core/handlers.py
@@ -553,7 +553,7 @@ class HandlerCore:
return
item = message['pubsub_event']['items']['item']
old_gaming = contact.gaming
- if item.xml.find('{urn:xmpp:gaming:0}gaming') is not None:
+ if item.xml.find('{urn:xmpp:gaming:0}game') is not None:
item = item['gaming']
# only name and server_address are used for now
contact.gaming = {
@@ -770,7 +770,7 @@ class HandlerCore:
self.core.events.trigger('highlight', message, tab)
if message['from'].resource == tab.own_nick:
- tab.last_sent_message = message
+ tab.set_last_sent_message(message, correct=replaced)
if tab is self.core.tabs.current_tab:
tab.text_win.refresh()
@@ -862,7 +862,7 @@ class HandlerCore:
jid=message['from'],
typ=1)
if sent:
- tab.last_sent_message = message
+ tab.set_last_sent_message(message, correct=replaced)
else:
tab.last_remote_message = datetime.now()
@@ -1116,7 +1116,7 @@ class HandlerCore:
if not contact:
return
roster.modified()
- contact.error = presence['error']['type'] + ': ' + presence['error']['condition']
+ contact.error = presence['error']['text'] or presence['error']['type'] + ': ' + presence['error']['condition']
# TODO: reset chat states status on presence error
def on_got_offline(self, presence):
@@ -1247,8 +1247,15 @@ class HandlerCore:
'conflict', 'host-unknown')):
return
await asyncio.sleep(1)
- self.core.information("Auto-reconnecting.", 'Info')
- self.core.xmpp.start()
+ if not self.core.xmpp.is_connecting() and not self.core.xmpp.is_connected():
+ self.core.information("Auto-reconnecting.", 'Info')
+ self.core.xmpp.start()
+
+ async def on_reconnect_delay(self, event):
+ """
+ When the reconnection is delayed
+ """
+ self.core.information("Reconnecting in %d seconds..." % (event), 'Info')
def on_stream_error(self, event):
"""
@@ -1504,6 +1511,10 @@ class HandlerCore:
poezio_colored,
nickname=char)
except:
+ # Most of the time what gets logged is whitespace pings. Skip.
+ # And also skip tab updates.
+ if stanza.strip() == '':
+ return None
log.debug('', exc_info=True)
if isinstance(self.core.tabs.current_tab, tabs.XMLTab):
diff --git a/poezio/multiuserchat.py b/poezio/multiuserchat.py
index 47244e3d..30c36a77 100644
--- a/poezio/multiuserchat.py
+++ b/poezio/multiuserchat.py
@@ -10,7 +10,7 @@ Add some facilities that are not available on the XEP_0045
slix plugin
"""
-from xml.etree import cElementTree as ET
+from xml.etree import ElementTree as ET
from poezio.common import safeJID
from slixmpp import JID
diff --git a/poezio/plugin.py b/poezio/plugin.py
index 61e0ea87..0ba13412 100644
--- a/poezio/plugin.py
+++ b/poezio/plugin.py
@@ -3,6 +3,8 @@ Define the PluginConfig and Plugin classes, plus the SafetyMetaclass.
These are used in the plugin system added in poezio 0.7.5
(see plugin_manager.py)
"""
+
+from typing import Any, Dict, Set
from asyncio import iscoroutinefunction
from functools import partial
from configparser import RawConfigParser
@@ -399,7 +401,13 @@ class BasePlugin(object, metaclass=SafetyMetaclass):
Class that all plugins derive from.
"""
+ # Internal use only
+ _unloading = False
+
default_config = None
+ dependencies: Set[str] = set()
+ # This dict will get populated when the plugin is initialized
+ refs: Dict[str, Any] = {}
def __init__(self, name, plugin_api, core, plugins_conf_dir):
self.__name = name
diff --git a/poezio/plugin_manager.py b/poezio/plugin_manager.py
index e603b6fa..bf708089 100644
--- a/poezio/plugin_manager.py
+++ b/poezio/plugin_manager.py
@@ -7,6 +7,7 @@ plugin env.
import logging
import os
+from typing import Dict, Set
from importlib import import_module, machinery
from pathlib import Path
from os import path
@@ -27,6 +28,8 @@ class PluginManager:
And keeps track of everything the plugin has done through the API.
"""
+ rdeps: Dict[str, Set[str]] = {}
+
def __init__(self, core):
self.core = core
# module name -> module object
@@ -58,10 +61,25 @@ class PluginManager:
for plugin in set(self.plugins.keys()):
self.unload(plugin, notify=False)
- def load(self, name: str, notify=True):
+ def set_rdeps(self, name):
+ """
+ Runs through plugin dependencies to build the reverse dependencies table.
+ """
+
+ if name not in self.rdeps:
+ self.rdeps[name] = set()
+ for dep in self.plugins[name].dependencies:
+ if dep not in self.rdeps:
+ self.rdeps[dep] = {name}
+ else:
+ self.rdeps[dep].add(name)
+
+ def load(self, name: str, notify=True, unload_first=True):
"""
Load a plugin.
"""
+ if not unload_first and name in self.plugins:
+ return None
if name in self.plugins:
self.unload(name)
@@ -83,7 +101,7 @@ class PluginManager:
log.debug('Found candidate entry for plugin %s: %r', name, entry)
try:
module = entry.load()
- except ImportError as exn:
+ except Exception as exn:
log.debug('Failed to import plugin: %s\n%r', name,
exn, exc_info=True)
finally:
@@ -109,8 +127,22 @@ class PluginManager:
self.event_handlers[name] = []
try:
self.plugins[name] = None
+
+ for dep in module.Plugin.dependencies:
+ self.load(dep, unload_first=False)
+ if dep not in self.plugins:
+ log.debug(
+ 'Plugin %s couldn\'t load because of dependency %s',
+ name, dep
+ )
+ return None
+ # Add reference of the dep to the plugin's usage
+ module.Plugin.refs[dep] = self.plugins[dep]
+
self.plugins[name] = module.Plugin(name, self.plugin_api, self.core,
self.plugins_conf_dir)
+ self.set_rdeps(name)
+
except Exception as e:
log.error('Error while loading the plugin %s', name, exc_info=True)
if notify:
@@ -122,8 +154,21 @@ class PluginManager:
self.core.information('Plugin %s loaded' % name, 'Info')
def unload(self, name: str, notify=True):
+ """
+ Unloads plugin as well as plugins depending on it.
+ """
+
if name in self.plugins:
try:
+ if self.plugins[name] is not None:
+ self.plugins[name]._unloading = True # Prevents loops
+ for rdep in self.rdeps[name].copy():
+ if rdep in self.plugins and not self.plugins[rdep]._unloading:
+ self.unload(rdep)
+ if rdep in self.plugins:
+ log.debug('Failed to unload reverse dependency %s first.', rdep)
+ return None
+
for command in self.commands[name].keys():
del self.core.commands[command]
for key in self.keys[name].keys():
@@ -143,6 +188,7 @@ class PluginManager:
if self.plugins[name] is not None:
self.plugins[name].unload()
del self.plugins[name]
+ del self.rdeps[name]
del self.commands[name]
del self.keys[name]
del self.tab_commands[name]
diff --git a/poezio/tabs/basetabs.py b/poezio/tabs/basetabs.py
index 7749de6c..706172ed 100644
--- a/poezio/tabs/basetabs.py
+++ b/poezio/tabs/basetabs.py
@@ -20,7 +20,7 @@ import asyncio
import time
from math import ceil, log10
from datetime import datetime
-from xml.etree import cElementTree as ET
+from xml.etree import ElementTree as ET
from typing import (
Any,
Callable,
@@ -609,7 +609,7 @@ class ChatTab(Tab):
message = self._text_buffer.modify_message(
txt, old_id, new_id, time=time, user=user, jid=jid)
if message:
- self.text_win.modify_message(old_id, message)
+ self.text_win.modify_message(message.identifier, message)
self.core.refresh_window()
return True
return False
@@ -748,6 +748,15 @@ class ChatTab(Tab):
self.core.remove_timed_event(self.timed_event_not_paused)
self.timed_event_not_paused = None
+ def set_last_sent_message(self, msg, correct=False):
+ """Ensure last_sent_message is set with the correct attributes"""
+ if correct:
+ # XXX: Is the copy needed. Is the object passed here reused
+ # afterwards? Who knows.
+ msg = copy.copy(msg)
+ msg['id'] = self.last_sent_message['id']
+ self.last_sent_message = msg
+
@command_args_parser.raw
def command_correct(self, line):
"""
diff --git a/poezio/tabs/conversationtab.py b/poezio/tabs/conversationtab.py
index 39411872..410c5eda 100644
--- a/poezio/tabs/conversationtab.py
+++ b/poezio/tabs/conversationtab.py
@@ -139,7 +139,7 @@ class ConversationTab(OneToOneTab):
self.core.events.trigger('conversation_say_after', msg, self)
if not msg['body']:
return
- self.last_sent_message = msg
+ self.set_last_sent_message(msg, correct=correct)
self.core.handler.on_normal_message(msg)
msg._add_receipt = True
msg.send()
diff --git a/poezio/tabs/muctab.py b/poezio/tabs/muctab.py
index 3e754ae6..92dc1e51 100644
--- a/poezio/tabs/muctab.py
+++ b/poezio/tabs/muctab.py
@@ -7,6 +7,7 @@ It keeps track of many things such as part/joins, maintains an
user list, and updates private tabs when necessary.
"""
+import asyncio
import bisect
import curses
import logging
@@ -20,6 +21,7 @@ from datetime import datetime
from typing import Dict, Callable, List, Optional, Union, Set
from slixmpp import InvalidJID, JID
+from slixmpp.exceptions import IqError, IqTimeout
from poezio.tabs import ChatTab, Tab, SHOW_NAME
from poezio import common
@@ -1127,7 +1129,7 @@ class MucTab(ChatTab):
user=user,
jid=jid)
if message:
- self.text_win.modify_message(old_id, message)
+ self.text_win.modify_message(message.identifier, message)
return highlight
return False
@@ -1596,24 +1598,90 @@ class MucTab(ChatTab):
nick, role, reason = args[0], args[1].lower(), args[2]
self.change_role(nick, role, reason)
- @command_args_parser.quoted(2)
- def command_affiliation(self, args):
+ @command_args_parser.quoted(0, 2)
+ def command_affiliation(self, args) -> None:
"""
- /affiliation <nick or jid> <affiliation>
+ /affiliation [<nick or jid> [<affiliation>]]
Changes the affiliation of a user
affiliations can be: outcast, none, member, admin, owner
"""
- def callback(iq):
- if iq['type'] == 'error':
- self.core.room_error(iq, self.jid.bare)
+ room = JID(self.name)
+ if not room:
+ self.core.information('affiliation: requires a valid chat address', 'Error')
+ return
- if args is None:
+ # List affiliations
+ if not args:
+ asyncio.ensure_future(self.get_users_affiliations(room))
+ return None
+
+ if len(args) != 2:
return self.core.command.help('affiliation')
nick, affiliation = args[0], args[1].lower()
+ # Set affiliation
self.change_affiliation(nick, affiliation)
+ async def get_users_affiliations(self, jid: JID) -> None:
+ MUC_ADMIN_NS = 'http://jabber.org/protocol/muc#admin'
+
+ iqs = await asyncio.gather(
+ self.core.xmpp['xep_0045'].get_users_by_affiliation(jid, 'owner'),
+ self.core.xmpp['xep_0045'].get_users_by_affiliation(jid, 'admin'),
+ self.core.xmpp['xep_0045'].get_users_by_affiliation(jid, 'member'),
+ self.core.xmpp['xep_0045'].get_users_by_affiliation(jid, 'outcast'),
+ return_exceptions=True,
+ )
+
+ all_errors = functools.reduce(
+ lambda acc, iq: acc and isinstance(iq, (IqError, IqTimeout)),
+ iqs,
+ True,
+ )
+
+ theme = get_theme()
+ aff_colors = {
+ 'owner': theme.CHAR_AFFILIATION_OWNER,
+ 'admin': theme.CHAR_AFFILIATION_ADMIN,
+ 'member': theme.CHAR_AFFILIATION_MEMBER,
+ 'outcast': theme.CHAR_AFFILIATION_OUTCAST,
+ }
+
+ if all_errors:
+ self.add_message(
+ 'Can\'t access affiliations',
+ highlight=True,
+ nickname='Error',
+ nick_color=theme.COLOR_ERROR_MSG,
+ typ=2,
+ )
+ self.core.refresh_window()
+ return None
+
+ self._text_buffer.add_message('Affiliations')
+ for iq in iqs:
+ if isinstance(iq, (IqError, IqTimeout)):
+ continue
+
+ query = iq.xml.find('{%s}query' % MUC_ADMIN_NS)
+ items = query.findall('{%s}item' % MUC_ADMIN_NS)
+ if not items: # Nobody with this affiliation
+ continue
+
+ affiliation = items[0].get('affiliation')
+ aff_char = aff_colors[affiliation]
+ self._text_buffer.add_message(
+ ' %s%s' % (aff_char, affiliation.capitalize()),
+ )
+
+ items = map(lambda i: i.get('jid'), items)
+ for ajid in sorted(items):
+ self._text_buffer.add_message(' %s' % ajid)
+
+ self.core.refresh_window()
+ return None
+
@command_args_parser.raw
def command_say(self, line, correct=False):
"""
@@ -1648,7 +1716,7 @@ class MucTab(ChatTab):
self.text_win.refresh()
self.input.refresh()
return
- self.last_sent_message = msg
+ self.set_last_sent_message(msg, correct=correct)
msg.send()
self.chat_state = needed
@@ -1936,7 +2004,7 @@ class MucTab(ChatTab):
'func':
self.command_affiliation,
'usage':
- '<nick or jid> <affiliation>',
+ '[<nick or jid> [<affiliation>]]',
'desc': ('Set the affiliation of a user. Affiliations can be:'
' outcast, none, member, admin, owner.'),
'shortdesc':
diff --git a/poezio/tabs/privatetab.py b/poezio/tabs/privatetab.py
index 8d2c1b11..ee4cd84c 100644
--- a/poezio/tabs/privatetab.py
+++ b/poezio/tabs/privatetab.py
@@ -177,7 +177,7 @@ class PrivateTab(OneToOneTab):
self.core.events.trigger('private_say_after', msg, self)
if not msg['body']:
return
- self.last_sent_message = msg
+ self.set_last_sent_message(msg, correct=correct)
self.core.handler.on_groupchat_private_message(msg, sent=True)
msg._add_receipt = True
msg.send()
diff --git a/poezio/text_buffer.py b/poezio/text_buffer.py
index d9347527..2c0d192a 100644
--- a/poezio/text_buffer.py
+++ b/poezio/text_buffer.py
@@ -11,7 +11,7 @@ independently by their TextWins.
import logging
log = logging.getLogger(__name__)
-from typing import Union, Optional, List, Tuple
+from typing import Dict, Union, Optional, List, Tuple
from datetime import datetime
from poezio.config import config
from poezio.theming import get_theme, dump_tuple
@@ -121,6 +121,8 @@ class TextBuffer:
self._messages_nb_limit = messages_nb_limit # type: int
# Message objects
self.messages = [] # type: List[Message]
+ # COMPAT: Correction id -> Original message id.
+ self.correction_ids = {} # type: Dict[str, str]
# we keep track of one or more windows
# so we can pass the new messages to them, as they are added, so
# they (the windows) can build the lines from the new message
@@ -186,15 +188,20 @@ class TextBuffer:
return min(ret_val, 1)
- def _find_message(self, old_id: str) -> int:
+ def _find_message(self, orig_id: str) -> Tuple[str, int]:
"""
Find a message in the text buffer from its message id
"""
+ # When looking for a message, ensure the id doesn't appear in a
+ # message we've removed from our message list. If so return the index
+ # of the corresponding id for the original message instead.
+ orig_id = self.correction_ids.get(orig_id, orig_id)
+
for i in range(len(self.messages) - 1, -1, -1):
msg = self.messages[i]
- if msg.identifier == old_id:
- return i
- return -1
+ if msg.identifier == orig_id:
+ return (orig_id, i)
+ return (orig_id, -1)
def ack_message(self, old_id: str, jid: str) -> Union[None, bool, Message]:
"""Mark a message as acked"""
@@ -211,7 +218,7 @@ class TextBuffer:
Edit the ack status of a message, and optionally
append some text.
"""
- i = self._find_message(old_id)
+ _, i = self._find_message(old_id)
if i == -1:
return None
msg = self.messages[i]
@@ -228,7 +235,7 @@ class TextBuffer:
def modify_message(self,
txt: str,
- old_id: str,
+ orig_id: str,
new_id: str,
highlight: bool = False,
time: Optional[datetime] = None,
@@ -236,14 +243,19 @@ class TextBuffer:
jid: Optional[str] = None):
"""
Correct a message in a text buffer.
+
+ Version 1.1.0 of Last Message Correction (0308) added clarifications
+ that break the way poezio handles corrections. Instead of linking
+ corrections to the previous correction/message as we were doing, we
+ are now required to link all corrections to the original messages.
"""
- i = self._find_message(old_id)
+ orig_id, i = self._find_message(orig_id)
if i == -1:
log.debug(
'Message %s not found in text_buffer, abort replacement.',
- old_id)
+ orig_id)
raise CorrectionError("nothing to replace")
msg = self.messages[i]
@@ -258,10 +270,12 @@ class TextBuffer:
elif not msg.user and msg.jid != jid:
raise CorrectionError(
'Messages %s and %s have not been '
- 'sent by the same fullJID' % (old_id, new_id))
+ 'sent by the same fullJID' % (orig_id, new_id))
if not time:
time = msg.time
+
+ self.correction_ids[new_id] = orig_id
message = Message(
txt,
time,
@@ -269,13 +283,13 @@ class TextBuffer:
msg.nick_color,
False,
msg.user,
- new_id,
+ orig_id,
highlight=highlight,
old_message=msg,
revisions=msg.revisions + 1,
jid=jid)
self.messages[i] = message
- log.debug('Replacing message %s with %s.', old_id, new_id)
+ log.debug('Replacing message %s with %s.', orig_id, new_id)
return message
def del_window(self, win) -> None:
diff --git a/poezio/theming.py b/poezio/theming.py
index bbf2fb64..fc34ae39 100755
--- a/poezio/theming.py
+++ b/poezio/theming.py
@@ -178,12 +178,13 @@ class Theme:
CHAR_CHATSTATE_COMPOSING = 'X'
CHAR_CHATSTATE_PAUSED = 'p'
- # These characters are used for the affiliation in the user list
- # in a MUC
+ # These characters are used for the affiliation wherever needed, e.g., in
+ # the user list in a MUC, or when displaying affiliation lists.
CHAR_AFFILIATION_OWNER = '~'
CHAR_AFFILIATION_ADMIN = '&'
CHAR_AFFILIATION_MEMBER = '+'
CHAR_AFFILIATION_NONE = '-'
+ CHAR_AFFILIATION_OUTCAST = '!'
# XML Tab
CHAR_XML_IN = 'IN '