comparison src/browser/panels.py @ 449:981ed669d3b3

/!\ reorganize all the file hierarchy, move the code and launching script to src: - browser_side --> src/browser - public --> src/browser_side/public - libervia.py --> src/browser/libervia_main.py - libervia_server --> src/server - libervia_server/libervia.sh --> src/libervia.sh - twisted --> src/twisted - new module src/common - split constants.py in 3 files: - src/common/constants.py - src/browser/constants.py - src/server/constants.py - output --> html (generated by pyjsbuild during the installation) - new option/parameter "data_dir" (-d) to indicates the directory containing html and server_css - setup.py installs libervia to the following paths: - src/common --> <LIB>/libervia/common - src/server --> <LIB>/libervia/server - src/twisted --> <LIB>/twisted - html --> <SHARE>/libervia/html - server_side --> <SHARE>libervia/server_side - LIBERVIA_INSTALL environment variable takes 2 new options with prompt confirmation: - clean: remove previous installation directories - purge: remove building and previous installation directories You may need to update your sat.conf and/or launching script to update the following options/parameters: - ssl_certificate - data_dir
author souliane <souliane@mailoo.org>
date Tue, 20 May 2014 06:41:16 +0200
parents browser_side/panels.py@17259c2ff96f
children 1a0cec9b0f1e
comparison
equal deleted inserted replaced
448:14c35f7f1ef5 449:981ed669d3b3
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # Libervia: a Salut à Toi frontend
5 # Copyright (C) 2011, 2012, 2013, 2014 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 import pyjd # this is dummy in pyjs
21 from sat.core.log import getLogger
22 log = getLogger(__name__)
23
24 from sat_frontends.tools.strings import addURLToText
25 from sat_frontends.tools.games import SYMBOLS
26 from sat.core.i18n import _
27
28 from pyjamas.ui.SimplePanel import SimplePanel
29 from pyjamas.ui.AbsolutePanel import AbsolutePanel
30 from pyjamas.ui.VerticalPanel import VerticalPanel
31 from pyjamas.ui.HorizontalPanel import HorizontalPanel
32 from pyjamas.ui.HTMLPanel import HTMLPanel
33 from pyjamas.ui.Frame import Frame
34 from pyjamas.ui.TextArea import TextArea
35 from pyjamas.ui.Label import Label
36 from pyjamas.ui.Button import Button
37 from pyjamas.ui.HTML import HTML
38 from pyjamas.ui.Image import Image
39 from pyjamas.ui.ClickListener import ClickHandler
40 from pyjamas.ui.FlowPanel import FlowPanel
41 from pyjamas.ui.KeyboardListener import KEY_ENTER, KEY_UP, KEY_DOWN, KeyboardHandler
42 from pyjamas.ui.MouseListener import MouseHandler
43 from pyjamas.ui.FocusListener import FocusHandler
44 from pyjamas.Timer import Timer
45 from pyjamas import DOM
46 from pyjamas import Window
47 from __pyjamas__ import doc
48
49 from datetime import datetime
50 from time import time
51
52 from jid import JID
53 from html_tools import html_sanitize
54 from base_panels import ChatText, OccupantsList, PopupMenuPanel, BaseTextEditor, LightTextEditor, HTMLTextEditor
55 from card_game import CardPanel
56 from radiocol import RadioColPanel
57 from menu import Menu
58 import dialog
59 import base_widget
60 import richtext
61 import contact
62 from constants import Const as C
63 from plugin_xep_0085 import ChatStateMachine
64
65
66 # TODO: at some point we should decide which behaviors to keep and remove these two constants
67 TOGGLE_EDITION_USE_ICON = False # set to True to use an icon inside the "toggle syntax" button
68 NEW_MESSAGE_USE_BUTTON = False # set to True to display the "New message" button instead of an empty entry
69
70
71 class UniBoxPanel(HorizontalPanel):
72 """Panel containing the UniBox"""
73
74 def __init__(self, host):
75 HorizontalPanel.__init__(self)
76 self.host = host
77 self.setStyleName('uniBoxPanel')
78 self.unibox = None
79
80 def refresh(self):
81 """Enable or disable this panel. Contained widgets are created when necessary."""
82 enable = self.host.params_ui['unibox']['value']
83 self.setVisible(enable)
84 if enable and not self.unibox:
85 self.button = Button('<img src="media/icons/tango/actions/32/format-text-italic.png" class="richTextIcon"/>')
86 self.button.setTitle('Open the rich text editor')
87 self.button.addStyleName('uniBoxButton')
88 self.add(self.button)
89 self.unibox = UniBox(self.host)
90 self.add(self.unibox)
91 self.setCellWidth(self.unibox, '100%')
92 self.button.addClickListener(self.openRichMessageEditor)
93 self.unibox.addKey("@@: ")
94 self.unibox.onSelectedChange(self.host.getSelected())
95
96 def openRichMessageEditor(self):
97 """Open the rich text editor."""
98 self.button.setVisible(False)
99 self.unibox.setVisible(False)
100 self.setCellWidth(self.unibox, '0px')
101 self.host.panel._contactsMove(self)
102
103 def afterEditCb():
104 Window.removeWindowResizeListener(self)
105 self.host.panel._contactsMove(self.host.panel._hpanel)
106 self.setCellWidth(self.unibox, '100%')
107 self.button.setVisible(True)
108 self.unibox.setVisible(True)
109 self.host.resize()
110
111 richtext.RichMessageEditor.getOrCreate(self.host, self, afterEditCb)
112 Window.addWindowResizeListener(self)
113 self.host.resize()
114
115 def onWindowResized(self, width, height):
116 right = self.host.panel.menu.getAbsoluteLeft() + self.host.panel.menu.getOffsetWidth()
117 left = self.host.panel._contacts.getAbsoluteLeft() + self.host.panel._contacts.getOffsetWidth()
118 ideal_width = right - left - 40
119 self.host.richtext.setWidth("%spx" % ideal_width)
120
121
122 class MessageBox(TextArea):
123 """A basic text area for entering messages"""
124
125 def __init__(self, host):
126 TextArea.__init__(self)
127 self.host = host
128 self.__size = (0, 0)
129 self.setStyleName('messageBox')
130 self.addKeyboardListener(self)
131 MouseHandler.__init__(self)
132 self.addMouseListener(self)
133 self._selected_cache = None
134
135 def onBrowserEvent(self, event):
136 # XXX: woraroung a pyjamas bug: self.currentEvent is not set
137 # so the TextBox's cancelKey doens't work. This is a workaround
138 # FIXME: fix the bug upstream
139 self.currentEvent = event
140 TextArea.onBrowserEvent(self, event)
141
142 def onKeyPress(self, sender, keycode, modifiers):
143 _txt = self.getText()
144
145 def history_cb(text):
146 self.setText(text)
147 Timer(5, lambda timer: self.setCursorPos(len(text)))
148
149 if keycode == KEY_ENTER:
150 if _txt:
151 self._selected_cache.onTextEntered(_txt)
152 self.host._updateInputHistory(_txt)
153 self.setText('')
154 sender.cancelKey()
155 elif keycode == KEY_UP:
156 self.host._updateInputHistory(_txt, -1, history_cb)
157 elif keycode == KEY_DOWN:
158 self.host._updateInputHistory(_txt, +1, history_cb)
159 else:
160 self.__onComposing()
161
162 def __onComposing(self):
163 """Callback when the user is composing a text."""
164 if hasattr(self._selected_cache, "target"):
165 self._selected_cache.state_machine._onEvent("composing")
166
167 def onMouseUp(self, sender, x, y):
168 size = (self.getOffsetWidth(), self.getOffsetHeight())
169 if size != self.__size:
170 self.__size = size
171 self.host.resize()
172
173 def onSelectedChange(self, selected):
174 self._selected_cache = selected
175
176
177 class UniBox(MessageBox, MouseHandler): #AutoCompleteTextBox):
178 """This text box is used as a main typing point, for message, microblog, etc"""
179
180 def __init__(self, host):
181 MessageBox.__init__(self, host)
182 #AutoCompleteTextBox.__init__(self)
183 self.setStyleName('uniBox')
184 host.addSelectedListener(self.onSelectedChange)
185
186 def addKey(self, key):
187 return
188 #self.getCompletionItems().completions.append(key)
189
190 def removeKey(self, key):
191 return
192 # TODO: investigate why AutoCompleteTextBox doesn't work here,
193 # maybe it can work on a TextBox but no TextArea. Remove addKey
194 # and removeKey methods if they don't serve anymore.
195 try:
196 self.getCompletionItems().completions.remove(key)
197 except KeyError:
198 log.warning("trying to remove an unknown key")
199
200 def _getTarget(self, txt):
201 """ Say who will receive the messsage
202 @return: a tuple (selected, target_type, target info) with:
203 - target_hook: None if we use the selected widget, (msg, data) if we have a hook (e.g. "@@: " for a public blog), where msg is the parsed message (i.e. without the "hook key: "@@: bla" become ("bla", None))
204 - target_type: one of PUBLIC, GROUP, ONE2ONE, STATUS, MISC
205 - msg: HTML message which will appear in the privacy warning banner """
206 target = self._selected_cache
207
208 def getSelectedOrStatus():
209 if target and target.isSelectable():
210 _type, msg = target.getWarningData()
211 target_hook = None # we use the selected widget, not a hook
212 else:
213 _type, msg = "STATUS", "This will be your new status message"
214 target_hook = (txt, None)
215 return (target_hook, _type, msg)
216
217 if not txt.startswith('@'):
218 target_hook, _type, msg = getSelectedOrStatus()
219 elif txt.startswith('@@: '):
220 _type = "PUBLIC"
221 msg = MicroblogPanel.warning_msg_public
222 target_hook = (txt[4:], None)
223 elif txt.startswith('@'):
224 _end = txt.find(': ')
225 if _end == -1:
226 target_hook, _type, msg = getSelectedOrStatus()
227 else:
228 group = txt[1:_end] # only one target group is managed for the moment
229 if not group or not group in self.host.contact_panel.getGroups():
230 # the group doesn't exists, we ignore the key
231 group = None
232 target_hook, _type, msg = getSelectedOrStatus()
233 else:
234 _type = "GROUP"
235 msg = MicroblogPanel.warning_msg_group % group
236 target_hook = (txt[_end + 2:], group)
237 else:
238 log.error("Unknown target")
239 target_hook, _type, msg = getSelectedOrStatus()
240
241 return (target_hook, _type, msg)
242
243 def onKeyPress(self, sender, keycode, modifiers):
244 _txt = self.getText()
245 target_hook, type_, msg = self._getTarget(_txt)
246
247 if keycode == KEY_ENTER:
248 if _txt:
249 if target_hook:
250 parsed_txt, data = target_hook
251 self.host.send([(type_, data)], parsed_txt)
252 self.host._updateInputHistory(_txt)
253 self.setText('')
254 self.host.showWarning(None, None)
255 else:
256 self.host.showWarning(type_, msg)
257 MessageBox.onKeyPress(self, sender, keycode, modifiers)
258
259 def getTargetAndData(self):
260 """For external use, to get information about the (hypothetical) message
261 that would be sent if we press Enter right now in the unibox.
262 @return a tuple (target, data) with:
263 - data: what would be the content of the message (body)
264 - target: JID, group with the prefix "@" or the public entity "@@"
265 """
266 _txt = self.getText()
267 target_hook, _type, _msg = self._getTarget(_txt)
268 if target_hook:
269 data, target = target_hook
270 if target is None:
271 return target_hook
272 return (data, "@%s" % (target if target != "" else "@"))
273 if isinstance(self._selected_cache, MicroblogPanel):
274 groups = self._selected_cache.accepted_groups
275 target = "@%s" % (groups[0] if len(groups) > 0 else "@")
276 if len(groups) > 1:
277 Window.alert("Sole the first group of the selected panel is taken in consideration: '%s'" % groups[0])
278 elif isinstance(self._selected_cache, ChatPanel):
279 target = self._selected_cache.target
280 else:
281 target = None
282 return (_txt, target)
283
284 def onWidgetClosed(self, lib_wid):
285 """Called when a libervia widget is closed"""
286 if self._selected_cache == lib_wid:
287 self.onSelectedChange(None)
288
289 """def complete(self):
290
291 #self.visible=False #XXX: self.visible is not unset in pyjamas when ENTER is pressed and a completion is done
292 #XXX: fixed directly on pyjamas, if the patch is accepted, no need to walk around this
293 return AutoCompleteTextBox.complete(self)"""
294
295
296 class WarningPopup():
297
298 def __init__(self):
299 self._popup = None
300 self._timer = Timer(notify=self._timeCb)
301
302 def showWarning(self, type_=None, msg=None, duration=2000):
303 """Display a popup information message, e.g. to notify the recipient of a message being composed.
304 If type_ is None, a popup being currently displayed will be hidden.
305 @type_: a type determining the CSS style to be applied (see __showWarning)
306 @msg: message to be displayed
307 """
308 if type_ is None:
309 self.__removeWarning()
310 return
311 if not self._popup:
312 self.__showWarning(type_, msg)
313 elif (type_, msg) != self._popup.target_data:
314 self._timeCb(None) # we remove the popup
315 self.__showWarning(type_, msg)
316
317 self._timer.schedule(duration)
318
319 def __showWarning(self, type_, msg):
320 """Display a popup information message, e.g. to notify the recipient of a message being composed.
321 @type_: a type determining the CSS style to be applied. For now the defined styles are
322 "NONE" (will do nothing), "PUBLIC", "GROUP", "STATUS" and "ONE2ONE".
323 @msg: message to be displayed
324 """
325 if type_ == "NONE":
326 return
327 if not msg:
328 log.warning("no msg set uniBox warning")
329 return
330 if type_ == "PUBLIC":
331 style = "targetPublic"
332 elif type_ == "GROUP":
333 style = "targetGroup"
334 elif type_ == "STATUS":
335 style = "targetStatus"
336 elif type_ == "ONE2ONE":
337 style = "targetOne2One"
338 else:
339 log.error("unknown message type")
340 return
341 contents = HTML(msg)
342
343 self._popup = dialog.PopupPanelWrapper(autoHide=False, modal=False)
344 self._popup.target_data = (type_, msg)
345 self._popup.add(contents)
346 self._popup.setStyleName("warningPopup")
347 if style:
348 self._popup.addStyleName(style)
349
350 left = 0
351 top = 0 # max(0, self.getAbsoluteTop() - contents.getOffsetHeight() - 2)
352 self._popup.setPopupPosition(left, top)
353 self._popup.show()
354
355 def _timeCb(self, timer):
356 if self._popup:
357 self._popup.hide()
358 del self._popup
359 self._popup = None
360
361 def __removeWarning(self):
362 """Remove the popup"""
363 self._timeCb(None)
364
365
366 class MicroblogItem():
367 # XXX: should be moved in a separated module
368
369 def __init__(self, data):
370 self.id = data['id']
371 self.type = data.get('type', 'main_item')
372 self.empty = data.get('new', False)
373 self.title = data.get('title', '')
374 self.title_xhtml = data.get('title_xhtml', '')
375 self.content = data.get('content', '')
376 self.content_xhtml = data.get('content_xhtml', '')
377 self.author = data['author']
378 self.updated = float(data.get('updated', 0)) # XXX: int doesn't work here
379 self.published = float(data.get('published', self.updated)) # XXX: int doesn't work here
380 self.service = data.get('service', '')
381 self.node = data.get('node', '')
382 self.comments = data.get('comments', False)
383 self.comments_service = data.get('comments_service', '')
384 self.comments_node = data.get('comments_node', '')
385
386
387 class MicroblogEntry(SimplePanel, ClickHandler, FocusHandler, KeyboardHandler):
388
389 def __init__(self, blog_panel, data):
390 """
391 @param blog_panel: the parent panel
392 @param data: dict containing the blog item data, or a MicroblogItem instance.
393 """
394 self._base_item = data if isinstance(data, MicroblogItem) else MicroblogItem(data)
395 for attr in ['id', 'type', 'empty', 'title', 'title_xhtml', 'content', 'content_xhtml',
396 'author', 'updated', 'published', 'comments', 'service', 'node',
397 'comments_service', 'comments_node']:
398 getter = lambda attr: lambda inst: getattr(inst._base_item, attr)
399 setter = lambda attr: lambda inst, value: setattr(inst._base_item, attr, value)
400 setattr(MicroblogEntry, attr, property(getter(attr), setter(attr)))
401
402 SimplePanel.__init__(self)
403 self._blog_panel = blog_panel
404
405 self.panel = FlowPanel()
406 self.panel.setStyleName('mb_entry')
407
408 self.header = HTMLPanel('')
409 self.panel.add(self.header)
410
411 self.entry_actions = VerticalPanel()
412 self.entry_actions.setStyleName('mb_entry_actions')
413 self.panel.add(self.entry_actions)
414
415 entry_avatar = SimplePanel()
416 entry_avatar.setStyleName('mb_entry_avatar')
417 self.avatar = Image(self._blog_panel.host.getAvatar(self.author))
418 entry_avatar.add(self.avatar)
419 self.panel.add(entry_avatar)
420
421 if TOGGLE_EDITION_USE_ICON:
422 self.entry_dialog = HorizontalPanel()
423 else:
424 self.entry_dialog = VerticalPanel()
425 self.entry_dialog.setStyleName('mb_entry_dialog')
426 self.panel.add(self.entry_dialog)
427
428 self.add(self.panel)
429 ClickHandler.__init__(self)
430 self.addClickListener(self)
431
432 self.__pub_data = (self.service, self.node, self.id)
433 self.__setContent()
434
435 def __setContent(self):
436 """Actually set the entry content (header, icons, bubble...)"""
437 self.delete_label = self.update_label = self.comment_label = None
438 self.bubble = self._current_comment = None
439 self.__setHeader()
440 self.__setBubble()
441 self.__setIcons()
442
443 def __setHeader(self):
444 """Set the entry header"""
445 if self.empty:
446 return
447 update_text = u" — ✍ " + "<span class='mb_entry_timestamp'>%s</span>" % datetime.fromtimestamp(self.updated)
448 self.header.setHTML("""<div class='mb_entry_header'>
449 <span class='mb_entry_author'>%(author)s</span> on
450 <span class='mb_entry_timestamp'>%(published)s</span>%(updated)s
451 </div>""" % {'author': html_sanitize(self.author),
452 'published': datetime.fromtimestamp(self.published),
453 'updated': update_text if self.published != self.updated else ''
454 }
455 )
456
457 def __setIcons(self):
458 """Set the entry icons (delete, update, comment)"""
459 if self.empty:
460 return
461
462 def addIcon(label, title):
463 label = Label(label)
464 label.setTitle(title)
465 label.addClickListener(self)
466 self.entry_actions.add(label)
467 return label
468
469 if self.comments:
470 self.comment_label = addIcon(u"↶", "Comment this message")
471 self.comment_label.setStyleName('mb_entry_action_larger')
472 is_publisher = self.author == self._blog_panel.host.whoami.bare
473 if is_publisher:
474 self.update_label = addIcon(u"✍", "Edit this message")
475 if is_publisher or str(self.node).endswith(self._blog_panel.host.whoami.bare):
476 self.delete_label = addIcon(u"✗", "Delete this message")
477
478 def updateAvatar(self, new_avatar):
479 """Change the avatar of the entry
480 @param new_avatar: path to the new image"""
481 self.avatar.setUrl(new_avatar)
482
483 def onClick(self, sender):
484 if sender == self:
485 try: # prevent re-selection of the main entry after a comment has been focused
486 if self.__ignoreNextEvent:
487 self.__ignoreNextEvent = False
488 return
489 except AttributeError:
490 pass
491 self._blog_panel.setSelectedEntry(self)
492 elif sender == self.delete_label:
493 self._delete()
494 elif sender == self.update_label:
495 self.edit(True)
496 elif sender == self.comment_label:
497 self.__ignoreNextEvent = True
498 self._comment()
499
500 def __modifiedCb(self, content):
501 """Send the new content to the backend
502 @return: False to restore the original content if a deletion has been cancelled
503 """
504 if not content['text']: # previous content has been emptied
505 self._delete(True)
506 return False
507 extra = {'published': str(self.published)}
508 if isinstance(self.bubble, richtext.RichTextEditor):
509 # TODO: if the user change his parameters after the message edition started,
510 # the message syntax could be different then the current syntax: pass the
511 # message syntax in extra for the frontend to use it instead of current syntax.
512 extra.update({'content_rich': content['text'], 'title': content['title']})
513 if self.empty:
514 if self.type == 'main_item':
515 self._blog_panel.host.bridge.call('sendMblog', None, None, self._blog_panel.accepted_groups, content['text'], extra)
516 else:
517 self._blog_panel.host.bridge.call('sendMblogComment', None, self._parent_entry.comments, content['text'], extra)
518 else:
519 self._blog_panel.host.bridge.call('updateMblog', None, self.__pub_data, self.comments, content['text'], extra)
520 return True
521
522 def __afterEditCb(self, content):
523 """Remove the entry if it was an empty one (used for creating a new blog post).
524 Data for the actual new blog post will be received from the bridge"""
525 if self.empty:
526 self._blog_panel.removeEntry(self.type, self.id)
527 if self.type == 'main_item': # restore the "New message" button
528 self._blog_panel.refresh()
529 else: # allow to create a new comment
530 self._parent_entry._current_comment = None
531 self.entry_dialog.setWidth('auto')
532 try:
533 self.toggle_syntax_button.removeFromParent()
534 except TypeError:
535 pass
536
537 def __setBubble(self, edit=False):
538 """Set the bubble displaying the initial content."""
539 content = {'text': self.content_xhtml if self.content_xhtml else self.content,
540 'title': self.title_xhtml if self.title_xhtml else self.title}
541 if self.content_xhtml:
542 content.update({'syntax': C.SYNTAX_XHTML})
543 if self.author != self._blog_panel.host.whoami.bare:
544 options = ['read_only']
545 else:
546 options = [] if self.empty else ['update_msg']
547 self.bubble = richtext.RichTextEditor(self._blog_panel.host, content, self.__modifiedCb, self.__afterEditCb, options)
548 else: # assume raw text message have no title
549 self.bubble = LightTextEditor(content, self.__modifiedCb, self.__afterEditCb, options={'no_xhtml': True})
550 self.bubble.addStyleName("bubble")
551 try:
552 self.toggle_syntax_button.removeFromParent()
553 except TypeError:
554 pass
555 self.entry_dialog.add(self.bubble)
556 self.edit(edit)
557 self.bubble.addEditListener(self.__showWarning)
558
559 def __showWarning(self, sender, keycode):
560 if keycode == KEY_ENTER:
561 self._blog_panel.host.showWarning(None, None)
562 else:
563 self._blog_panel.host.showWarning(*self._blog_panel.getWarningData(self.type == 'comment'))
564
565 def _delete(self, empty=False):
566 """Ask confirmation for deletion.
567 @return: False if the deletion has been cancelled."""
568 def confirm_cb(answer):
569 if answer:
570 self._blog_panel.host.bridge.call('deleteMblog', None, self.__pub_data, self.comments)
571 else: # restore the text if it has been emptied during the edition
572 self.bubble.setContent(self.bubble._original_content)
573
574 if self.empty:
575 text = _("New ") + (_("message") if self.comments else _("comment")) + _(" without body has been cancelled.")
576 dialog.InfoDialog(_("Information"), text).show()
577 return
578 text = ""
579 if empty:
580 text = (_("Message") if self.comments else _("Comment")) + _(" body has been emptied.<br/>")
581 target = _('message and all its comments') if self.comments else _('comment')
582 text += _("Do you really want to delete this %s?") % target
583 dialog.ConfirmDialog(confirm_cb, text=text).show()
584
585 def _comment(self):
586 """Add an empty entry for a new comment"""
587 if self._current_comment:
588 self._current_comment.bubble.setFocus(True)
589 self._blog_panel.setSelectedEntry(self._current_comment)
590 return
591 data = {'id': str(time()),
592 'new': True,
593 'type': 'comment',
594 'author': self._blog_panel.host.whoami.bare,
595 'service': self.comments_service,
596 'node': self.comments_node
597 }
598 entry = self._blog_panel.addEntry(data)
599 if entry is None:
600 log.info("The entry of id %s can not be commented" % self.id)
601 return
602 entry._parent_entry = self
603 self._current_comment = entry
604 self.edit(True, entry)
605 self._blog_panel.setSelectedEntry(entry)
606
607 def edit(self, edit, entry=None):
608 """Toggle the bubble between display and edit mode
609 @edit: boolean value
610 @entry: MicroblogEntry instance, or None to use self
611 """
612 if entry is None:
613 entry = self
614 try:
615 entry.toggle_syntax_button.removeFromParent()
616 except TypeError:
617 pass
618 entry.bubble.edit(edit)
619 if edit:
620 if isinstance(entry.bubble, richtext.RichTextEditor):
621 image = '<a class="richTextIcon">A</a>'
622 html = '<a style="color: blue;">raw text</a>'
623 title = _('Switch to raw text edition')
624 else:
625 image = '<img src="media/icons/tango/actions/32/format-text-italic.png" class="richTextIcon"/>'
626 html = '<a style="color: blue;">rich text</a>'
627 title = _('Switch to rich text edition')
628 if TOGGLE_EDITION_USE_ICON:
629 entry.entry_dialog.setWidth('80%')
630 entry.toggle_syntax_button = Button(image, entry.toggleContentSyntax)
631 entry.toggle_syntax_button.setTitle(title)
632 entry.entry_dialog.add(entry.toggle_syntax_button)
633 else:
634 entry.toggle_syntax_button = HTML(html)
635 entry.toggle_syntax_button.addClickListener(entry.toggleContentSyntax)
636 entry.toggle_syntax_button.addStyleName('mb_entry_toggle_syntax')
637 entry.entry_dialog.add(entry.toggle_syntax_button)
638 entry.toggle_syntax_button.setStyleAttribute('top', '-20px') # XXX: need to force CSS
639 entry.toggle_syntax_button.setStyleAttribute('left', '-20px')
640
641 def toggleContentSyntax(self):
642 """Toggle the editor between raw and rich text"""
643 original_content = self.bubble.getOriginalContent()
644 rich = not isinstance(self.bubble, richtext.RichTextEditor)
645 if rich:
646 original_content['syntax'] = C.SYNTAX_XHTML
647
648 def setBubble(text):
649 self.content = text
650 self.content_xhtml = text if rich else ''
651 self.content_title = self.content_title_xhtml = ''
652 self.bubble.removeFromParent()
653 self.__setBubble(True)
654 self.bubble.setOriginalContent(original_content)
655 if rich:
656 self.bubble.setDisplayContent() # needed in case the edition is aborted, to not end with an empty bubble
657
658 text = self.bubble.getContent()['text']
659 if not text:
660 setBubble(' ') # something different than empty string is needed to initialize the rich text editor
661 return
662 if not rich:
663 def confirm_cb(answer):
664 if answer:
665 self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_CURRENT, C.SYNTAX_TEXT)
666 dialog.ConfirmDialog(confirm_cb, text=_("Do you really want to lose the title and text formatting?")).show()
667 else:
668 self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_TEXT, C.SYNTAX_XHTML)
669
670
671 class MicroblogPanel(base_widget.LiberviaWidget):
672 warning_msg_public = "This message will be PUBLIC and everybody will be able to see it, even people you don't know"
673 warning_msg_group = "This message will be published for all the people of the group <span class='warningTarget'>%s</span>"
674
675 def __init__(self, host, accepted_groups):
676 """Panel used to show microblog
677 @param accepted_groups: groups displayed in this panel, if empty, show all microblogs from all contacts
678 """
679 base_widget.LiberviaWidget.__init__(self, host, ", ".join(accepted_groups), selectable=True)
680 self.setAcceptedGroup(accepted_groups)
681 self.host = host
682 self.entries = {}
683 self.comments = {}
684 self.selected_entry = None
685 self.vpanel = VerticalPanel()
686 self.vpanel.setStyleName('microblogPanel')
687 self.setWidget(self.vpanel)
688
689 def refresh(self):
690 """Refresh the display of this widget. If the unibox is disabled,
691 display the 'New message' button or an empty bubble on top of the panel"""
692 if hasattr(self, 'new_button'):
693 self.new_button.setVisible(self.host.uni_box is None)
694 return
695 if self.host.uni_box is None:
696 def addBox():
697 if hasattr(self, 'new_button'):
698 self.new_button.setVisible(False)
699 data = {'id': str(time()),
700 'new': True,
701 'author': self.host.whoami.bare,
702 }
703 entry = self.addEntry(data)
704 entry.edit(True)
705 if NEW_MESSAGE_USE_BUTTON:
706 self.new_button = Button("New message", listener=addBox)
707 self.new_button.setStyleName("microblogNewButton")
708 self.vpanel.insert(self.new_button, 0)
709 else:
710 addBox()
711
712 @classmethod
713 def registerClass(cls):
714 base_widget.LiberviaWidget.addDropKey("GROUP", cls.createPanel)
715 base_widget.LiberviaWidget.addDropKey("CONTACT_TITLE", cls.createMetaPanel)
716
717 @classmethod
718 def createPanel(cls, host, item):
719 """Generic panel creation for one, several or all groups (meta).
720 @parem host: the SatWebFrontend instance
721 @param item: single group as a string, list of groups
722 (as an array) or None (for the meta group = "all groups")
723 @return: the created MicroblogPanel
724 """
725 _items = item if isinstance(item, list) else ([] if item is None else [item])
726 _type = 'ALL' if _items == [] else 'GROUP'
727 # XXX: pyjamas doesn't support use of cls directly
728 _new_panel = MicroblogPanel(host, _items)
729 host.FillMicroblogPanel(_new_panel)
730 host.bridge.call('getMassiveLastMblogs', _new_panel.massiveInsert, _type, _items, 10)
731 host.setSelected(_new_panel)
732 _new_panel.refresh()
733 return _new_panel
734
735 @classmethod
736 def createMetaPanel(cls, host, item):
737 """Needed for the drop keys to not be mixed between meta panel and panel for "Contacts" group"""
738 return MicroblogPanel.createPanel(host, None)
739
740 @property
741 def accepted_groups(self):
742 return self._accepted_groups
743
744 def matchEntity(self, entity):
745 """
746 @param entity: single group as a string, list of groups
747 (as an array) or None (for the meta group = "all groups")
748 @return: True if self matches the given entity
749 """
750 entity = entity if isinstance(entity, list) else ([] if entity is None else [entity])
751 entity.sort() # sort() do not return the sorted list: do it here, not on the "return" line
752 return self.accepted_groups == entity
753
754 def getWarningData(self, comment=None):
755 """
756 @param comment: True if the composed message is a comment. If None, consider we are
757 composing from the unibox and guess the message type from self.selected_entry
758 @return: a couple (type, msg) for calling self.host.showWarning"""
759 if comment is None: # composing from the unibox
760 if self.selected_entry and not self.selected_entry.comments:
761 log.error("an item without comment is selected")
762 return ("NONE", None)
763 comment = self.selected_entry is not None
764 if comment:
765 return ("PUBLIC", "This is a <span class='warningTarget'>comment</span> and keep the initial post visibility, so it is potentialy public")
766 elif not self._accepted_groups:
767 # we have a meta MicroblogPanel, we publish publicly
768 return ("PUBLIC", self.warning_msg_public)
769 else:
770 # we only accept one group at the moment
771 # FIXME: manage several groups
772 return ("GROUP", self.warning_msg_group % self._accepted_groups[0])
773
774 def onTextEntered(self, text):
775 if self.selected_entry:
776 # we are entering a comment
777 comments_url = self.selected_entry.comments
778 if not comments_url:
779 raise Exception("ERROR: the comments URL is empty")
780 target = ("COMMENT", comments_url)
781 elif not self._accepted_groups:
782 # we are entering a public microblog
783 target = ("PUBLIC", None)
784 else:
785 # we are entering a microblog restricted to a group
786 # FIXME: manage several groups
787 target = ("GROUP", self._accepted_groups[0])
788 self.host.send([target], text)
789
790 def accept_all(self):
791 return not self._accepted_groups # we accept every microblog only if we are not filtering by groups
792
793 def getEntries(self):
794 """Ask all the entries for the currenly accepted groups,
795 and fill the panel"""
796
797 def massiveInsert(self, mblogs):
798 """Insert several microblogs at once
799 @param mblogs: dictionary of microblogs, as the result of getMassiveLastGroupBlogs
800 """
801 log.debug("Massive insertion of %d microblogs" % len(mblogs))
802 for publisher in mblogs:
803 log.debug("adding blogs for [%s]" % publisher)
804 for mblog in mblogs[publisher]:
805 if not "content" in mblog:
806 log.warning("No content found in microblog [%s]", mblog)
807 continue
808 self.addEntry(mblog)
809
810 def mblogsInsert(self, mblogs):
811 """ Insert several microblogs at once
812 @param mblogs: list of microblogs
813 """
814 for mblog in mblogs:
815 if not "content" in mblog:
816 log.warning("No content found in microblog [%s]", mblog)
817 continue
818 self.addEntry(mblog)
819
820 def _chronoInsert(self, vpanel, entry, reverse=True):
821 """ Insert an entry in chronological order
822 @param vpanel: VerticalPanel instance
823 @param entry: MicroblogEntry
824 @param reverse: more recent entry on top if True, chronological order else"""
825 if entry.empty:
826 entry.published = time()
827 # we look for the right index to insert our entry:
828 # if reversed, we insert the entry above the first entry
829 # in the past
830 idx = 0
831
832 for child in vpanel.children:
833 if not isinstance(child, MicroblogEntry):
834 idx += 1
835 continue
836 if reverse:
837 if child.published < entry.published:
838 break
839 else:
840 if child.published > entry.published:
841 break
842 idx += 1
843
844 vpanel.insert(entry, idx)
845
846 def addEntry(self, data):
847 """Add an entry to the panel
848 @param data: dict containing the item data
849 @return: the added entry, or None
850 """
851 _entry = MicroblogEntry(self, data)
852 if _entry.type == "comment":
853 comments_hash = (_entry.service, _entry.node)
854 if not comments_hash in self.comments:
855 # The comments node is not known in this panel
856 return None
857 parent = self.comments[comments_hash]
858 parent_idx = self.vpanel.getWidgetIndex(parent)
859 # we find or create the panel where the comment must be inserted
860 try:
861 sub_panel = self.vpanel.getWidget(parent_idx + 1)
862 except IndexError:
863 sub_panel = None
864 if not sub_panel or not isinstance(sub_panel, VerticalPanel):
865 sub_panel = VerticalPanel()
866 sub_panel.setStyleName('microblogPanel')
867 sub_panel.addStyleName('subPanel')
868 self.vpanel.insert(sub_panel, parent_idx + 1)
869 for idx in xrange(0, len(sub_panel.getChildren())):
870 comment = sub_panel.getIndexedChild(idx)
871 if comment.id == _entry.id:
872 # update an existing comment
873 sub_panel.remove(comment)
874 sub_panel.insert(_entry, idx)
875 return _entry
876 # we want comments to be inserted in chronological order
877 self._chronoInsert(sub_panel, _entry, reverse=False)
878 return _entry
879
880 if _entry.id in self.entries: # update
881 idx = self.vpanel.getWidgetIndex(self.entries[_entry.id])
882 self.vpanel.remove(self.entries[_entry.id])
883 self.vpanel.insert(_entry, idx)
884 else: # new entry
885 self._chronoInsert(self.vpanel, _entry)
886 self.entries[_entry.id] = _entry
887
888 if _entry.comments:
889 # entry has comments, we keep the comments service/node as a reference
890 comments_hash = (_entry.comments_service, _entry.comments_node)
891 self.comments[comments_hash] = _entry
892 self.host.bridge.call('getMblogComments', self.mblogsInsert, _entry.comments_service, _entry.comments_node)
893
894 return _entry
895
896 def removeEntry(self, type_, id_):
897 """Remove an entry from the panel
898 @param type_: entry type ('main_item' or 'comment')
899 @param id_: entry id
900 """
901 for child in self.vpanel.getChildren():
902 if isinstance(child, MicroblogEntry) and type_ == 'main_item':
903 if child.id == id_:
904 main_idx = self.vpanel.getWidgetIndex(child)
905 try:
906 sub_panel = self.vpanel.getWidget(main_idx + 1)
907 if isinstance(sub_panel, VerticalPanel):
908 sub_panel.removeFromParent()
909 except IndexError:
910 pass
911 child.removeFromParent()
912 self.selected_entry = None
913 break
914 elif isinstance(child, VerticalPanel) and type_ == 'comment':
915 for comment in child.getChildren():
916 if comment.id == id_:
917 comment.removeFromParent()
918 self.selected_entry = None
919 break
920
921 def setSelectedEntry(self, entry):
922 try:
923 self.vpanel.getParent().ensureVisible(entry) # scroll to the clicked entry
924 except AttributeError:
925 log.warning("FIXME: MicroblogPanel.vpanel should be wrapped in a ScrollPanel!")
926 removeStyle = lambda entry: entry.removeStyleName('selected_entry')
927 if not self.host.uni_box or not entry.comments:
928 entry.addStyleName('selected_entry') # blink the clicked entry
929 clicked_entry = entry # entry may be None when the timer is done
930 Timer(500, lambda timer: removeStyle(clicked_entry))
931 if not self.host.uni_box:
932 return # unibox is disabled
933 # from here the previous behavior (toggle main item selection) is conserved
934 entry = entry if entry.comments else None
935 if self.selected_entry == entry:
936 entry = None
937 if self.selected_entry:
938 removeStyle(self.selected_entry)
939 if entry:
940 log.debug("microblog entry selected (author=%s)" % entry.author)
941 entry.addStyleName('selected_entry')
942 self.selected_entry = entry
943
944 def updateValue(self, type_, jid, value):
945 """Update a jid value in entries
946 @param type_: one of 'avatar', 'nick'
947 @param jid: jid concerned
948 @param value: new value"""
949 def updateVPanel(vpanel):
950 for child in vpanel.children:
951 if isinstance(child, MicroblogEntry) and child.author == jid:
952 child.updateAvatar(value)
953 elif isinstance(child, VerticalPanel):
954 updateVPanel(child)
955 if type_ == 'avatar':
956 updateVPanel(self.vpanel)
957
958 def setAcceptedGroup(self, group):
959 """Add one or more group(s) which can be displayed in this panel.
960 Prevent from duplicate values and keep the list sorted.
961 @param group: string of the group, or list of string
962 """
963 if not hasattr(self, "_accepted_groups"):
964 self._accepted_groups = []
965 groups = group if isinstance(group, list) else [group]
966 for _group in groups:
967 if _group not in self._accepted_groups:
968 self._accepted_groups.append(_group)
969 self._accepted_groups.sort()
970
971 def isJidAccepted(self, jid):
972 """Tell if a jid is actepted and shown in this panel
973 @param jid: jid
974 @return: True if the jid is accepted"""
975 if self.accept_all():
976 return True
977 for group in self._accepted_groups:
978 if self.host.contact_panel.isContactInGroup(group, jid):
979 return True
980 return False
981
982
983 class StatusPanel(HTMLTextEditor):
984
985 EMPTY_STATUS = '&lt;click to set a status&gt;'
986
987 def __init__(self, host, status=''):
988 self.host = host
989 modifiedCb = lambda content: self.host.bridge.call('setStatus', None, self.host.status_panel.presence, content['text']) or True
990 HTMLTextEditor.__init__(self, {'text': status}, modifiedCb, options={'no_xhtml': True, 'listen_focus': True, 'listen_click': True})
991 self.edit(False)
992 self.setStyleName('statusPanel')
993
994 @property
995 def status(self):
996 return self._original_content['text']
997
998 def __cleanContent(self, content):
999 status = content['text']
1000 if status == self.EMPTY_STATUS or status in C.PRESENCE.values():
1001 content['text'] = ''
1002 return content
1003
1004 def getContent(self):
1005 return self.__cleanContent(HTMLTextEditor.getContent(self))
1006
1007 def setContent(self, content):
1008 content = self.__cleanContent(content)
1009 BaseTextEditor.setContent(self, content)
1010
1011 def setDisplayContent(self):
1012 status = self._original_content['text']
1013 try:
1014 presence = self.host.status_panel.presence
1015 except AttributeError: # during initialization
1016 presence = None
1017 if not status:
1018 if presence and presence in C.PRESENCE:
1019 status = C.PRESENCE[presence]
1020 else:
1021 status = self.EMPTY_STATUS
1022 self.display.setHTML(addURLToText(status))
1023
1024
1025 class PresenceStatusPanel(HorizontalPanel, ClickHandler):
1026
1027 def __init__(self, host, presence="", status=""):
1028 self.host = host
1029 HorizontalPanel.__init__(self, Width='100%')
1030 self.presence_button = Label(u"◉")
1031 self.presence_button.setStyleName("presence-button")
1032 self.status_panel = StatusPanel(host, status=status)
1033 self.setPresence(presence)
1034 entries = {}
1035 for value in C.PRESENCE.keys():
1036 entries.update({C.PRESENCE[value]: {"value": value}})
1037
1038 def callback(sender, key):
1039 self.setPresence(entries[key]["value"]) # order matters
1040 self.host.send([("STATUS", None)], self.status_panel.status)
1041
1042 self.presence_list = PopupMenuPanel(entries, callback=callback, style={"menu": "gwt-ListBox"})
1043 self.presence_list.registerClickSender(self.presence_button)
1044
1045 panel = HorizontalPanel()
1046 panel.add(self.presence_button)
1047 panel.add(self.status_panel)
1048 panel.setCellVerticalAlignment(self.presence_button, 'baseline')
1049 panel.setCellVerticalAlignment(self.status_panel, 'baseline')
1050 panel.setStyleName("marginAuto")
1051 self.add(panel)
1052
1053 self.status_panel.edit(False)
1054
1055 ClickHandler.__init__(self)
1056 self.addClickListener(self)
1057
1058 @property
1059 def presence(self):
1060 return self._presence
1061
1062 @property
1063 def status(self):
1064 return self.status_panel._original_content['text']
1065
1066 def setPresence(self, presence):
1067 self._presence = presence
1068 contact.setPresenceStyle(self.presence_button, self._presence)
1069
1070 def setStatus(self, status):
1071 self.status_panel.setContent({'text': status})
1072 self.status_panel.setDisplayContent()
1073
1074 def onClick(self, sender):
1075 # As status is the default target of uniBar, we don't want to select anything if click on it
1076 self.host.setSelected(None)
1077
1078
1079 class ChatPanel(base_widget.LiberviaWidget):
1080
1081 def __init__(self, host, target, type_='one2one'):
1082 """Panel used for conversation (one 2 one or group chat)
1083 @param host: SatWebFrontend instance
1084 @param target: entity (JID) with who we have a conversation (contact's jid for one 2 one chat, or MUC room)
1085 @param type: one2one for simple conversation, group for MUC"""
1086 base_widget.LiberviaWidget.__init__(self, host, title=target.bare, selectable=True)
1087 self.vpanel = VerticalPanel()
1088 self.vpanel.setSize('100%', '100%')
1089 self.type = type_
1090 self.nick = None
1091 if not target:
1092 log.error("Empty target !")
1093 return
1094 self.target = target
1095 self.__body = AbsolutePanel()
1096 self.__body.setStyleName('chatPanel_body')
1097 chat_area = HorizontalPanel()
1098 chat_area.setStyleName('chatArea')
1099 if type_ == 'group':
1100 self.occupants_list = OccupantsList()
1101 chat_area.add(self.occupants_list)
1102 self.__body.add(chat_area)
1103 self.content = AbsolutePanel()
1104 self.content.setStyleName('chatContent')
1105 self.content_scroll = base_widget.ScrollPanelWrapper(self.content)
1106 chat_area.add(self.content_scroll)
1107 chat_area.setCellWidth(self.content_scroll, '100%')
1108 self.vpanel.add(self.__body)
1109 self.vpanel.setCellHeight(self.__body, '100%')
1110 self.addStyleName('chatPanel')
1111 self.setWidget(self.vpanel)
1112 self.state_machine = ChatStateMachine(self.host, str(self.target))
1113 self._state = None
1114
1115 @classmethod
1116 def registerClass(cls):
1117 base_widget.LiberviaWidget.addDropKey("CONTACT", cls.createPanel)
1118
1119 @classmethod
1120 def createPanel(cls, host, item):
1121 _contact = item if isinstance(item, JID) else JID(item)
1122 host.contact_panel.setContactMessageWaiting(_contact.bare, False)
1123 _new_panel = ChatPanel(host, _contact) # XXX: pyjamas doesn't seems to support creating with cls directly
1124 _new_panel.historyPrint()
1125 host.setSelected(_new_panel)
1126 _new_panel.refresh()
1127 return _new_panel
1128
1129 def refresh(self):
1130 """Refresh the display of this widget. If the unibox is disabled,
1131 add a message box at the bottom of the panel"""
1132 self.host.contact_panel.setContactMessageWaiting(self.target.bare, False)
1133 self.content_scroll.scrollToBottom()
1134
1135 enable_box = self.host.uni_box is None
1136 if hasattr(self, 'message_box'):
1137 self.message_box.setVisible(enable_box)
1138 return
1139 if enable_box:
1140 self.message_box = MessageBox(self.host)
1141 self.message_box.onSelectedChange(self)
1142 self.vpanel.add(self.message_box)
1143
1144 def matchEntity(self, entity):
1145 """
1146 @param entity: target jid as a string or JID instance.
1147 Could also be a couple with a type in the second element.
1148 @return: True if self matches the given entity
1149 """
1150 if isinstance(entity, tuple):
1151 entity, type_ = entity if len(entity) > 1 else (entity[0], self.type)
1152 else:
1153 type_ = self.type
1154 entity = entity if isinstance(entity, JID) else JID(entity)
1155 try:
1156 return self.target.bare == entity.bare and self.type == type_
1157 except AttributeError as e:
1158 e.include_traceback()
1159 return False
1160
1161 def getWarningData(self):
1162 if self.type not in ["one2one", "group"]:
1163 raise Exception("Unmanaged type !")
1164 if self.type == "one2one":
1165 msg = "This message will be sent to your contact <span class='warningTarget'>%s</span>" % self.target
1166 elif self.type == "group":
1167 msg = "This message will be sent to all the participants of the multi-user room <span class='warningTarget'>%s</span>" % self.target
1168 return ("ONE2ONE" if self.type == "one2one" else "GROUP", msg)
1169
1170 def onTextEntered(self, text):
1171 self.host.send([("groupchat" if self.type == 'group' else "chat", str(self.target))], text)
1172 self.state_machine._onEvent("active")
1173
1174 def onQuit(self):
1175 base_widget.LiberviaWidget.onQuit(self)
1176 if self.type == 'group':
1177 self.host.bridge.call('mucLeave', None, self.target.bare)
1178
1179 def setUserNick(self, nick):
1180 """Set the nick of the user, usefull for e.g. change the color of the user"""
1181 self.nick = nick
1182
1183 def setPresents(self, nicks):
1184 """Set the users presents in this room
1185 @param occupants: list of nicks (string)"""
1186 self.occupants_list.clear()
1187 for nick in nicks:
1188 self.occupants_list.addOccupant(nick)
1189
1190 def userJoined(self, nick, data):
1191 self.occupants_list.addOccupant(nick)
1192 self.printInfo("=> %s has joined the room" % nick)
1193
1194 def userLeft(self, nick, data):
1195 self.occupants_list.removeOccupant(nick)
1196 self.printInfo("<= %s has left the room" % nick)
1197
1198 def changeUserNick(self, old_nick, new_nick):
1199 assert(self.type == "group")
1200 self.occupants_list.removeOccupant(old_nick)
1201 self.occupants_list.addOccupant(new_nick)
1202 self.printInfo(_("%(old_nick)s is now known as %(new_nick)s") % {'old_nick': old_nick, 'new_nick': new_nick})
1203
1204 def historyPrint(self, size=20):
1205 """Print the initial history"""
1206 def getHistoryCB(history):
1207 # display day change
1208 day_format = "%A, %d %b %Y"
1209 previous_day = datetime.now().strftime(day_format)
1210 for line in history:
1211 timestamp, from_jid, to_jid, message, mess_type, extra = line
1212 message_day = datetime.fromtimestamp(float(timestamp or time())).strftime(day_format)
1213 if previous_day != message_day:
1214 self.printInfo("* " + message_day)
1215 previous_day = message_day
1216 self.printMessage(from_jid, message, extra, timestamp)
1217 self.host.bridge.call('getHistory', getHistoryCB, self.host.whoami.bare, self.target.bare, size, True)
1218
1219 def printInfo(self, msg, type_='normal', link_cb=None):
1220 """Print general info
1221 @param msg: message to print
1222 @param type_: one of:
1223 "normal": general info like "toto has joined the room"
1224 "link": general info that is clickable like "click here to join the main room"
1225 "me": "/me" information like "/me clenches his fist" ==> "toto clenches his fist"
1226 @param link_cb: method to call when the info is clicked, ignored if type_ is not 'link'
1227 """
1228 _wid = HTML(msg) if type_ == 'link' else Label(msg)
1229 if type_ == 'normal':
1230 _wid.setStyleName('chatTextInfo')
1231 elif type_ == 'link':
1232 _wid.setStyleName('chatTextInfo-link')
1233 if link_cb:
1234 _wid.addClickListener(link_cb)
1235 elif type_ == 'me':
1236 _wid.setStyleName('chatTextMe')
1237 else:
1238 _wid.setStyleName('chatTextInfo')
1239 self.content.add(_wid)
1240
1241 def printMessage(self, from_jid, msg, extra, timestamp=None):
1242 """Print message in chat window. Must be implemented by child class"""
1243 _jid = JID(from_jid)
1244 nick = _jid.node if self.type == 'one2one' else _jid.resource
1245 mymess = _jid.resource == self.nick if self.type == "group" else _jid.bare == self.host.whoami.bare # mymess = True if message comes from local user
1246 if msg.startswith('/me '):
1247 self.printInfo('* %s %s' % (nick, msg[4:]), type_='me')
1248 return
1249 self.content.add(ChatText(timestamp, nick, mymess, msg, extra.get('xhtml')))
1250 self.content_scroll.scrollToBottom()
1251
1252 def startGame(self, game_type, waiting, referee, players, *args):
1253 """Configure the chat window to start a game"""
1254 classes = {"Tarot": CardPanel, "RadioCol": RadioColPanel}
1255 if game_type not in classes.keys():
1256 return # unknown game
1257 attr = game_type.lower()
1258 self.occupants_list.updateSpecials(players, SYMBOLS[attr])
1259 if waiting or not self.nick in players:
1260 return # waiting for player or not playing
1261 attr = "%s_panel" % attr
1262 if hasattr(self, attr):
1263 return
1264 log.info("%s Game Started \o/" % game_type)
1265 panel = classes[game_type](self, referee, self.nick, players, *args)
1266 setattr(self, attr, panel)
1267 self.vpanel.insert(panel, 0)
1268 self.vpanel.setCellHeight(panel, panel.getHeight())
1269
1270 def getGame(self, game_type):
1271 """Return class managing the game type"""
1272 # TODO: check that the game is launched, and manage errors
1273 if game_type == "Tarot":
1274 return self.tarot_panel
1275 elif game_type == "RadioCol":
1276 return self.radiocol_panel
1277
1278 def setState(self, state, nick=None):
1279 """Set the chat state (XEP-0085) of the contact. Leave nick to None
1280 to set the state for a one2one conversation, or give a nickname or
1281 C.ALL_OCCUPANTS to set the state of a participant within a MUC.
1282 @param state: the new chat state
1283 @param nick: None for one2one, the MUC user nick or ALL_OCCUPANTS
1284 """
1285 if nick:
1286 assert(self.type == 'group')
1287 occupants = self.occupants_list.occupants_list.keys() if nick == C.ALL_OCCUPANTS else [nick]
1288 for occupant in occupants:
1289 self.occupants_list.occupants_list[occupant].setState(state)
1290 else:
1291 assert(self.type == 'one2one')
1292 self._state = state
1293 self.refreshTitle()
1294 self.state_machine.started = not not state # start to send "composing" state from now
1295
1296 def refreshTitle(self):
1297 """Refresh the title of this ChatPanel dialog"""
1298 if self._state:
1299 self.setTitle(self.target.bare + " (" + self._state + ")")
1300 else:
1301 self.setTitle(self.target.bare)
1302
1303
1304 class WebPanel(base_widget.LiberviaWidget):
1305 """ (mini)browser like widget """
1306
1307 def __init__(self, host, url=None):
1308 """
1309 @param host: SatWebFrontend instance
1310 """
1311 base_widget.LiberviaWidget.__init__(self, host)
1312 self._vpanel = VerticalPanel()
1313 self._vpanel.setSize('100%', '100%')
1314 self._url = dialog.ExtTextBox(enter_cb=self.onUrlClick)
1315 self._url.setText(url or "")
1316 self._url.setWidth('100%')
1317 hpanel = HorizontalPanel()
1318 hpanel.add(self._url)
1319 btn = Button("Go", self.onUrlClick)
1320 hpanel.setCellWidth(self._url, "100%")
1321 #self.setCellWidth(btn, "10%")
1322 hpanel.add(self._url)
1323 hpanel.add(btn)
1324 self._vpanel.add(hpanel)
1325 self._vpanel.setCellHeight(hpanel, '20px')
1326 self._frame = Frame(url or "")
1327 self._frame.setSize('100%', '100%')
1328 DOM.setStyleAttribute(self._frame.getElement(), "position", "relative")
1329 self._vpanel.add(self._frame)
1330 self.setWidget(self._vpanel)
1331
1332 def onUrlClick(self, sender):
1333 self._frame.setUrl(self._url.getText())
1334
1335
1336 class MainPanel(AbsolutePanel):
1337
1338 def __init__(self, host):
1339 self.host = host
1340 AbsolutePanel.__init__(self)
1341
1342 # menu
1343 self.menu = Menu(host)
1344
1345 # unibox
1346 self.unibox_panel = UniBoxPanel(host)
1347 self.unibox_panel.setVisible(False)
1348
1349 # contacts
1350 self._contacts = HorizontalPanel()
1351 self._contacts.addStyleName('globalLeftArea')
1352 self.contacts_switch = Button(u'«', self._contactsSwitch)
1353 self.contacts_switch.addStyleName('contactsSwitch')
1354 self._contacts.add(self.contacts_switch)
1355 self._contacts.add(self.host.contact_panel)
1356
1357 # tabs
1358 self.tab_panel = base_widget.MainTabPanel(host)
1359 self.discuss_panel = base_widget.WidgetsPanel(self.host, locked=True)
1360 self.tab_panel.add(self.discuss_panel, "Discussions")
1361 self.tab_panel.selectTab(0)
1362
1363 self.header = AbsolutePanel()
1364 self.header.add(self.menu)
1365 self.header.add(self.unibox_panel)
1366 self.header.add(self.host.status_panel)
1367 self.header.setStyleName('header')
1368 self.add(self.header)
1369
1370 self._hpanel = HorizontalPanel()
1371 self._hpanel.add(self._contacts)
1372 self._hpanel.add(self.tab_panel)
1373 self.add(self._hpanel)
1374
1375 self.setWidth("100%")
1376 Window.addWindowResizeListener(self)
1377
1378 def _contactsSwitch(self, btn=None):
1379 """ (Un)hide contacts panel """
1380 if btn is None:
1381 btn = self.contacts_switch
1382 cpanel = self.host.contact_panel
1383 cpanel.setVisible(not cpanel.getVisible())
1384 btn.setText(u"«" if cpanel.getVisible() else u"»")
1385 self.host.resize()
1386
1387 def _contactsMove(self, parent):
1388 """Move the contacts container (containing the contact list and
1389 the "hide/show" button) to another parent, but always as the
1390 first child position (insert at index 0).
1391 """
1392 if self._contacts.getParent():
1393 if self._contacts.getParent() == parent:
1394 return
1395 self._contacts.removeFromParent()
1396 parent.insert(self._contacts, 0)
1397
1398 def onWindowResized(self, width, height):
1399 _elts = doc().getElementsByClassName('gwt-TabBar')
1400 if not _elts.length:
1401 tab_bar_h = 0
1402 else:
1403 tab_bar_h = _elts.item(0).offsetHeight
1404 ideal_height = Window.getClientHeight() - tab_bar_h
1405 self.setHeight("%s%s" % (ideal_height, "px"))
1406
1407 def refresh(self):
1408 """Refresh the main panel"""
1409 self.unibox_panel.refresh()