comparison src/browser/sat_browser/panels.py @ 589:a5019e62c3e9 frontends_multi_profiles

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