text_buffer.py 11 KB
Newer Older
1 2
"""
Define the TextBuffer class
3 4 5 6 7

A text buffer contains a list of intermediate representations of messages
(not xml stanzas, but neither the Lines used in windows.py.

Each text buffer can be linked to multiple windows, that will be rendered
Link Mauve's avatar
Link Mauve committed
8
independently by their TextWins.
9 10
"""

11 12 13
import logging
log = logging.getLogger(__name__)

mathieui's avatar
mathieui committed
14
from typing import (
mathieui's avatar
mathieui committed
15
    cast,
mathieui's avatar
mathieui committed
16 17 18 19 20 21 22
    Dict,
    List,
    Optional,
    TYPE_CHECKING,
    Tuple,
    Union,
)
mathieui's avatar
mathieui committed
23
from dataclasses import dataclass
24
from datetime import datetime
mathieui's avatar
mathieui committed
25
from poezio.config import config
mathieui's avatar
mathieui committed
26 27 28 29 30 31
from poezio.ui.types import (
    BaseMessage,
    Message,
    MucOwnJoinMessage,
    MucOwnLeaveMessage,
)
32

mathieui's avatar
mathieui committed
33 34
if TYPE_CHECKING:
    from poezio.windows.text_win import TextWin
35
    from poezio.user import User
mathieui's avatar
mathieui committed
36 37


mathieui's avatar
mathieui committed
38 39
class CorrectionError(Exception):
    pass
mathieui's avatar
mathieui committed
40

mathieui's avatar
mathieui committed
41

42 43 44
class AckError(Exception):
    pass

mathieui's avatar
mathieui committed
45

mathieui's avatar
mathieui committed
46 47 48
@dataclass
class HistoryGap:
    """Class representing a period of non-presence inside a MUC"""
49 50
    leave_message: Optional[BaseMessage]
    join_message: Optional[BaseMessage]
mathieui's avatar
mathieui committed
51 52 53 54
    last_timestamp_before_leave: Optional[datetime]
    first_timestamp_after_join: Optional[datetime]


55
class TextBuffer:
56 57
    """
    This class just keep trace of messages, in a list with various
58
    information and attributes.
59
    """
mathieui's avatar
mathieui committed
60

61
    def __init__(self, messages_nb_limit: Optional[int] = None) -> None:
62 63

        if messages_nb_limit is None:
mathieui's avatar
mathieui committed
64
            messages_nb_limit = cast(int, config.get('max_messages_in_memory'))
65
        self._messages_nb_limit = messages_nb_limit  # type: int
66
        # Message objects
67
        self.messages = []  # type: List[BaseMessage]
68
        # COMPAT: Correction id -> Original message id.
Maxime Buquet's avatar
Maxime Buquet committed
69
        self.correction_ids = {}  # type: Dict[str, str]
70
        # we keep track of one or more windows
71
        # so we can pass the new messages to them, as they are added, so
72
        # they (the windows) can build the lines from the new message
mathieui's avatar
mathieui committed
73
        self._windows = []  # type: List[TextWin]
74

75
    def add_window(self, win) -> None:
76
        self._windows.append(win)
77

mathieui's avatar
mathieui committed
78 79
    def find_last_gap_muc(self) -> Optional[HistoryGap]:
        """Find the last known history gap contained in buffer"""
mathieui's avatar
mathieui committed
80 81
        leave = None  # type:Optional[Tuple[int, BaseMessage]]
        join = None  # type:Optional[Tuple[int, BaseMessage]]
mathieui's avatar
mathieui committed
82 83
        for i, item in enumerate(reversed(self.messages)):
            if isinstance(item, MucOwnLeaveMessage):
84 85 86 87
                leave = (len(self.messages) - i - 1, item)
                break
            elif join and isinstance(item, MucOwnJoinMessage):
                leave = (len(self.messages) - i - 1, item)
mathieui's avatar
mathieui committed
88 89
                break
            if isinstance(item, MucOwnJoinMessage):
90 91 92 93 94 95 96 97
                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.
mathieui's avatar
mathieui committed
98
        if leave and join and isinstance(leave[1], MucOwnJoinMessage):
99 100 101 102 103 104 105 106 107 108 109 110
            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):
mathieui's avatar
mathieui committed
111 112 113 114
                if isinstance(self.messages[i], Message):
                    return None
        elif not (join or leave):
            return None
115 116 117

        # If a leave message is found, get the last Message timestamp
        # before it.
mathieui's avatar
mathieui committed
118
        if leave is None:
119 120
            leave_msg = None
        elif last_timestamp is None:
mathieui's avatar
mathieui committed
121
            leave_msg = leave[1]
122
            for i in range(leave[0], 0, -1):
mathieui's avatar
mathieui committed
123 124 125
                if isinstance(self.messages[i], Message):
                    last_timestamp = self.messages[i].time
                    break
126 127 128 129
        else:
            leave_msg = leave[1]
        # If a join message is found, get the first Message timestamp
        # after it, or the current time.
mathieui's avatar
mathieui committed
130 131 132 133
        if join is None:
            join_msg = None
        else:
            join_msg = join[1]
134
            for i in range(join[0], len(self.messages)):
mathieui's avatar
mathieui committed
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154
                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

mathieui's avatar
mathieui committed
155
    def add_history_messages(self, messages: List[BaseMessage], gap: Optional[HistoryGap] = None) -> None:
mathieui's avatar
mathieui committed
156 157
        """Insert history messages at their correct place """
        index = 0
mathieui's avatar
mathieui committed
158
        new_index = None
mathieui's avatar
mathieui committed
159
        if gap is not None:
mathieui's avatar
mathieui committed
160 161 162 163
            new_index = self.get_gap_index(gap)
            if new_index is None:  # Not sure what happened, abort
                return
            index = new_index
mathieui's avatar
mathieui committed
164 165 166 167 168 169 170
        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)

171
    @property
mathieui's avatar
mathieui committed
172
    def last_message(self) -> Optional[BaseMessage]:
173 174
        return self.messages[-1] if self.messages else None

175
    def add_message(self, msg: BaseMessage):
176 177 178
        """
        Create a message and add it to the text buffer
        """
179
        self.messages.append(msg)
180

181
        while len(self.messages) > self._messages_nb_limit:
182
            self.messages.pop(0)
183

184
        ret_val = 0
mathieui's avatar
mathieui committed
185 186
        show_timestamps = cast(bool, config.get('show_timestamps'))
        nick_size = cast(int, config.get('max_nick_length'))
mathieui's avatar
mathieui committed
187 188
        for window in self._windows:  # make the associated windows
            # build the lines from the new message
Madhur Garg's avatar
Madhur Garg committed
189 190 191 192 193 194
            nb = window.build_new_message(
                msg,
                timestamp=show_timestamps,
                nick_size=nick_size)
            if ret_val == 0:
                ret_val = nb
mathieui's avatar
mathieui committed
195
            if window.pos != 0:
Madhur Garg's avatar
Madhur Garg committed
196
                window.scroll_up(nb)
197

198
        return min(ret_val, 1)
199

200
    def _find_message(self, orig_id: str) -> Tuple[str, int]:
201 202 203
        """
        Find a message in the text buffer from its message id
        """
Maxime Buquet's avatar
Maxime Buquet committed
204 205 206
        # 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.
207
        orig_id = self.correction_ids.get(orig_id, orig_id)
Maxime Buquet's avatar
Maxime Buquet committed
208

mathieui's avatar
mathieui committed
209
        for i in range(len(self.messages) - 1, -1, -1):
210
            msg = self.messages[i]
211 212 213
            if msg.identifier == orig_id:
                return (orig_id, i)
        return (orig_id, -1)
214

215
    def ack_message(self, old_id: str, jid: str) -> Union[None, bool, Message]:
216
        """Mark a message as acked"""
217
        return self._edit_ack(1, old_id, jid)
218

Link Mauve's avatar
Link Mauve committed
219 220
    def nack_message(self, error: str, old_id: str,
                     jid: str) -> Union[None, bool, Message]:
221
        """Mark a message as errored"""
222
        return self._edit_ack(-1, old_id, jid, append=error)
223

224
    def _edit_ack(self, value: int, old_id: str, jid: str,
Link Mauve's avatar
Link Mauve committed
225
                  append: str = '') -> Union[None, bool, Message]:
226
        """
227 228
        Edit the ack status of a message, and optionally
        append some text.
229
        """
Maxime Buquet's avatar
Maxime Buquet committed
230
        _, i = self._find_message(old_id)
231
        if i == -1:
232
            return None
233
        msg = self.messages[i]
mathieui's avatar
mathieui committed
234 235
        if not isinstance(msg, Message):
            return None
236 237
        if msg.ack == 1:  # Message was already acked
            return False
238
        if msg.jid != jid:
mathieui's avatar
mathieui committed
239 240
            raise AckError('Wrong JID for message id %s (was %s, expected %s)'
                           % (old_id, msg.jid, jid))
241

242
        msg.ack = value
243
        if append:
244 245
            msg.txt += append
        return msg
246

mathieui's avatar
mathieui committed
247
    def modify_message(self,
248
                       txt: str,
249
                       orig_id: str,
250 251 252
                       new_id: str,
                       highlight: bool = False,
                       time: Optional[datetime] = None,
253
                       user: Optional['User'] = None,
254
                       jid: Optional[str] = None) -> Message:
255 256
        """
        Correct a message in a text buffer.
Maxime Buquet's avatar
Maxime Buquet committed
257 258 259 260 261

        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.
262 263
        """

264
        orig_id, i = self._find_message(orig_id)
265 266

        if i == -1:
mathieui's avatar
mathieui committed
267 268
            log.debug(
                'Message %s not found in text_buffer, abort replacement.',
269
                orig_id)
270 271 272
            raise CorrectionError("nothing to replace")

        msg = self.messages[i]
mathieui's avatar
mathieui committed
273 274
        if not isinstance(msg, Message):
            raise CorrectionError('Wrong message type')
275 276
        if msg.user and msg.user is not user:
            raise CorrectionError("Different users")
277
        elif msg.delayed:
278 279 280 281 282
            raise CorrectionError("Delayed message")
        elif not msg.user and (msg.jid is None or jid is None):
            raise CorrectionError('Could not check the '
                                  'identity of the sender')
        elif not msg.user and msg.jid != jid:
mathieui's avatar
mathieui committed
283 284
            raise CorrectionError(
                'Messages %s and %s have not been '
285
                'sent by the same fullJID' % (orig_id, new_id))
286 287 288

        if not time:
            time = msg.time
Maxime Buquet's avatar
Maxime Buquet committed
289

290
        self.correction_ids[new_id] = orig_id
mathieui's avatar
mathieui committed
291
        message = Message(
292 293 294 295 296 297
            txt=txt,
            time=time,
            nickname=msg.nickname,
            nick_color=msg.nick_color,
            user=msg.user,
            identifier=orig_id,
mathieui's avatar
mathieui committed
298 299 300 301
            highlight=highlight,
            old_message=msg,
            revisions=msg.revisions + 1,
            jid=jid)
302
        self.messages[i] = message
303
        log.debug('Replacing message %s with %s.', orig_id, new_id)
304
        return message
305

306
    def del_window(self, win) -> None:
307
        self._windows.remove(win)
308

309 310 311 312 313 314 315
    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

316
    def __del__(self):
317 318
        size = len(self.messages)
        log.debug('** Deleting %s messages from textbuffer', size)