Mercurial > libervia-web
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, ())) |