summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--poezio/common.py17
-rw-r--r--poezio/core/core.py11
-rw-r--r--poezio/mam.py276
-rw-r--r--poezio/tabs/basetabs.py9
-rw-r--r--poezio/tabs/conversationtab.py2
-rw-r--r--poezio/tabs/muctab.py35
-rw-r--r--poezio/tabs/privatetab.py2
-rw-r--r--poezio/text_buffer.py127
-rw-r--r--poezio/ui/render.py2
-rw-r--r--poezio/ui/types.py13
-rw-r--r--poezio/windows/text_win.py10
-rw-r--r--test/test_text_buffer.py198
12 files changed, 547 insertions, 155 deletions
diff --git a/poezio/common.py b/poezio/common.py
index 7cddc306..98870dda 100644
--- a/poezio/common.py
+++ b/poezio/common.py
@@ -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)
diff --git a/poezio/core/core.py b/poezio/core/core.py
index 8ac88dd4..973c9103 100644
--- a/poezio/core/core.py
+++ b/poezio/core/core.py
@@ -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
diff --git a/poezio/mam.py b/poezio/mam.py
index 50dad4a3..371b34dd 100644
--- a/poezio/mam.py
+++ b/poezio/mam.py
@@ -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)
+ if amount > 0:
+ args['rsm'] = {'max': amount}
+ args['start'] = start
+ args['end'] = end
+ return core.xmpp['xep_0313'].retrieve(**args)
- return results
+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 add_messages_to_buffer(tab, top: bool, results, amount: int) -> bool:
- """Prepends or appends messages to the tab text_buffer"""
+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')
-
- if amount >= 100:
- amount = 99
+ end = to_utc(end)
+ end_str = datetime.strftime(end, '%Y-%m-%dT%H:%M:%SZ')
- groupchat = isinstance(tab, tabs.MucTab)
+ start_str = None
+ if start is not None:
+ start = to_utc(start)
+ start_str = datetime.strftime(start, '%Y-%m-%dT%H:%M:%SZ')
- 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
+
+
+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) -> None:
+
+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
diff --git a/poezio/tabs/basetabs.py b/poezio/tabs/basetabs.py
index fbb0c4cf..a42ee41b 100644
--- a/poezio/tabs/basetabs.py
+++ b/poezio/tabs/basetabs.py
@@ -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):
diff --git a/poezio/tabs/conversationtab.py b/poezio/tabs/conversationtab.py
index 70005f0f..5950e4cb 100644
--- a/poezio/tabs/conversationtab.py
+++ b/poezio/tabs/conversationtab.py
@@ -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
diff --git a/poezio/tabs/muctab.py b/poezio/tabs/muctab.py
index d16ac58a..751509a7 100644
--- a/poezio/tabs/muctab.py
+++ b/poezio/tabs/muctab.py
@@ -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,
diff --git a/poezio/tabs/privatetab.py b/poezio/tabs/privatetab.py
index b43294a1..cd2123f3 100644
--- a/poezio/tabs/privatetab.py
+++ b/poezio/tabs/privatetab.py
@@ -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
diff --git a/poezio/text_buffer.py b/poezio/text_buffer.py
index 3b3ac051..ff853a67 100644
--- a/poezio/text_buffer.py
+++ b/poezio/text_buffer.py
@@ -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)
diff --git a/poezio/ui/render.py b/poezio/ui/render.py
index a431b4e7..c85d3cc5 100644
--- a/poezio/ui/render.py
+++ b/poezio/ui/render.py
@@ -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
diff --git a/poezio/ui/types.py b/poezio/ui/types.py
index ae72b6b9..15117275 100644
--- a/poezio/ui/types.py
+++ b/poezio/ui/types.py
@@ -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')
diff --git a/poezio/windows/text_win.py b/poezio/windows/text_win.py
index 9e6641f7..2cb75271 100644
--- a/poezio/windows/text_win.py
+++ b/poezio/windows/text_win.py
@@ -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:
diff --git a/test/test_text_buffer.py b/test/test_text_buffer.py
new file mode 100644
index 00000000..65c6d9bf
--- /dev/null
+++ b/test/test_text_buffer.py
@@ -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]
+