Merge branch 'fix-history-fetch' into 'master'

Fix many MAM issues

Closes #3516, #3496, #3498, #3506, #3522, and #3493

See merge request poezio/poezio!105
This commit is contained in:
mathieui 2020-05-23 16:38:05 +02:00
commit e48780ddf0
12 changed files with 548 additions and 156 deletions

View file

@ -8,7 +8,11 @@
Various useful functions.
"""
from datetime import datetime, timedelta
from datetime import (
datetime,
timedelta,
timezone,
)
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
@ -488,3 +492,14 @@ def unique_prefix_of(a: str, b: str) -> str:
return a[:i+1]
# both are equal, return a
return a
def to_utc(time: datetime) -> datetime:
"""Convert a datetime-aware time zone into raw UTC"""
tzone = datetime.now().astimezone().tzinfo
if time.tzinfo is not None: # Convert to UTC
time = time.astimezone(tz=timezone.utc)
else: # Assume local tz, convert to URC
time = time.replace(tzinfo=tzone).astimezone(tz=timezone.utc)
# Return an offset-naive datetime
return time.replace(tzinfo=None)

View file

@ -2075,16 +2075,7 @@ class Core:
# do not join rooms that do not have autojoin
# but display them anyway
if bm.autojoin:
muc.join_groupchat(
self,
bm.jid,
nick,
passwd=bm.password,
status=self.status.message,
show=self.status.show,
tab=tab)
if tab._text_buffer.last_message is None:
asyncio.ensure_future(mam.on_tab_open(tab))
tab.join()
def check_bookmark_storage(self, features):
private = 'jabber:iq:private' in features

View file

@ -6,34 +6,49 @@
XEP-0313: Message Archive Management(MAM).
"""
import asyncio
import logging
import random
from datetime import datetime, timedelta, timezone
from hashlib import md5
from typing import Optional, Callable
from typing import (
Any,
AsyncIterable,
Callable,
Dict,
List,
Optional,
)
from slixmpp import JID
from slixmpp import JID, Message as SMessage
from slixmpp.exceptions import IqError, IqTimeout
from poezio.theming import get_theme
from poezio import tabs
from poezio import xhtml, colors
from poezio.config import config
from poezio.text_buffer import TextBuffer
from poezio.ui.types import Message
from poezio.common import to_utc
from poezio.text_buffer import TextBuffer, HistoryGap
from poezio.ui.types import (
BaseMessage,
EndOfArchive,
Message,
)
log = logging.getLogger(__name__)
class DiscoInfoException(Exception): pass
class MAMQueryException(Exception): pass
class NoMAMSupportException(Exception): pass
def add_line(
tab,
text_buffer: TextBuffer,
def make_line(
tab: tabs.ChatTab,
text: str,
time: datetime,
nick: str,
top: bool,
) -> None:
identifier: str = '',
) -> Message:
"""Adds a textual entry in the TextBuffer"""
# Convert to local timezone
@ -61,150 +76,188 @@ def add_line(
color = xhtml.colors.get(color)
color = (color, -1)
else:
nick = nick.split('/')[0]
color = get_theme().COLOR_OWN_NICK
text_buffer.add_message(
Message(
txt=text,
time=time,
nickname=nick,
nick_color=color,
history=True,
user=None,
top=top,
)
if nick.split('/')[0] == tab.core.xmpp.boundjid.bare:
color = get_theme().COLOR_OWN_NICK
else:
color = get_theme().COLOR_REMOTE_USER
nick = tab.get_nick()
return Message(
txt=text,
identifier=identifier,
time=time,
nickname=nick,
nick_color=color,
history=True,
user=None,
)
async def query(
async def get_mam_iterator(
core,
groupchat: bool,
remote_jid: JID,
amount: int,
reverse: bool,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
reverse: bool = True,
start: Optional[str] = None,
end: Optional[str] = None,
before: Optional[str] = None,
callback: Optional[Callable] = None,
) -> None:
) -> AsyncIterable[Message]:
"""Get an async iterator for this mam query"""
try:
query_jid = remote_jid if groupchat else JID(core.xmpp.boundjid.bare)
iq = await core.xmpp.plugin['xep_0030'].get_info(jid=query_jid)
except (IqError, IqTimeout):
raise DiscoInfoException
raise DiscoInfoException()
if 'urn:xmpp:mam:2' not in iq['disco_info'].get_features():
raise NoMAMSupportException
raise NoMAMSupportException()
args = {
'iterator': True,
'reverse': reverse,
}
} # type: Dict[str, Any]
if groupchat:
args['jid'] = remote_jid
else:
args['with_jid'] = remote_jid
args['rsm'] = {'max': amount}
if reverse:
if before is not None:
args['rsm']['before'] = before
else:
args['end'] = end
else:
args['rsm']['start'] = start
if before is not None:
args['rsm']['end'] = end
try:
results = core.xmpp['xep_0313'].retrieve(**args)
except (IqError, IqTimeout):
raise MAMQueryException
if callback is not None:
callback(results)
return results
if amount > 0:
args['rsm'] = {'max': amount}
args['start'] = start
args['end'] = end
return core.xmpp['xep_0313'].retrieve(**args)
async def add_messages_to_buffer(tab, top: bool, results, amount: int) -> bool:
"""Prepends or appends messages to the tab text_buffer"""
def _parse_message(msg: SMessage) -> Dict:
"""Parse info inside a MAM forwarded message"""
forwarded = msg['mam_result']['forwarded']
message = forwarded['stanza']
return {
'time': forwarded['delay']['stamp'],
'nick': str(message['from']),
'text': message['body'],
'identifier': message['origin-id']
}
async def retrieve_messages(tab: tabs.ChatTab,
results: AsyncIterable[SMessage],
amount: int = 100) -> List[BaseMessage]:
"""Run the MAM query and put messages in order"""
text_buffer = tab._text_buffer
msg_count = 0
msgs = []
async for rsm in results:
if top:
to_add = []
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:
msgs.append(msg)
if msg_count == amount:
tab.core.refresh_window()
return False
.xml.find('{%s}%s' % ('jabber:client', 'body')) is not None:
args = _parse_message(msg)
msgs.append(make_line(tab, **args))
for msg in reversed(msgs):
to_add.append(msg)
msg_count += 1
msgs.reverse()
for msg in msgs:
forwarded = msg['mam_result']['forwarded']
timestamp = forwarded['delay']['stamp']
message = forwarded['stanza']
tab.last_stanza_id = msg['mam_result']['id']
nick = str(message['from'])
add_line(tab, text_buffer, message['body'], timestamp, nick, top)
else:
for msg in rsm['mam']['results']:
forwarded = msg['mam_result']['forwarded']
timestamp = forwarded['delay']['stamp']
message = forwarded['stanza']
nick = str(message['from'])
add_line(tab, text_buffer, message['body'], timestamp, nick, top)
tab.core.refresh_window()
return False
if msg_count == amount:
to_add.reverse()
return to_add
msgs = []
to_add.reverse()
return to_add
except (IqError, IqTimeout) as exc:
log.debug('Unable to complete MAM query: %s', exc, exc_info=True)
raise MAMQueryException('Query interrupted')
async def fetch_history(tab, end: Optional[datetime] = None, amount: Optional[int] = None):
async def fetch_history(tab: tabs.ChatTab,
start: Optional[datetime] = None,
end: Optional[datetime] = None,
amount: int = 100) -> List[BaseMessage]:
remote_jid = tab.jid
before = tab.last_stanza_id
if not end:
for msg in tab._text_buffer.messages:
if isinstance(msg, Message):
end = msg.time
end -= timedelta(microseconds=1)
break
if end is None:
end = datetime.now()
tzone = datetime.now().astimezone().tzinfo
end = end.replace(tzinfo=tzone).astimezone(tz=timezone.utc)
end = end.replace(tzinfo=None)
end = datetime.strftime(end, '%Y-%m-%dT%H:%M:%SZ')
end = to_utc(end)
end_str = datetime.strftime(end, '%Y-%m-%dT%H:%M:%SZ')
if amount >= 100:
amount = 99
start_str = None
if start is not None:
start = to_utc(start)
start_str = datetime.strftime(start, '%Y-%m-%dT%H:%M:%SZ')
groupchat = isinstance(tab, tabs.MucTab)
results = await query(
tab.core,
groupchat,
remote_jid,
amount,
mam_iterator = await get_mam_iterator(
core=tab.core,
groupchat=isinstance(tab, tabs.MucTab),
remote_jid=remote_jid,
amount=amount,
end=end_str,
start=start_str,
reverse=True,
end=end,
before=before,
)
query_status = await add_messages_to_buffer(tab, True, results, amount)
tab.query_status = query_status
return await retrieve_messages(tab, mam_iterator, amount)
async def fill_missing_history(tab: tabs.ChatTab, gap: HistoryGap) -> None:
start = gap.last_timestamp_before_leave
end = gap.first_timestamp_after_join
if start:
start = start + timedelta(seconds=1)
if end:
end = end - timedelta(seconds=1)
try:
messages = await fetch_history(tab, start=start, end=end, amount=999)
tab._text_buffer.add_history_messages(messages, gap=gap)
if messages:
tab.core.refresh_window()
except (NoMAMSupportException, MAMQueryException, DiscoInfoException):
return
finally:
tab.query_status = False
async def on_tab_open(tab) -> None:
async def on_new_tab_open(tab: tabs.ChatTab) -> None:
"""Called when opening a new tab"""
amount = 2 * tab.text_win.height
end = datetime.now()
tab.query_status = True
for message in tab._text_buffer.messages:
time = message.time
if time < end:
end = time
end = end + timedelta(seconds=-1)
if isinstance(message, Message) and to_utc(message.time) < to_utc(end):
end = message.time
break
end = end - timedelta(microseconds=1)
try:
await fetch_history(tab, end=end, amount=amount)
messages = await fetch_history(tab, end=end, amount=amount)
tab._text_buffer.add_history_messages(messages)
if messages:
tab.core.refresh_window()
except (NoMAMSupportException, MAMQueryException, DiscoInfoException):
tab.query_status = False
return None
finally:
tab.query_status = False
async def on_scroll_up(tab) -> None:
def schedule_tab_open(tab: tabs.ChatTab) -> None:
"""Set the query status and schedule a MAM query"""
tab.query_status = True
asyncio.ensure_future(on_tab_open(tab))
async def on_tab_open(tab: tabs.ChatTab) -> None:
gap = tab._text_buffer.find_last_gap_muc()
if gap is None or not gap.leave_message:
await on_new_tab_open(tab)
else:
await fill_missing_history(tab, gap)
def schedule_scroll_up(tab: tabs.ChatTab) -> None:
"""Set query status and schedule a scroll up"""
tab.query_status = True
asyncio.ensure_future(on_scroll_up(tab))
async def on_scroll_up(tab: tabs.ChatTab) -> None:
tw = tab.text_win
# If position in the tab is < two screen pages, then fetch MAM, so that we
@ -212,22 +265,31 @@ async def on_scroll_up(tab) -> None:
# join if not already available.
total, pos, height = len(tw.built_lines), tw.pos, tw.height
rest = (total - pos) // height
# Not resetting the state of query_status here, it is changed only after the
# query is complete (in fetch_history)
# This is done to stop message repetition, eg: if the user presses PageUp continuously.
tab.query_status = True
if rest > 1:
tab.query_status = False
return None
try:
# XXX: Do we want to fetch a possibly variable number of messages?
# (InfoTab changes height depending on the type of messages, see
# `information_buffer_popup_on`).
await fetch_history(tab, amount=height)
messages = await fetch_history(tab, amount=height)
last_message_exists = False
if tab._text_buffer.messages:
last_message = tab._text_buffer.messages[0]
last_message_exists = True
if not messages and last_message_exists and not isinstance(last_message, EndOfArchive):
time = tab._text_buffer.messages[0].time
messages = [EndOfArchive('End of archive reached', time=time)]
tab._text_buffer.add_history_messages(messages)
if messages:
tab.core.refresh_window()
except NoMAMSupportException:
tab.core.information('MAM not supported for %r' % tab.jid, 'Info')
return None
except (MAMQueryException, DiscoInfoException):
tab.core.information('An error occured when fetching MAM for %r' % tab.jid, 'Error')
return None
finally:
tab.query_status = False

View file

@ -32,7 +32,6 @@ from typing import (
)
from poezio import (
mam,
poopt,
timed_events,
xhtml,
@ -493,12 +492,11 @@ class ChatTab(Tab):
self._jid = jid
#: Is the tab currently requesting MAM data?
self.query_status = False
self.last_stanza_id = None
self._name = jid.full # type: Optional[str]
self.text_win = None
self.text_win = windows.TextWin()
self.directed_presence = None
self._text_buffer = TextBuffer()
self._text_buffer.add_window(self.text_win)
self.chatstate = None # can be "active", "composing", "paused", "gone", "inactive"
# We keep a reference of the event that will set our chatstate to "paused", so that
# we can delete it or change it if we need to
@ -926,7 +924,8 @@ class ChatTab(Tab):
def on_scroll_up(self):
if not self.query_status:
asyncio.ensure_future(mam.on_scroll_up(tab=self))
from poezio import mam
mam.schedule_scroll_up(tab=self)
return self.text_win.scroll_up(self.text_win.height - 1)
def on_scroll_down(self):

View file

@ -48,8 +48,6 @@ class ConversationTab(OneToOneTab):
self.nick = None
self.nick_sent = False
self.state = 'normal'
self.text_win = windows.TextWin()
self._text_buffer.add_window(self.text_win)
self.upper_bar = windows.ConversationStatusMessageWin()
self.input = windows.MessageInput()
# keys

View file

@ -31,7 +31,7 @@ from poezio import multiuserchat as muc
from poezio import timed_events
from poezio import windows
from poezio import xhtml
from poezio.common import safeJID
from poezio.common import safeJID, to_utc
from poezio.config import config
from poezio.core.structs import Command
from poezio.decorators import refresh_wrapper, command_args_parser
@ -40,7 +40,14 @@ from poezio.roster import roster
from poezio.theming import get_theme, dump_tuple
from poezio.user import User
from poezio.core.structs import Completion, Status
from poezio.ui.types import BaseMessage, Message, InfoMessage, StatusMessage
from poezio.ui.types import (
BaseMessage,
InfoMessage,
Message,
MucOwnJoinMessage,
MucOwnLeaveMessage,
StatusMessage,
)
log = logging.getLogger(__name__)
@ -84,8 +91,6 @@ class MucTab(ChatTab):
self.self_ping_event = None
# UI stuff
self.topic_win = windows.Topic()
self.text_win = windows.TextWin()
self._text_buffer.add_window(self.text_win)
self.v_separator = windows.VerticalSeparator()
self.user_win = windows.UserList()
self.info_header = windows.MucInfoWin()
@ -151,10 +156,10 @@ class MucTab(ChatTab):
"""
status = self.core.get_status()
if self.last_connection:
delta = datetime.now() - self.last_connection
delta = to_utc(datetime.now()) - to_utc(self.last_connection)
seconds = delta.seconds + delta.days * 24 * 3600
else:
seconds = None
seconds = self._text_buffer.find_last_message()
muc.join_groupchat(
self.core,
self.jid.bare,
@ -163,7 +168,6 @@ class MucTab(ChatTab):
status=status.message,
show=status.show,
seconds=seconds)
asyncio.ensure_future(mam.on_tab_open(self))
def leave_room(self, message: str):
if self.joined:
@ -200,7 +204,7 @@ class MucTab(ChatTab):
'color_spec': spec_col,
'nick': self.own_nick,
}
self.add_message(InfoMessage(msg), typ=2)
self.add_message(MucOwnLeaveMessage(msg), typ=2)
self.disconnect()
muc.leave_groupchat(self.core.xmpp, self.jid.bare, self.own_nick,
message)
@ -567,7 +571,7 @@ class MucTab(ChatTab):
'nick_col': color,
'info_col': info_col,
}
self.add_message(InfoMessage(enable_message), typ=2)
self.add_message(MucOwnJoinMessage(enable_message), typ=2)
self.core.enable_private_tabs(self.jid.bare, enable_message)
if '201' in status_codes:
self.add_message(
@ -594,6 +598,7 @@ class MucTab(ChatTab):
},
),
typ=0)
mam.schedule_tab_open(self)
def handle_presence_joined(self, presence: Presence, status_codes) -> None:
"""
@ -651,7 +656,7 @@ class MucTab(ChatTab):
def on_non_member_kicked(self):
"""We have been kicked because the MUC is members-only"""
self.add_message(
InfoMessage(
MucOwnLeaveMessage(
'You have been kicked because you '
'are not a member and the room is now members-only.'
),
@ -661,7 +666,7 @@ class MucTab(ChatTab):
def on_muc_shutdown(self):
"""We have been kicked because the MUC service is shutting down"""
self.add_message(
InfoMessage(
MucOwnLeaveMessage(
'You have been kicked because the'
' MUC service is shutting down.'
),
@ -759,6 +764,7 @@ class MucTab(ChatTab):
"""
When someone is banned from a muc
"""
cls = InfoMessage
self.users.remove(user)
by = presence.xml.find('{%s}x/{%s}item/{%s}actor' %
(NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
@ -774,6 +780,7 @@ class MucTab(ChatTab):
char_kick = theme.CHAR_KICK
if from_nick == self.own_nick: # we are banned
cls = MucOwnLeaveMessage
if by:
kick_msg = ('\x191}%(spec)s \x193}You\x19%(info_col)s}'
' have been banned by \x194}%(by)s') % {
@ -834,12 +841,13 @@ class MucTab(ChatTab):
'reason': reason.text,
'info_col': info_col
}
self.add_message(InfoMessage(kick_msg), typ=2)
self.add_message(cls(kick_msg), typ=2)
def on_user_kicked(self, presence, user, from_nick):
"""
When someone is kicked from a muc
"""
cls = InfoMessage
self.users.remove(user)
actor_elem = presence.xml.find('{%s}x/{%s}item/{%s}actor' %
(NS_MUC_USER, NS_MUC_USER, NS_MUC_USER))
@ -852,6 +860,7 @@ class MucTab(ChatTab):
if actor_elem is not None:
by = actor_elem.get('nick') or actor_elem.get('jid')
if from_nick == self.own_nick: # we are kicked
cls = MucOwnLeaveMessage
if by:
kick_msg = ('\x191}%(spec)s \x193}You\x19'
'%(info_col)s} have been kicked'
@ -912,7 +921,7 @@ class MucTab(ChatTab):
'reason': reason.text,
'info_col': info_col
}
self.add_message(InfoMessage(kick_msg), typ=2)
self.add_message(cls(kick_msg), typ=2)
def on_user_leave_groupchat(self,
user: User,

View file

@ -46,8 +46,6 @@ class PrivateTab(OneToOneTab):
def __init__(self, core, jid, nick):
OneToOneTab.__init__(self, core, jid)
self.own_nick = nick
self.text_win = windows.TextWin()
self._text_buffer.add_window(self.text_win)
self.info_header = windows.PrivateInfoWin()
self.input = windows.MessageInput()
# keys

View file

@ -12,6 +12,7 @@ import logging
log = logging.getLogger(__name__)
from typing import (
cast,
Dict,
List,
Optional,
@ -19,9 +20,15 @@ from typing import (
Tuple,
Union,
)
from dataclasses import dataclass
from datetime import datetime
from poezio.config import config
from poezio.ui.types import Message, BaseMessage
from poezio.ui.types import (
BaseMessage,
Message,
MucOwnJoinMessage,
MucOwnLeaveMessage,
)
if TYPE_CHECKING:
from poezio.windows.text_win import TextWin
@ -35,6 +42,15 @@ class AckError(Exception):
pass
@dataclass
class HistoryGap:
"""Class representing a period of non-presence inside a MUC"""
leave_message: Optional[BaseMessage]
join_message: Optional[BaseMessage]
last_timestamp_before_leave: Optional[datetime]
first_timestamp_after_join: Optional[datetime]
class TextBuffer:
"""
This class just keep trace of messages, in a list with various
@ -44,7 +60,7 @@ class TextBuffer:
def __init__(self, messages_nb_limit: Optional[int] = None) -> None:
if messages_nb_limit is None:
messages_nb_limit = config.get('max_messages_in_memory')
messages_nb_limit = cast(int, config.get('max_messages_in_memory'))
self._messages_nb_limit = messages_nb_limit # type: int
# Message objects
self.messages = [] # type: List[BaseMessage]
@ -58,6 +74,99 @@ class TextBuffer:
def add_window(self, win) -> None:
self._windows.append(win)
def find_last_gap_muc(self) -> Optional[HistoryGap]:
"""Find the last known history gap contained in buffer"""
leave = None # type:Optional[Tuple[int, BaseMessage]]
join = None # type:Optional[Tuple[int, BaseMessage]]
for i, item in enumerate(reversed(self.messages)):
if isinstance(item, MucOwnLeaveMessage):
leave = (len(self.messages) - i - 1, item)
break
elif join and isinstance(item, MucOwnJoinMessage):
leave = (len(self.messages) - i - 1, item)
break
if isinstance(item, MucOwnJoinMessage):
join = (len(self.messages) - i - 1, item)
last_timestamp = None
first_timestamp = datetime.now()
# Identify the special case when we got disconnected from a chatroom
# without receiving or sending the relevant presence, therefore only
# having two joins with no leave, and messages in the middle.
if leave and join and isinstance(leave[1], MucOwnJoinMessage):
for i in range(join[0] - 1, leave[0], - 1):
if isinstance(self.messages[i], Message):
leave = (
i,
self.messages[i]
)
last_timestamp = self.messages[i].time
break
# If we have a normal gap but messages inbetween, it probably
# already has history, so abort there without returning it.
if join and leave:
for i in range(leave[0] + 1, join[0], 1):
if isinstance(self.messages[i], Message):
return None
elif not (join or leave):
return None
# If a leave message is found, get the last Message timestamp
# before it.
if leave is None:
leave_msg = None
elif last_timestamp is None:
leave_msg = leave[1]
for i in range(leave[0], 0, -1):
if isinstance(self.messages[i], Message):
last_timestamp = self.messages[i].time
break
else:
leave_msg = leave[1]
# If a join message is found, get the first Message timestamp
# after it, or the current time.
if join is None:
join_msg = None
else:
join_msg = join[1]
for i in range(join[0], len(self.messages)):
msg = self.messages[i]
if isinstance(msg, Message) and msg.time < first_timestamp:
first_timestamp = msg.time
break
return HistoryGap(
leave_message=leave_msg,
join_message=join_msg,
last_timestamp_before_leave=last_timestamp,
first_timestamp_after_join=first_timestamp,
)
def get_gap_index(self, gap: HistoryGap) -> Optional[int]:
"""Find the first index to insert into inside a gap"""
if gap.leave_message is None:
return 0
for i, msg in enumerate(self.messages):
if msg is gap.leave_message:
return i + 1
return None
def add_history_messages(self, messages: List[BaseMessage], gap: Optional[HistoryGap] = None) -> None:
"""Insert history messages at their correct place """
index = 0
new_index = None
if gap is not None:
new_index = self.get_gap_index(gap)
if new_index is None: # Not sure what happened, abort
return
index = new_index
for message in messages:
self.messages.insert(index, message)
index += 1
log.debug('inserted message: %s', message)
for window in self._windows: # make the associated windows
window.rebuild_everything(self)
@property
def last_message(self) -> Optional[BaseMessage]:
return self.messages[-1] if self.messages else None
@ -72,8 +181,8 @@ class TextBuffer:
self.messages.pop(0)
ret_val = 0
show_timestamps = config.get('show_timestamps')
nick_size = config.get('max_nick_length')
show_timestamps = cast(bool, config.get('show_timestamps'))
nick_size = cast(int, config.get('max_nick_length'))
for window in self._windows: # make the associated windows
# build the lines from the new message
nb = window.build_new_message(
@ -82,8 +191,7 @@ class TextBuffer:
nick_size=nick_size)
if ret_val == 0:
ret_val = nb
top = isinstance(msg, Message) and msg.top
if window.pos != 0 and top is False:
if window.pos != 0:
window.scroll_up(nb)
return min(ret_val, 1)
@ -197,6 +305,13 @@ class TextBuffer:
def del_window(self, win) -> None:
self._windows.remove(win)
def find_last_message(self) -> Optional[Message]:
"""Find the last real message received in this buffer"""
for message in reversed(self.messages):
if isinstance(message, Message):
return message
return None
def __del__(self):
size = len(self.messages)
log.debug('** Deleting %s messages from textbuffer', size)

View file

@ -94,8 +94,6 @@ def build_message(msg: Message, width: int, timestamp: bool, nick_size: int = 10
offset = msg.compute_offset(timestamp, nick_size)
lines = poopt.cut_text(txt, width - offset - 1)
generated_lines = generate_lines(lines, msg, default_color='')
if msg.top:
generated_lines.reverse()
return generated_lines

View file

@ -12,6 +12,7 @@ from poezio.ui.consts import (
)
class BaseMessage:
__slots__ = ('txt', 'time', 'identifier')
@ -27,12 +28,24 @@ class BaseMessage:
return SHORT_FORMAT_LENGTH + 1
class EndOfArchive(BaseMessage):
"""Marker added to a buffer when we reach the end of a MAM archive"""
class InfoMessage(BaseMessage):
def __init__(self, txt: str, identifier: str = '', time: Optional[datetime] = None):
txt = ('\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT)) + txt
super().__init__(txt=txt, identifier=identifier, time=time)
class MucOwnLeaveMessage(InfoMessage):
"""Status message displayed on our room leave/kick/ban"""
class MucOwnJoinMessage(InfoMessage):
"""Status message displayed on our room join"""
class XMLLog(BaseMessage):
"""XML Log message"""
__slots__ = ('txt', 'time', 'identifier', 'incoming')

View file

@ -95,14 +95,10 @@ class TextWin(Win):
lines = build_lines(
message, self.width, timestamp=timestamp, nick_size=nick_size
)
if isinstance(message, Message) and message.top:
for line in lines:
self.built_lines.insert(0, line)
if self.lock:
self.lock_buffer.extend(lines)
else:
if self.lock:
self.lock_buffer.extend(lines)
else:
self.built_lines.extend(lines)
self.built_lines.extend(lines)
if not lines or not lines[0]:
return 0
if isinstance(message, Message) and message.highlight:

198
test/test_text_buffer.py Normal file
View file

@ -0,0 +1,198 @@
"""
Tests for the TextBuffer class
"""
from pytest import fixture
from poezio.text_buffer import (
TextBuffer,
HistoryGap,
)
from poezio.ui.types import (
Message,
BaseMessage,
MucOwnJoinMessage,
MucOwnLeaveMessage,
)
@fixture(scope='function')
def buf2048():
return TextBuffer(2048)
@fixture(scope='function')
def msgs_nojoin():
msg1 = Message('1', 'q')
msg2 = Message('2', 's')
leave = MucOwnLeaveMessage('leave')
return [msg1, msg2, leave]
@fixture(scope='function')
def msgs_noleave():
join = MucOwnJoinMessage('join')
msg3 = Message('3', 'd')
msg4 = Message('4', 'f')
return [join, msg3, msg4]
@fixture(scope='function')
def msgs_doublejoin():
join = MucOwnJoinMessage('join')
msg1 = Message('1', 'd')
msg2 = Message('2', 'f')
join2 = MucOwnJoinMessage('join')
return [join, msg1, msg2, join2]
def test_last_message(buf2048):
msg = BaseMessage('toto')
buf2048.add_message(BaseMessage('titi'))
buf2048.add_message(msg)
assert buf2048.last_message is msg
def test_message_nb_limit():
buf = TextBuffer(5)
for i in range(10):
buf.add_message(BaseMessage("%s" % i))
assert len(buf.messages) == 5
def test_find_gap(buf2048, msgs_noleave):
msg1 = Message('1', 'q')
msg2 = Message('2', 's')
leave = MucOwnLeaveMessage('leave')
join = MucOwnJoinMessage('join')
msg3 = Message('3', 'd')
msg4 = Message('4', 'f')
msgs = [msg1, msg2, leave, join, msg3, msg4]
for msg in msgs:
buf2048.add_message(msg)
gap = buf2048.find_last_gap_muc()
assert gap.leave_message == leave
assert gap.join_message == join
assert gap.last_timestamp_before_leave == msg2.time
assert gap.first_timestamp_after_join == msg3.time
def test_find_gap_doublejoin(buf2048, msgs_doublejoin):
for msg in msgs_doublejoin:
buf2048.add_message(msg)
gap = buf2048.find_last_gap_muc()
assert gap.leave_message == msgs_doublejoin[2]
assert gap.join_message == msgs_doublejoin[3]
def test_find_gap_doublejoin_no_msg(buf2048):
join1 = MucOwnJoinMessage('join')
join2 = MucOwnJoinMessage('join')
for msg in [join1, join2]:
buf2048.add_message(msg)
gap = buf2048.find_last_gap_muc()
assert gap.leave_message is join1
assert gap.join_message is join2
def test_find_gap_already_filled(buf2048):
msg1 = Message('1', 'q')
msg2 = Message('2', 's')
leave = MucOwnLeaveMessage('leave')
msg5 = Message('5', 'g')
msg6 = Message('6', 'h')
join = MucOwnJoinMessage('join')
msg3 = Message('3', 'd')
msg4 = Message('4', 'f')
msgs = [msg1, msg2, leave, msg5, msg6, join, msg3, msg4]
for msg in msgs:
buf2048.add_message(msg)
assert buf2048.find_last_gap_muc() is None
def test_find_gap_noleave(buf2048, msgs_noleave):
for msg in msgs_noleave:
buf2048.add_message(msg)
gap = buf2048.find_last_gap_muc()
assert gap.leave_message is None
assert gap.last_timestamp_before_leave is None
assert gap.join_message == msgs_noleave[0]
assert gap.first_timestamp_after_join == msgs_noleave[1].time
def test_find_gap_nojoin(buf2048, msgs_nojoin):
for msg in msgs_nojoin:
buf2048.add_message(msg)
gap = buf2048.find_last_gap_muc()
assert gap.leave_message == msgs_nojoin[-1]
assert gap.join_message is None
assert gap.last_timestamp_before_leave == msgs_nojoin[1].time
def test_get_gap_index(buf2048):
msg1 = Message('1', 'q')
msg2 = Message('2', 's')
leave = MucOwnLeaveMessage('leave')
join = MucOwnJoinMessage('join')
msg3 = Message('3', 'd')
msg4 = Message('4', 'f')
msgs = [msg1, msg2, leave, join, msg3, msg4]
for msg in msgs:
buf2048.add_message(msg)
gap = buf2048.find_last_gap_muc()
assert buf2048.get_gap_index(gap) == 3
def test_get_gap_index_doublejoin(buf2048, msgs_doublejoin):
for msg in msgs_doublejoin:
buf2048.add_message(msg)
gap = buf2048.find_last_gap_muc()
assert buf2048.get_gap_index(gap) == 3
def test_get_gap_index_doublejoin_no_msg(buf2048):
join1 = MucOwnJoinMessage('join')
join2 = MucOwnJoinMessage('join')
for msg in [join1, join2]:
buf2048.add_message(msg)
gap = buf2048.find_last_gap_muc()
assert buf2048.get_gap_index(gap) == 1
def test_get_gap_index_nojoin(buf2048, msgs_nojoin):
for msg in msgs_nojoin:
buf2048.add_message(msg)
gap = buf2048.find_last_gap_muc()
assert buf2048.get_gap_index(gap) == 3
def test_get_gap_index_noleave(buf2048, msgs_noleave):
for msg in msgs_noleave:
buf2048.add_message(msg)
gap = buf2048.find_last_gap_muc()
assert buf2048.get_gap_index(gap) == 0
def test_add_history_messages(buf2048):
msg1 = Message('1', 'q')
msg2 = Message('2', 's')
leave = MucOwnLeaveMessage('leave')
join = MucOwnJoinMessage('join')
msg3 = Message('3', 'd')
msg4 = Message('4', 'f')
msgs = [msg1, msg2, leave, join, msg3, msg4]
for msg in msgs:
buf2048.add_message(msg)
msg5 = Message('5', 'g')
msg6 = Message('6', 'h')
gap = buf2048.find_last_gap_muc()
buf2048.add_history_messages([msg5, msg6], gap=gap)
assert buf2048.messages == [msg1, msg2, leave, msg5, msg6, join, msg3, msg4]
def test_add_history_empty(buf2048):
msg1 = Message('1', 'q')
msg2 = Message('2', 's')
msg3 = Message('3', 'd')
msg4 = Message('4', 'f')
buf2048.add_message(msg1)
buf2048.add_history_messages([msg2, msg3, msg4])
assert buf2048.messages == [msg2, msg3, msg4, msg1]