comparison src/browser/sat_browser/blog.py @ 679:a90cc8fc9605

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