comparison src/browser/sat_browser/blog.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 src/browser/sat_browser/panels.py@bade589dbd5a
children d78126d82ca0
comparison
equal deleted inserted replaced
585:bade589dbd5a 589:a5019e62c3e9
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.core.i18n import _
25
26 from pyjamas.ui.SimplePanel import SimplePanel
27 from pyjamas.ui.VerticalPanel import VerticalPanel
28 from pyjamas.ui.HorizontalPanel import HorizontalPanel
29 from pyjamas.ui.HTMLPanel import HTMLPanel
30 from pyjamas.ui.Label import Label
31 from pyjamas.ui.Button import Button
32 from pyjamas.ui.HTML import HTML
33 from pyjamas.ui.Image import Image
34 from pyjamas.ui.ClickListener import ClickHandler
35 from pyjamas.ui.FlowPanel import FlowPanel
36 from pyjamas.ui.KeyboardListener import KEY_ENTER, KeyboardHandler
37 from pyjamas.ui.FocusListener import FocusHandler
38 from pyjamas.Timer import Timer
39
40 from datetime import datetime
41 from time import time
42
43 import html_tools
44 import base_panels
45 import dialog
46 import base_widget
47 import richtext
48 from constants import Const as C
49 from sat_frontends.quick_frontend import quick_widgets
50
51 # TODO: at some point we should decide which behaviors to keep and remove these two constants
52 TOGGLE_EDITION_USE_ICON = False # set to True to use an icon inside the "toggle syntax" button
53 NEW_MESSAGE_USE_BUTTON = False # set to True to display the "New message" button instead of an empty entry
54
55
56 class MicroblogItem():
57 # XXX: should be moved in a separated module
58
59 def __init__(self, data):
60 self.id = data['id']
61 self.type = data.get('type', 'main_item')
62 self.empty = data.get('new', False)
63 self.title = data.get('title', '')
64 self.title_xhtml = data.get('title_xhtml', '')
65 self.content = data.get('content', '')
66 self.content_xhtml = data.get('content_xhtml', '')
67 self.author = data['author']
68 self.updated = float(data.get('updated', 0)) # XXX: int doesn't work here
69 self.published = float(data.get('published', self.updated)) # XXX: int doesn't work here
70 self.service = data.get('service', '')
71 self.node = data.get('node', '')
72 self.comments = data.get('comments', False)
73 self.comments_service = data.get('comments_service', '')
74 self.comments_node = data.get('comments_node', '')
75
76
77 class MicroblogEntry(SimplePanel, ClickHandler, FocusHandler, KeyboardHandler):
78
79 def __init__(self, blog_panel, data):
80 """
81 @param blog_panel: the parent panel
82 @param data: dict containing the blog item data, or a MicroblogItem instance.
83 """
84 self._base_item = data if isinstance(data, MicroblogItem) else MicroblogItem(data)
85 for attr in ['id', 'type', 'empty', 'title', 'title_xhtml', 'content', 'content_xhtml',
86 'author', 'updated', 'published', 'comments', 'service', 'node',
87 'comments_service', 'comments_node']:
88 getter = lambda attr: lambda inst: getattr(inst._base_item, attr)
89 setter = lambda attr: lambda inst, value: setattr(inst._base_item, attr, value)
90 setattr(MicroblogEntry, attr, property(getter(attr), setter(attr)))
91
92 SimplePanel.__init__(self)
93 self._blog_panel = blog_panel
94
95 self.panel = FlowPanel()
96 self.panel.setStyleName('mb_entry')
97
98 self.header = HTMLPanel('')
99 self.panel.add(self.header)
100
101 self.entry_actions = VerticalPanel()
102 self.entry_actions.setStyleName('mb_entry_actions')
103 self.panel.add(self.entry_actions)
104
105 entry_avatar = SimplePanel()
106 entry_avatar.setStyleName('mb_entry_avatar')
107 # FIXME
108 self.avatar = Image(C.DEFAULT_AVATAR) # self._blog_panel.host.getAvatar(self.author))
109 entry_avatar.add(self.avatar)
110 self.panel.add(entry_avatar)
111
112 if TOGGLE_EDITION_USE_ICON:
113 self.entry_dialog = HorizontalPanel()
114 else:
115 self.entry_dialog = VerticalPanel()
116 self.entry_dialog.setStyleName('mb_entry_dialog')
117 self.panel.add(self.entry_dialog)
118
119 self.add(self.panel)
120 ClickHandler.__init__(self)
121 self.addClickListener(self)
122
123 self.__pub_data = (self.service, self.node, self.id)
124 self.__setContent()
125
126 def __setContent(self):
127 """Actually set the entry content (header, icons, bubble...)"""
128 self.delete_label = self.update_label = self.comment_label = None
129 self.bubble = self._current_comment = None
130 self.__setHeader()
131 self.__setBubble()
132 self.__setIcons()
133
134 def __setHeader(self):
135 """Set the entry header"""
136 if self.empty:
137 return
138 update_text = u" — ✍ " + "<span class='mb_entry_timestamp'>%s</span>" % datetime.fromtimestamp(self.updated)
139 self.header.setHTML("""<div class='mb_entry_header'>
140 <span class='mb_entry_author'>%(author)s</span> on
141 <span class='mb_entry_timestamp'>%(published)s</span>%(updated)s
142 </div>""" % {'author': html_tools.html_sanitize(self.author),
143 'published': datetime.fromtimestamp(self.published),
144 'updated': update_text if self.published != self.updated else ''
145 }
146 )
147
148 def __setIcons(self):
149 """Set the entry icons (delete, update, comment)"""
150 if self.empty:
151 return
152
153 def addIcon(label, title):
154 label = Label(label)
155 label.setTitle(title)
156 label.addClickListener(self)
157 self.entry_actions.add(label)
158 return label
159
160 if self.comments:
161 self.comment_label = addIcon(u"↶", "Comment this message")
162 self.comment_label.setStyleName('mb_entry_action_larger')
163 is_publisher = self.author == self._blog_panel.host.whoami.bare
164 if is_publisher:
165 self.update_label = addIcon(u"✍", "Edit this message")
166 if is_publisher or str(self.node).endswith(self._blog_panel.host.whoami.bare):
167 self.delete_label = addIcon(u"✗", "Delete this message")
168
169 def updateAvatar(self, new_avatar):
170 """Change the avatar of the entry
171 @param new_avatar: path to the new image"""
172 self.avatar.setUrl(new_avatar)
173
174 def onClick(self, sender):
175 if sender == self:
176 self._blog_panel.setSelectedEntry(self)
177 elif sender == self.delete_label:
178 self._delete()
179 elif sender == self.update_label:
180 self.edit(True)
181 elif sender == self.comment_label:
182 self._comment()
183
184 def __modifiedCb(self, content):
185 """Send the new content to the backend
186 @return: False to restore the original content if a deletion has been cancelled
187 """
188 if not content['text']: # previous content has been emptied
189 self._delete(True)
190 return False
191 extra = {'published': str(self.published)}
192 if isinstance(self.bubble, richtext.RichTextEditor):
193 # TODO: if the user change his parameters after the message edition started,
194 # the message syntax could be different then the current syntax: pass the
195 # message syntax in extra for the frontend to use it instead of current syntax.
196 extra.update({'content_rich': content['text'], 'title': content['title']})
197 if self.empty:
198 if self.type == 'main_item':
199 self._blog_panel.host.bridge.call('sendMblog', None, None, self._blog_panel.accepted_groups, content['text'], extra)
200 else:
201 self._blog_panel.host.bridge.call('sendMblogComment', None, self._parent_entry.comments, content['text'], extra)
202 else:
203 self._blog_panel.host.bridge.call('updateMblog', None, self.__pub_data, self.comments, content['text'], extra)
204 return True
205
206 def __afterEditCb(self, content):
207 """Remove the entry if it was an empty one (used for creating a new blog post).
208 Data for the actual new blog post will be received from the bridge"""
209 if self.empty:
210 self._blog_panel.removeEntry(self.type, self.id)
211 if self.type == 'main_item': # restore the "New message" button
212 self._blog_panel.refresh()
213 else: # allow to create a new comment
214 self._parent_entry._current_comment = None
215 self.entry_dialog.setWidth('auto')
216 try:
217 self.toggle_syntax_button.removeFromParent()
218 except TypeError:
219 pass
220
221 def __setBubble(self, edit=False):
222 """Set the bubble displaying the initial content."""
223 content = {'text': self.content_xhtml if self.content_xhtml else self.content,
224 'title': self.title_xhtml if self.title_xhtml else self.title}
225 if self.content_xhtml:
226 content.update({'syntax': C.SYNTAX_XHTML})
227 if self.author != self._blog_panel.host.whoami.bare:
228 options = ['read_only']
229 else:
230 options = [] if self.empty else ['update_msg']
231 self.bubble = richtext.RichTextEditor(self._blog_panel.host, content, self.__modifiedCb, self.__afterEditCb, options)
232 else: # assume raw text message have no title
233 self.bubble = base_panels.LightTextEditor(content, self.__modifiedCb, self.__afterEditCb, options={'no_xhtml': True})
234 self.bubble.addStyleName("bubble")
235 try:
236 self.toggle_syntax_button.removeFromParent()
237 except TypeError:
238 pass
239 self.entry_dialog.add(self.bubble)
240 self.edit(edit)
241 self.bubble.addEditListener(self.__showWarning)
242
243 def __showWarning(self, sender, keycode):
244 if keycode == KEY_ENTER:
245 self._blog_panel.host.showWarning(None, None)
246 else:
247 self._blog_panel.host.showWarning(*self._blog_panel.getWarningData(self.type == 'comment'))
248
249 def _delete(self, empty=False):
250 """Ask confirmation for deletion.
251 @return: False if the deletion has been cancelled."""
252 def confirm_cb(answer):
253 if answer:
254 self._blog_panel.host.bridge.call('deleteMblog', None, self.__pub_data, self.comments)
255 else: # restore the text if it has been emptied during the edition
256 self.bubble.setContent(self.bubble._original_content)
257
258 if self.empty:
259 text = _("New ") + (_("message") if self.comments else _("comment")) + _(" without body has been cancelled.")
260 dialog.InfoDialog(_("Information"), text).show()
261 return
262 text = ""
263 if empty:
264 text = (_("Message") if self.comments else _("Comment")) + _(" body has been emptied.<br/>")
265 target = _('message and all its comments') if self.comments else _('comment')
266 text += _("Do you really want to delete this %s?") % target
267 dialog.ConfirmDialog(confirm_cb, text=text).show()
268
269 def _comment(self):
270 """Add an empty entry for a new comment"""
271 if self._current_comment:
272 self._current_comment.bubble.setFocus(True)
273 self._blog_panel.setSelectedEntry(self._current_comment, True)
274 return
275 data = {'id': str(time()),
276 'new': True,
277 'type': 'comment',
278 'author': self._blog_panel.host.whoami.bare,
279 'service': self.comments_service,
280 'node': self.comments_node
281 }
282 entry = self._blog_panel.addEntry(data)
283 if entry is None:
284 log.info("The entry of id %s can not be commented" % self.id)
285 return
286 entry._parent_entry = self
287 self._current_comment = entry
288 self.edit(True, entry)
289 self._blog_panel.setSelectedEntry(entry, True)
290
291 def edit(self, edit, entry=None):
292 """Toggle the bubble between display and edit mode
293 @edit: boolean value
294 @entry: MicroblogEntry instance, or None to use self
295 """
296 if entry is None:
297 entry = self
298 try:
299 entry.toggle_syntax_button.removeFromParent()
300 except TypeError:
301 pass
302 entry.bubble.edit(edit)
303 if edit:
304 if isinstance(entry.bubble, richtext.RichTextEditor):
305 image = '<a class="richTextIcon">A</a>'
306 html = '<a style="color: blue;">raw text</a>'
307 title = _('Switch to raw text edition')
308 else:
309 image = '<img src="media/icons/tango/actions/32/format-text-italic.png" class="richTextIcon"/>'
310 html = '<a style="color: blue;">rich text</a>'
311 title = _('Switch to rich text edition')
312 if TOGGLE_EDITION_USE_ICON:
313 entry.entry_dialog.setWidth('80%')
314 entry.toggle_syntax_button = Button(image, entry.toggleContentSyntax)
315 entry.toggle_syntax_button.setTitle(title)
316 entry.entry_dialog.add(entry.toggle_syntax_button)
317 else:
318 entry.toggle_syntax_button = HTML(html)
319 entry.toggle_syntax_button.addClickListener(entry.toggleContentSyntax)
320 entry.toggle_syntax_button.addStyleName('mb_entry_toggle_syntax')
321 entry.entry_dialog.add(entry.toggle_syntax_button)
322 entry.toggle_syntax_button.setStyleAttribute('top', '-20px') # XXX: need to force CSS
323 entry.toggle_syntax_button.setStyleAttribute('left', '-20px')
324
325 def toggleContentSyntax(self):
326 """Toggle the editor between raw and rich text"""
327 original_content = self.bubble.getOriginalContent()
328 rich = not isinstance(self.bubble, richtext.RichTextEditor)
329 if rich:
330 original_content['syntax'] = C.SYNTAX_XHTML
331
332 def setBubble(text):
333 self.content = text
334 self.content_xhtml = text if rich else ''
335 self.content_title = self.content_title_xhtml = ''
336 self.bubble.removeFromParent()
337 self.__setBubble(True)
338 self.bubble.setOriginalContent(original_content)
339 if rich:
340 self.bubble.setDisplayContent() # needed in case the edition is aborted, to not end with an empty bubble
341
342 text = self.bubble.getContent()['text']
343 if not text:
344 setBubble(' ') # something different than empty string is needed to initialize the rich text editor
345 return
346 if not rich:
347 def confirm_cb(answer):
348 if answer:
349 self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_CURRENT, C.SYNTAX_TEXT)
350 dialog.ConfirmDialog(confirm_cb, text=_("Do you really want to lose the title and text formatting?")).show()
351 else:
352 self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_TEXT, C.SYNTAX_XHTML)
353
354
355 class MicroblogPanel(quick_widgets.QuickWidget, base_widget.LiberviaWidget):
356 warning_msg_public = "This message will be <b>PUBLIC</b> and everybody will be able to see it, even people you don't know"
357 warning_msg_group = "This message will be published for all the people of the group <span class='warningTarget'>%s</span>"
358 # FIXME: all the generic parts must be moved to quick_frontends
359
360 def __init__(self, host, accepted_groups, profiles=None):
361 """Panel used to show microblog
362
363 @param accepted_groups: groups displayed in this panel, if empty, show all microblogs from all contacts
364 """
365 self.setAcceptedGroup(accepted_groups)
366 quick_widgets.QuickWidget.__init__(self, host, self.target, C.PROF_KEY_NONE)
367 base_widget.LiberviaWidget.__init__(self, host, ", ".join(accepted_groups), selectable=True)
368 self.entries = {}
369 self.comments = {}
370 self.selected_entry = None
371 self.vpanel = VerticalPanel()
372 self.vpanel.setStyleName('microblogPanel')
373 self.setWidget(self.vpanel)
374
375 @property
376 def target(self):
377 return tuple(self.accepted_groups)
378
379 def refresh(self):
380 """Refresh the display of this widget. If the unibox is disabled,
381 display the 'New message' button or an empty bubble on top of the panel"""
382 if hasattr(self, 'new_button'):
383 self.new_button.setVisible(self.host.uni_box is None)
384 return
385 if self.host.uni_box is None:
386 def addBox():
387 if hasattr(self, 'new_button'):
388 self.new_button.setVisible(False)
389 data = {'id': str(time()),
390 'new': True,
391 'author': self.host.whoami.bare,
392 }
393 entry = self.addEntry(data)
394 entry.edit(True)
395 if NEW_MESSAGE_USE_BUTTON:
396 self.new_button = Button("New message", listener=addBox)
397 self.new_button.setStyleName("microblogNewButton")
398 self.vpanel.insert(self.new_button, 0)
399 elif not self.getNewMainEntry():
400 addBox()
401
402 def getNewMainEntry(self):
403 """Get the new entry being edited, or None if it doesn't exists.
404
405 @return (MicroblogEntry): the new entry being edited.
406 """
407 try:
408 first = self.vpanel.children[0]
409 except IndexError:
410 return None
411 assert(first.type == 'main_item')
412 return first if first.empty else None
413
414 @classmethod
415 def registerClass(cls):
416 base_widget.LiberviaWidget.addDropKey("GROUP", cls.createPanel)
417 base_widget.LiberviaWidget.addDropKey("CONTACT_TITLE", cls.createMetaPanel)
418
419 @classmethod
420 def createPanel(cls, host, item):
421 """Generic panel creation for one, several or all groups (meta).
422 @parem host: the SatWebFrontend instance
423 @param item: single group as a string, list of groups
424 (as an array) or None (for the meta group = "all groups")
425 @return: the created MicroblogPanel
426 """
427 _items = item if isinstance(item, list) else ([] if item is None else [item])
428 _type = 'ALL' if _items == [] else 'GROUP'
429 # XXX: pyjamas doesn't support use of cls directly
430 _new_panel = MicroblogPanel(host, _items)
431 host.FillMicroblogPanel(_new_panel)
432 host.bridge.call('getMassiveLastMblogs', _new_panel.massiveInsert, _type, _items, 10)
433 host.setSelected(_new_panel)
434 _new_panel.refresh()
435 return _new_panel
436
437 @classmethod
438 def createMetaPanel(cls, host, item):
439 """Needed for the drop keys to not be mixed between meta panel and panel for "Contacts" group"""
440 return MicroblogPanel.createPanel(host, None)
441
442 @property
443 def accepted_groups(self):
444 return self._accepted_groups
445
446 def matchEntity(self, item):
447 """
448 @param item: single group as a string, list of groups
449 (as an array) or None (for the meta group = "all groups")
450 @return: True if self matches the given entity
451 """
452 groups = item if isinstance(item, list) else ([] if item is None else [item])
453 groups.sort() # sort() do not return the sorted list: do it here, not on the "return" line
454 return self.accepted_groups == groups
455
456 def getWarningData(self, comment=None):
457 """
458 @param comment: True if the composed message is a comment. If None, consider we are
459 composing from the unibox and guess the message type from self.selected_entry
460 @return: a couple (type, msg) for calling self.host.showWarning"""
461 if comment is None: # composing from the unibox
462 if self.selected_entry and not self.selected_entry.comments:
463 log.error("an item without comment is selected")
464 return ("NONE", None)
465 comment = self.selected_entry is not None
466 if comment:
467 return ("PUBLIC", "This is a <span class='warningTarget'>comment</span> and keep the initial post visibility, so it is potentialy public")
468 elif not self._accepted_groups:
469 # we have a meta MicroblogPanel, we publish publicly
470 return ("PUBLIC", self.warning_msg_public)
471 else:
472 # we only accept one group at the moment
473 # FIXME: manage several groups
474 return ("GROUP", self.warning_msg_group % self._accepted_groups[0])
475
476 def onTextEntered(self, text):
477 if self.selected_entry:
478 # we are entering a comment
479 comments_url = self.selected_entry.comments
480 if not comments_url:
481 raise Exception("ERROR: the comments URL is empty")
482 target = ("COMMENT", comments_url)
483 elif not self._accepted_groups:
484 # we are entering a public microblog
485 target = ("PUBLIC", None)
486 else:
487 # we are entering a microblog restricted to a group
488 # FIXME: manage several groups
489 target = ("GROUP", self._accepted_groups[0])
490 self.host.send([target], text)
491
492 def accept_all(self):
493 return not self._accepted_groups # we accept every microblog only if we are not filtering by groups
494
495 def getEntries(self):
496 """Ask all the entries for the currenly accepted groups,
497 and fill the panel"""
498
499 def massiveInsert(self, mblogs):
500 """Insert several microblogs at once
501 @param mblogs: dictionary of microblogs, as the result of getMassiveLastGroupBlogs
502 """
503 count = sum([len(value) for value in mblogs.values()])
504 log.debug("Massive insertion of %d microblogs" % count)
505 for publisher in mblogs:
506 log.debug("adding blogs for [%s]" % publisher)
507 for mblog in mblogs[publisher]:
508 if not "content" in mblog:
509 log.warning("No content found in microblog [%s]" % mblog)
510 continue
511 self.addEntry(mblog)
512
513 def mblogsInsert(self, mblogs):
514 """ Insert several microblogs at once
515 @param mblogs: list of microblogs
516 """
517 for mblog in mblogs:
518 if not "content" in mblog:
519 log.warning("No content found in microblog [%s]" % mblog)
520 continue
521 self.addEntry(mblog)
522
523 def _chronoInsert(self, vpanel, entry, reverse=True):
524 """ Insert an entry in chronological order
525 @param vpanel: VerticalPanel instance
526 @param entry: MicroblogEntry
527 @param reverse: more recent entry on top if True, chronological order else"""
528 assert(isinstance(reverse, bool))
529 if entry.empty:
530 entry.published = time()
531 # we look for the right index to insert our entry:
532 # if reversed, we insert the entry above the first entry
533 # in the past
534 idx = 0
535
536 for child in vpanel.children:
537 if not isinstance(child, MicroblogEntry):
538 idx += 1
539 continue
540 condition_to_stop = child.empty or (child.published > entry.published)
541 if condition_to_stop != reverse: # != is XOR
542 break
543 idx += 1
544
545 vpanel.insert(entry, idx)
546
547 def addEntry(self, data):
548 """Add an entry to the panel
549 @param data: dict containing the item data
550 @return: the added entry, or None
551 """
552 _entry = MicroblogEntry(self, data)
553 if _entry.type == "comment":
554 comments_hash = (_entry.service, _entry.node)
555 if not comments_hash in self.comments:
556 # The comments node is not known in this panel
557 return None
558 parent = self.comments[comments_hash]
559 parent_idx = self.vpanel.getWidgetIndex(parent)
560 # we find or create the panel where the comment must be inserted
561 try:
562 sub_panel = self.vpanel.getWidget(parent_idx + 1)
563 except IndexError:
564 sub_panel = None
565 if not sub_panel or not isinstance(sub_panel, VerticalPanel):
566 sub_panel = VerticalPanel()
567 sub_panel.setStyleName('microblogPanel')
568 sub_panel.addStyleName('subPanel')
569 self.vpanel.insert(sub_panel, parent_idx + 1)
570 for idx in xrange(0, len(sub_panel.getChildren())):
571 comment = sub_panel.getIndexedChild(idx)
572 if comment.id == _entry.id:
573 # update an existing comment
574 sub_panel.remove(comment)
575 sub_panel.insert(_entry, idx)
576 return _entry
577 # we want comments to be inserted in chronological order
578 self._chronoInsert(sub_panel, _entry, reverse=False)
579 return _entry
580
581 if _entry.id in self.entries: # update
582 idx = self.vpanel.getWidgetIndex(self.entries[_entry.id])
583 self.vpanel.remove(self.entries[_entry.id])
584 self.vpanel.insert(_entry, idx)
585 else: # new entry
586 self._chronoInsert(self.vpanel, _entry)
587 self.entries[_entry.id] = _entry
588
589 if _entry.comments:
590 # entry has comments, we keep the comments service/node as a reference
591 comments_hash = (_entry.comments_service, _entry.comments_node)
592 self.comments[comments_hash] = _entry
593 self.host.bridge.call('getMblogComments', self.mblogsInsert, _entry.comments_service, _entry.comments_node)
594
595 return _entry
596
597 def removeEntry(self, type_, id_):
598 """Remove an entry from the panel
599 @param type_: entry type ('main_item' or 'comment')
600 @param id_: entry id
601 """
602 for child in self.vpanel.getChildren():
603 if isinstance(child, MicroblogEntry) and type_ == 'main_item':
604 if child.id == id_:
605 main_idx = self.vpanel.getWidgetIndex(child)
606 try:
607 sub_panel = self.vpanel.getWidget(main_idx + 1)
608 if isinstance(sub_panel, VerticalPanel):
609 sub_panel.removeFromParent()
610 except IndexError:
611 pass
612 child.removeFromParent()
613 self.selected_entry = None
614 break
615 elif isinstance(child, VerticalPanel) and type_ == 'comment':
616 for comment in child.getChildren():
617 if comment.id == id_:
618 comment.removeFromParent()
619 self.selected_entry = None
620 break
621
622 def ensureVisible(self, entry):
623 """Scroll to an entry to ensure its visibility
624
625 @param entry (MicroblogEntry): the entry
626 """
627 try:
628 self.vpanel.getParent().ensureVisible(entry) # scroll to the clicked entry
629 except AttributeError:
630 log.warning("FIXME: MicroblogPanel.vpanel should be wrapped in a ScrollPanel!")
631
632 def setSelectedEntry(self, entry, ensure_visible=False):
633 """Select an entry.
634
635 @param entry (MicroblogEntry): the entry to select
636 @param ensure_visible (boolean): if True, also scroll to the entry
637 """
638 if ensure_visible:
639 self.ensureVisible(entry)
640
641 if not self.host.uni_box or not entry.comments:
642 entry.addStyleName('selected_entry') # blink the clicked entry
643 clicked_entry = entry # entry may be None when the timer is done
644 Timer(500, lambda timer: clicked_entry.removeStyleName('selected_entry'))
645 if not self.host.uni_box:
646 return # unibox is disabled
647
648 # from here the previous behavior (toggle main item selection) is conserved
649 entry = entry if entry.comments else None
650 if self.selected_entry == entry:
651 entry = None
652 if self.selected_entry:
653 self.selected_entry.removeStyleName('selected_entry')
654 if entry:
655 log.debug("microblog entry selected (author=%s)" % entry.author)
656 entry.addStyleName('selected_entry')
657 self.selected_entry = entry
658
659 def updateValue(self, type_, jid, value):
660 """Update a jid value in entries
661 @param type_: one of 'avatar', 'nick'
662 @param jid: jid concerned
663 @param value: new value"""
664 def updateVPanel(vpanel):
665 for child in vpanel.children:
666 if isinstance(child, MicroblogEntry) and child.author == jid:
667 child.updateAvatar(value)
668 elif isinstance(child, VerticalPanel):
669 updateVPanel(child)
670 if type_ == 'avatar':
671 updateVPanel(self.vpanel)
672
673 def setAcceptedGroup(self, group):
674 """Add one or more group(s) which can be displayed in this panel.
675
676 Prevent from duplicate values and keep the list sorted.
677 @param group: string of the group, or list of string
678 """
679 if isinstance(group, basestring):
680 groups = [group]
681 else:
682 groups = list(group)
683 try:
684 self._accepted_groups.extend(groups)
685 except (AttributeError, TypeError): # XXX: should be AttributeError, but pyjamas bugs here
686 self._accepted_groups = groups
687 self._accepted_groups.sort()
688
689 def isJidAccepted(self, jid_s):
690 """Tell if a jid is actepted and shown in this panel
691 @param jid_s: jid
692 @return: True if the jid is accepted"""
693 if self.accept_all():
694 return True
695 for group in self._accepted_groups:
696 if self.host.contact_panel.isContactInGroup(group, jid_s):
697 return True
698 return False