comparison sat_frontends/primitivus/chat.py @ 2562:26edcf3a30eb

core, setup: huge cleaning: - moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention - move twisted directory to root - removed all hacks from setup.py, and added missing dependencies, it is now clean - use https URL for website in setup.py - removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed - renamed sat.sh to sat and fixed its installation - added python_requires to specify Python version needed - replaced glib2reactor which use deprecated code by gtk3reactor sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author Goffi <goffi@goffi.org>
date Mon, 02 Apr 2018 19:44:50 +0200
parents frontends/src/primitivus/chat.py@0046283a285d
children 56f94936df1e
comparison
equal deleted inserted replaced
2561:bd30dc3ffe5a 2562:26edcf3a30eb
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # Primitivus: a SAT frontend
5 # Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from sat.core.i18n import _
21 from sat.core import log as logging
22 log = logging.getLogger(__name__)
23 import urwid
24 from urwid_satext import sat_widgets
25 from sat_frontends.quick_frontend import quick_widgets
26 from sat_frontends.quick_frontend import quick_chat
27 from sat_frontends.quick_frontend import quick_games
28 from sat_frontends.primitivus import game_tarot
29 from sat_frontends.primitivus.constants import Const as C
30 from sat_frontends.primitivus.keys import action_key_map as a_key
31 from sat_frontends.primitivus.widget import PrimitivusWidget
32 from sat_frontends.primitivus.contact_list import ContactList
33 from functools import total_ordering
34 import bisect
35
36
37 OCCUPANTS_FOOTER = _(u"{} occupants")
38
39 class MessageWidget(urwid.WidgetWrap):
40
41 def __init__(self, mess_data):
42 """
43 @param mess_data(quick_chat.Message, None): message data
44 None: used only for non text widgets (e.g.: focus separator)
45 """
46 self.mess_data = mess_data
47 mess_data.widgets.add(self)
48 super(MessageWidget, self).__init__(urwid.Text(self.markup))
49
50 @property
51 def markup(self):
52 return self._generateInfoMarkup() if self.mess_data.type == C.MESS_TYPE_INFO else self._generateMarkup()
53
54 @property
55 def info_type(self):
56 return self.mess_data.info_type
57
58 @property
59 def parent(self):
60 return self.mess_data.parent
61
62 @property
63 def message(self):
64 """Return currently displayed message"""
65 return self.mess_data.main_message
66
67 @message.setter
68 def message(self, value):
69 self.mess_data.message = {'':value}
70 self.redraw()
71
72 @property
73 def type(self):
74 try:
75 return self.mess_data.type
76 except AttributeError:
77 return C.MESS_TYPE_INFO
78
79 def redraw(self):
80 self._w.set_text(self.markup)
81 self.mess_data.parent.host.redraw() # FIXME: should not be necessary
82
83 def selectable(self):
84 return True
85
86 def keypress(self, size, key):
87 return key
88
89 def get_cursor_coords(self, size):
90 return 0, 0
91
92 def render(self, size, focus=False):
93 # Text widget doesn't render cursor, but we want one
94 # so we add it here
95 canvas = urwid.CompositeCanvas(self._w.render(size, focus))
96 if focus:
97 canvas.set_cursor(self.get_cursor_coords(size))
98 return canvas
99
100 def _generateInfoMarkup(self):
101 return ('info_msg', self.message)
102
103 def _generateMarkup(self):
104 """Generate text markup according to message data and Widget options"""
105 markup = []
106 d = self.mess_data
107 mention = d.mention
108
109 # message status
110 if d.status is None:
111 markup.append(u' ')
112 elif d.status == "delivered":
113 markup.append(('msg_status_received', u'✔'))
114 else:
115 log.warning(u"Unknown status: {}".format(d.status))
116
117 # timestamp
118 if self.parent.show_timestamp:
119 attr = 'msg_mention' if mention else 'date'
120 markup.append((attr, u"[{}]".format(d.time_text)))
121 else:
122 if mention:
123 markup.append(('msg_mention', '[*]'))
124
125 # nickname
126 if self.parent.show_short_nick:
127 markup.append(('my_nick' if d.own_mess else 'other_nick', "**" if d.own_mess else "*"))
128 else:
129 markup.append(('my_nick' if d.own_mess else 'other_nick', u"[{}] ".format(d.nick or '')))
130
131 msg = self.message # needed to generate self.selected_lang
132
133 if d.selected_lang:
134 markup.append(("msg_lang", u"[{}] ".format(d.selected_lang)))
135
136 # message body
137 markup.append(msg)
138
139 return markup
140
141 # events
142 def update(self, update_dict=None):
143 """update all the linked message widgets
144
145 @param update_dict(dict, None): key=attribute updated value=new_value
146 """
147 self.redraw()
148
149 @total_ordering
150 class OccupantWidget(urwid.WidgetWrap):
151
152 def __init__(self, occupant_data):
153 self.occupant_data = occupant_data
154 occupant_data.widgets.add(self)
155 markup = self._generateMarkup()
156 text = sat_widgets.ClickableText(markup)
157 urwid.connect_signal(text,
158 'click',
159 self.occupant_data.parent._occupantsClicked,
160 user_args=[self.occupant_data])
161 super(OccupantWidget, self).__init__(text)
162
163 def __eq__(self, other):
164 if other is None:
165 return False
166 return self.occupant_data.nick == other.occupant_data.nick
167
168 def __lt__(self, other):
169 return self.occupant_data.nick.lower() < other.occupant_data.nick.lower()
170
171 @property
172 def markup(self):
173 return self._generateMarkup()
174
175 @property
176 def parent(self):
177 return self.mess_data.parent
178
179 @property
180 def nick(self):
181 return self.occupant_data.nick
182
183 def redraw(self):
184 self._w.set_text(self.markup)
185 self.occupant_data.parent.host.redraw() # FIXME: should not be necessary
186
187 def selectable(self):
188 return True
189
190 def keypress(self, size, key):
191 return key
192
193 def get_cursor_coords(self, size):
194 return 0, 0
195
196 def render(self, size, focus=False):
197 # Text widget doesn't render cursor, but we want one
198 # so we add it here
199 canvas = urwid.CompositeCanvas(self._w.render(size, focus))
200 if focus:
201 canvas.set_cursor(self.get_cursor_coords(size))
202 return canvas
203
204 def _generateMarkup(self):
205 # TODO: role and affiliation are shown in a Q&D way
206 # should be more intuitive and themable
207 o = self.occupant_data
208 markup = []
209 markup.append(('info_msg', u'{}{} '.format(
210 o.role[0].upper(),
211 o.affiliation[0].upper(),
212 )))
213 markup.append(o.nick)
214 if o.state is not None:
215 markup.append(u' {}'.format(C.CHAT_STATE_ICON[o.state]))
216 return markup
217
218 # events
219 def update(self, update_dict=None):
220 self.redraw()
221
222
223 class OccupantsWidget(urwid.WidgetWrap):
224
225 def __init__(self, parent):
226 self.parent = parent
227 self.occupants_walker = urwid.SimpleListWalker([])
228 self.occupants_footer = urwid.Text('', align='center')
229 self.updateFooter()
230 occupants_widget = urwid.Frame(urwid.ListBox(self.occupants_walker), footer=self.occupants_footer)
231 super(OccupantsWidget, self).__init__(occupants_widget)
232 occupants_list = sorted(self.parent.occupants.keys(), key=lambda o:o.lower())
233 for occupant in occupants_list:
234 occupant_data = self.parent.occupants[occupant]
235 self.occupants_walker.append(OccupantWidget(occupant_data))
236
237 def updateFooter(self):
238 """update footer widget"""
239 txt = OCCUPANTS_FOOTER.format(len(self.parent.occupants))
240 self.occupants_footer.set_text(txt)
241
242 def getNicks(self, start=u''):
243 """Return nicks of all occupants
244
245 @param start(unicode): only return nicknames which start with this text
246 """
247 return [w.nick for w in self.occupants_walker if isinstance(w, OccupantWidget) and w.nick.startswith(start)]
248
249 def addUser(self, occupant_data):
250 """add a user to the list"""
251 bisect.insort(self.occupants_walker, OccupantWidget(occupant_data))
252 self.updateFooter()
253 self.parent.host.redraw() # FIXME: should not be necessary
254
255 def removeUser(self, occupant_data):
256 """remove a user from the list"""
257 for widget in occupant_data.widgets:
258 self.occupants_walker.remove(widget)
259 self.updateFooter()
260 self.parent.host.redraw() # FIXME: should not be necessary
261
262
263 class Chat(PrimitivusWidget, quick_chat.QuickChat):
264
265 def __init__(self, host, target, type_=C.CHAT_ONE2ONE, nick=None, occupants=None, subject=None, profiles=None):
266 quick_chat.QuickChat.__init__(self, host, target, type_, nick, occupants, subject, profiles=profiles)
267 self.filters = [] # list of filter callbacks to apply
268 self.mess_walker = urwid.SimpleListWalker([])
269 self.mess_widgets = urwid.ListBox(self.mess_walker)
270 self.chat_widget = urwid.Frame(self.mess_widgets)
271 self.chat_colums = urwid.Columns([('weight', 8, self.chat_widget)])
272 self.pile = urwid.Pile([self.chat_colums])
273 PrimitivusWidget.__init__(self, self.pile, self.target)
274
275 # we must adapt the behaviour with the type
276 if type_ == C.CHAT_GROUP:
277 if len(self.chat_colums.contents) == 1:
278 self.occupants_widget = OccupantsWidget(self)
279 self.occupants_panel = sat_widgets.VerticalSeparator(self.occupants_widget)
280 self._appendOccupantsPanel()
281 self.host.addListener('presence', self.presenceListener, [profiles])
282
283 # focus marker is a separator indicated last visible message before focus was lost
284 self.focus_marker = None # link to current marker
285 self.focus_marker_set = None # True if a new marker has been inserted
286 self.show_timestamp = True
287 self.show_short_nick = False
288 self.show_title = 1 # 0: clip title; 1: full title; 2: no title
289 self.postInit()
290
291 def keypress(self, size, key):
292 if key == a_key['OCCUPANTS_HIDE']: # user wants to (un)hide the occupants panel
293 if self.type == C.CHAT_GROUP:
294 widgets = [widget for (widget, options) in self.chat_colums.contents]
295 if self.occupants_panel in widgets:
296 self._removeOccupantsPanel()
297 else:
298 self._appendOccupantsPanel()
299 elif key == a_key['TIMESTAMP_HIDE']: # user wants to (un)hide timestamp
300 self.show_timestamp = not self.show_timestamp
301 self.redraw()
302 elif key == a_key['SHORT_NICKNAME']: # user wants to (not) use short nick
303 self.show_short_nick = not self.show_short_nick
304 self.redraw()
305 elif key == a_key['SUBJECT_SWITCH']: # user wants to (un)hide group's subject or change its apperance
306 if self.subject:
307 self.show_title = (self.show_title + 1) % 3
308 if self.show_title == 0:
309 self.setSubject(self.subject, 'clip')
310 elif self.show_title == 1:
311 self.setSubject(self.subject, 'space')
312 elif self.show_title == 2:
313 self.chat_widget.header = None
314 self._invalidate()
315 elif key == a_key['GOTO_BOTTOM']: # user wants to focus last message
316 self.mess_widgets.focus_position = len(self.mess_walker) - 1
317
318 return super(Chat, self).keypress(size, key)
319
320 def completion(self, text, completion_data):
321 """Completion method which complete nicknames in group chat
322
323 for params, see [sat_widgets.AdvancedEdit]
324 """
325 if self.type != C.CHAT_GROUP:
326 return text
327
328 space = text.rfind(" ")
329 start = text[space + 1:]
330 words = self.occupants_widget.getNicks(start)
331 if not words:
332 return text
333 try:
334 word_idx = words.index(completion_data['last_word']) + 1
335 except (KeyError, ValueError):
336 word_idx = 0
337 else:
338 if word_idx == len(words):
339 word_idx = 0
340 word = completion_data['last_word'] = words[word_idx]
341 return u"{}{}{}".format(text[:space + 1], word, ': ' if space < 0 else '')
342
343 def getMenu(self):
344 """Return Menu bar"""
345 menu = sat_widgets.Menu(self.host.loop)
346 if self.type == C.CHAT_GROUP:
347 self.host.addMenus(menu, C.MENU_ROOM, {'room_jid': self.target.bare})
348 game = _("Game")
349 menu.addMenu(game, "Tarot", self.onTarotRequest)
350 elif self.type == C.CHAT_ONE2ONE:
351 # FIXME: self.target is a bare jid, we need to check that
352 contact_list = self.host.contact_lists[self.profile]
353 if not self.target.resource:
354 full_jid = contact_list.getFullJid(self.target)
355 else:
356 full_jid = self.target
357 self.host.addMenus(menu, C.MENU_SINGLE, {'jid': full_jid})
358 return menu
359
360 def setFilter(self, args):
361 """set filtering of messages
362
363 @param args(list[unicode]): filters following syntax "[filter]=[value]"
364 empty list to clear all filters
365 only lang=XX is handled for now
366 """
367 del self.filters[:]
368 if args:
369 if args[0].startswith("lang="):
370 lang = args[0][5:].strip()
371 self.filters.append(lambda mess_data: lang in mess_data.message)
372
373 self.printMessages()
374
375 def presenceListener(self, entity, show, priority, statuses, profile):
376 """Update entity's presence status
377
378 @param entity (jid.JID): entity updated
379 @param show: availability
380 @param priority: resource's priority
381 @param statuses: dict of statuses
382 @param profile: %(doc_profile)s
383 """
384 # FIXME: disable for refactoring, need to be checked and re-enabled
385 return
386 # assert self.type == C.CHAT_GROUP
387 # if entity.bare != self.target:
388 # return
389 # self.update(entity)
390
391 def createMessage(self, message):
392 self.appendMessage(message)
393
394 def _user_moved(self, message):
395 """return true if message is a user left/joined message
396
397 @param message(quick_chat.Message): message to add
398 """
399 if message.type != C.MESS_TYPE_INFO:
400 return False
401 try:
402 info_type = message.extra['info_type']
403 except KeyError:
404 return False
405 else:
406 return info_type in quick_chat.ROOM_USER_MOVED
407
408 def _scrollDown(self):
409 """scroll down message only if we are already at the bottom (minus 1)"""
410 current_focus = self.mess_widgets.focus_position
411 bottom = len(self.mess_walker) - 1
412 if current_focus == bottom - 1:
413 self.mess_widgets.focus_position = bottom # scroll down
414 self.host.redraw() # FIXME: should not be necessary
415
416 def appendMessage(self, message):
417 """Create a MessageWidget and append it
418
419 Can merge messages together is desirable (e.g.: multiple joined/leave)
420 @param message(quick_chat.Message): message to add
421 """
422 if self.filters:
423 if not all([f(message) for f in self.filters]):
424 return
425 if self._user_moved(message):
426 for wid in reversed(self.mess_walker):
427 # we merge in/out messages if no message was sent meanwhile
428 if not isinstance(wid, MessageWidget):
429 continue
430 if wid.mess_data.type != C.MESS_TYPE_INFO:
431 break
432 if wid.info_type in quick_chat.ROOM_USER_MOVED and wid.mess_data.nick == message.nick:
433 try:
434 count = wid.reentered_count
435 except AttributeError:
436 count = wid.reentered_count = 1
437 nick = wid.mess_data.nick
438 if message.info_type == quick_chat.ROOM_USER_LEFT:
439 wid.message = _(u"<= {nick} has left the room ({count})").format(nick=nick, count=count)
440 else:
441 wid.message = _(u"<=> {nick} re-entered the room ({count})") .format(nick=nick, count=count)
442 wid.reentered_count+=1
443 return
444
445 if ((self.host.selected_widget != self or not self.host.x_notify.hasFocus())
446 and self.focus_marker_set is not None):
447 if not self.focus_marker_set and not self._locked and self.mess_walker:
448 if self.focus_marker is not None:
449 self.mess_walker.remove(self.focus_marker)
450 self.focus_marker = urwid.Divider('—')
451 self.mess_walker.append(self.focus_marker)
452 self.focus_marker_set = True
453 self._scrollDown()
454 else:
455 if self.focus_marker_set:
456 self.focus_marker_set = False
457
458 if not message.message:
459 log.error(u"Received an empty message for uid {}".format(message.uid))
460 else:
461 wid = MessageWidget(message)
462 self.mess_walker.append(wid)
463 self._scrollDown()
464 if self._user_moved(message):
465 return # no notification for moved messages
466
467 # notifications
468
469 if self._locked:
470 # we don't want notifications when locked
471 # because that's history messages
472 return
473
474 if wid.mess_data.mention:
475 from_jid = wid.mess_data.from_jid
476 msg = _(u'You have been mentioned by {nick} in {room}'.format(
477 nick=wid.mess_data.nick,
478 room=self.target,
479 ))
480 self.host.notify(C.NOTIFY_MENTION, from_jid, msg, widget=self, profile=self.profile)
481 elif self.type == C.CHAT_ONE2ONE:
482 from_jid = wid.mess_data.from_jid
483 msg = _(u'{entity} is talking to you'.format(
484 entity=from_jid,
485 ))
486 self.host.notify(C.NOTIFY_MESSAGE, from_jid, msg, widget=self, profile=self.profile)
487 else:
488 self.host.notify(C.NOTIFY_MESSAGE, self.target, widget=self, profile=self.profile)
489
490
491 def addUser(self, nick):
492 occupant = super(Chat, self).addUser(nick)
493 self.occupants_widget.addUser(occupant)
494
495 def removeUser(self, occupant_data):
496 occupant = super(Chat, self).removeUser(occupant_data)
497 if occupant is not None:
498 self.occupants_widget.removeUser(occupant)
499
500 def _occupantsClicked(self, occupant, clicked_wid):
501 assert self.type == C.CHAT_GROUP
502 contact_list = self.host.contact_lists[self.profile]
503
504 # we have a click on a nick, we need to create the widget if it doesn't exists
505 self.getOrCreatePrivateWidget(occupant.jid)
506
507 # now we select the new window
508 for contact_list in self.host.widgets.getWidgets(ContactList, profiles=(self.profile,)):
509 contact_list.setFocus(occupant.jid, True)
510
511 def _appendOccupantsPanel(self):
512 self.chat_colums.contents.append((self.occupants_panel, ('weight', 2, False)))
513
514 def _removeOccupantsPanel(self):
515 for widget, options in self.chat_colums.contents:
516 if widget is self.occupants_panel:
517 self.chat_colums.contents.remove((widget, options))
518 break
519
520 def addGamePanel(self, widget):
521 """Insert a game panel to this Chat dialog.
522
523 @param widget (Widget): the game panel
524 """
525 assert (len(self.pile.contents) == 1)
526 self.pile.contents.insert(0, (widget, ('weight', 1)))
527 self.pile.contents.insert(1, (urwid.Filler(urwid.Divider('-'), ('fixed', 1))))
528 self.host.redraw()
529
530 def removeGamePanel(self, widget):
531 """Remove the game panel from this Chat dialog.
532
533 @param widget (Widget): the game panel
534 """
535 assert (len(self.pile.contents) == 3)
536 del self.pile.contents[0]
537 self.host.redraw()
538
539 def setSubject(self, subject, wrap='space'):
540 """Set title for a group chat"""
541 quick_chat.QuickChat.setSubject(self, subject)
542 self.subj_wid = urwid.Text(unicode(subject.replace('\n', '|') if wrap == 'clip' else subject),
543 align='left' if wrap == 'clip' else 'center', wrap=wrap)
544 self.chat_widget.header = urwid.AttrMap(self.subj_wid, 'title')
545 self.host.redraw()
546
547 ## Messages
548
549 def printMessages(self, clear=True):
550 """generate message widgets
551
552 @param clear(bool): clear message before printing if true
553 """
554 if clear:
555 del self.mess_walker[:]
556 for message in self.messages.itervalues():
557 self.appendMessage(message)
558
559 def redraw(self):
560 """redraw all messages"""
561 for w in self.mess_walker:
562 try:
563 w.redraw()
564 except AttributeError:
565 pass
566
567 def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, filters=None, profile='@NONE@'):
568 del self.mess_walker[:]
569 if filters and 'search' in filters:
570 self.mess_walker.append(urwid.Text(_(u"Results for searching the globbing pattern: {}").format(filters['search'])))
571 self.mess_walker.append(urwid.Text(_(u"Type ':history <lines>' to reset the chat history")))
572 super(Chat, self).updateHistory(size, filters, profile)
573
574 def _onHistoryPrinted(self):
575 """Refresh or scroll down the focus after the history is printed"""
576 self.printMessages(clear=False)
577 super(Chat, self)._onHistoryPrinted()
578
579 def onPrivateCreated(self, widget):
580 self.host.contact_lists[widget.profile].setSpecial(widget.target, C.CONTACT_SPECIAL_GROUP)
581
582 def onSelected(self):
583 self.focus_marker_set = False
584
585 def notify(self, contact="somebody", msg=""):
586 """Notify the user of a new message if primitivus doesn't have the focus.
587
588 @param contact (unicode): contact who wrote to the users
589 @param msg (unicode): the message that has been received
590 """
591 # FIXME: not called anymore after refactoring
592 if msg == "":
593 return
594 if self.mess_widgets.get_focus()[1] == len(self.mess_walker) - 2:
595 # we don't change focus if user is not at the bottom
596 # as that mean that he is probably watching discussion history
597 self.mess_widgets.focus_position = len(self.mess_walker) - 1
598 self.host.redraw()
599 if not self.host.x_notify.hasFocus():
600 if self.type == C.CHAT_ONE2ONE:
601 self.host.x_notify.sendNotification(_("Primitivus: %s is talking to you") % contact)
602 elif self.nick is not None and self.nick.lower() in msg.lower():
603 self.host.x_notify.sendNotification(_("Primitivus: %(user)s mentioned you in room '%(room)s'") % {'user': contact, 'room': self.target})
604
605 # MENU EVENTS #
606 def onTarotRequest(self, menu):
607 # TODO: move this to plugin_misc_tarot with dynamic menu
608 if len(self.occupants) != 4:
609 self.host.showPopUp(sat_widgets.Alert(_("Can't start game"), _("You need to be exactly 4 peoples in the room to start a Tarot game"), ok_cb=self.host.removePopUp))
610 else:
611 self.host.bridge.tarotGameCreate(self.target, list(self.occupants), self.profile)
612
613 # MISC EVENTS #
614
615 def onDelete(self):
616 # FIXME: to be checked after refactoring
617 super(Chat, self).onDelete()
618 if self.type == C.CHAT_GROUP:
619 self.host.removeListener('presence', self.presenceListener)
620
621 def onChatState(self, from_jid, state, profile):
622 super(Chat, self).onChatState(from_jid, state, profile)
623 if self.type == C.CHAT_ONE2ONE:
624 self.title_dynamic = C.CHAT_STATE_ICON[state]
625 self.host.redraw() # FIXME: should not be necessary
626
627 def _onSubjectDialogCb(self, button, dialog):
628 self.changeSubject(dialog.text)
629 self.host.removePopUp(dialog)
630
631 def onSubjectDialog(self, new_subject=None):
632 dialog = sat_widgets.InputDialog(
633 _(u'Change title'),
634 _(u'Enter the new title'),
635 default_txt=new_subject if new_subject is not None else self.subject)
636 dialog.setCallback('ok', self._onSubjectDialogCb, dialog)
637 dialog.setCallback('cancel', lambda dummy: self.host.removePopUp(dialog))
638 self.host.showPopUp(dialog)
639
640 quick_widgets.register(quick_chat.QuickChat, Chat)
641 quick_widgets.register(quick_games.Tarot, game_tarot.TarotGame)