comparison browser/sat_browser/blog.py @ 1124:28e3eb3bb217

files reorganisation and installation rework: - files have been reorganised to follow other SàT projects and usual Python organisation (no more "/src" directory) - VERSION file is now used, as for other SàT projects - replace the overcomplicated setup.py be a more sane one. Pyjamas part is not compiled anymore by setup.py, it must be done separatly - removed check for data_dir if it's empty - installation tested working in virtual env - libervia launching script is now in bin/libervia
author Goffi <goffi@goffi.org>
date Sat, 25 Aug 2018 17:59:48 +0200
parents src/browser/sat_browser/blog.py@f2170536ba23
children 2af117bfe6cc
comparison
equal deleted inserted replaced
1123:63a4b8fe9782 1124:28e3eb3bb217
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # Libervia: a Salut à Toi frontend
5 # Copyright (C) 2011-2018 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 from sat.tools.common import data_format
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.ScrollPanel import ScrollPanel
29 from pyjamas.ui.HorizontalPanel import HorizontalPanel
30 from pyjamas.ui.Label import Label
31 from pyjamas.ui.HTML import HTML
32 from pyjamas.ui.Image import Image
33 from pyjamas.ui.ClickListener import ClickHandler
34 from pyjamas.ui.FlowPanel import FlowPanel
35 from pyjamas.ui import KeyboardListener as keyb
36 from pyjamas.ui.KeyboardListener import KeyboardHandler
37 from pyjamas.ui.FocusListener import FocusHandler
38 from pyjamas.ui.MouseListener import MouseHandler
39 from pyjamas.Timer import Timer
40
41 from datetime import datetime
42
43 import html_tools
44 import dialog
45 import richtext
46 import editor_widget
47 import libervia_widget
48 from constants import Const as C
49 from sat_frontends.quick_frontend import quick_widgets
50 from sat_frontends.quick_frontend import quick_blog
51
52 unicode = str # XXX: pyjamas doesn't manage unicode
53 ENTRY_RICH = (C.ENTRY_MODE_RICH, C.ENTRY_MODE_XHTML)
54
55
56 class Entry(quick_blog.Entry, VerticalPanel, ClickHandler, FocusHandler, KeyboardHandler):
57 """Graphical representation of a quick_blog.Item"""
58
59 def __init__(self, manager, item_data=None, comments_data=None, service=None, node=None):
60 quick_blog.Entry.__init__(self, manager, item_data, comments_data, service, node)
61
62 VerticalPanel.__init__(self)
63
64 self.panel = FlowPanel()
65 self.panel.setStyleName('mb_entry')
66
67 self.header = HorizontalPanel(StyleName='mb_entry_header')
68 self.panel.add(self.header)
69
70 self.entry_actions = VerticalPanel()
71 self.entry_actions.setStyleName('mb_entry_actions')
72 self.panel.add(self.entry_actions)
73
74 entry_avatar = SimplePanel()
75 entry_avatar.setStyleName('mb_entry_avatar')
76 author_jid = self.author_jid
77 self.avatar = Image(self.blog.host.getAvatarURL(author_jid) if author_jid is not None else C.DEFAULT_AVATAR_URL)
78 # TODO: show a warning icon if author is not validated
79 entry_avatar.add(self.avatar)
80 self.panel.add(entry_avatar)
81
82 self.entry_dialog = VerticalPanel()
83 self.entry_dialog.setStyleName('mb_entry_dialog')
84 self.panel.add(self.entry_dialog)
85
86 self.comments_panel = None
87 self._current_comment = None
88
89 self.add(self.panel)
90 ClickHandler.__init__(self)
91 self.addClickListener(self)
92
93 self.refresh()
94 self.displayed = False # True when entry is added to parent
95 if comments_data:
96 self.addComments(comments_data)
97
98 def refresh(self):
99 self.comment_label = None
100 self.update_label = None
101 self.delete_label = None
102 self.header.clear()
103 self.entry_dialog.clear()
104 self.entry_actions.clear()
105 self._setHeader()
106 self._setBubble()
107 self._setIcons()
108
109 def _setHeader(self):
110 """Set the entry header."""
111 if not self.new:
112 author = html_tools.html_sanitize(unicode(self.item.author))
113 author_jid = html_tools.html_sanitize(unicode(self.item.author_jid))
114 if author_jid and not self.item.author_verified:
115 author_jid += u' <span style="color:red; font-weight: bold;">⚠</span>'
116 if author:
117 author += " &lt;%s&gt;" % author_jid
118 elif author_jid:
119 author = author_jid
120 else:
121 author = _("<unknown author>")
122
123 update_text = u" — ✍ " + "<span class='mb_entry_timestamp'>%s</span>" % datetime.fromtimestamp(self.item.updated)
124 self.header.add(HTML("""<span class='mb_entry_header_info'>
125 <span class='mb_entry_author'>%(author)s</span> on
126 <span class='mb_entry_timestamp'>%(published)s</span>%(updated)s
127 </span>""" % {'author': author,
128 'published': datetime.fromtimestamp(self.item.published) if self.item.published is not None else '',
129 'updated': update_text if self.item.published != self.item.updated else ''
130 }))
131 if self.item.comments:
132 self.show_comments_link = HTML('')
133 self.header.add(self.show_comments_link)
134
135 def _setBubble(self):
136 """Set the bubble displaying the initial content."""
137 content = {'text': self.item.content_xhtml if self.item.content_xhtml else self.item.content or '',
138 'title': self.item.title_xhtml if self.item.title_xhtml else self.item.title or ''}
139 data_format.iter2dict('tag', self.item.tags, content)
140
141 if self.mode == C.ENTRY_MODE_TEXT:
142 # assume raw text message have no title
143 self.bubble = editor_widget.LightTextEditor(content, modifiedCb=self._modifiedCb, afterEditCb=self._afterEditCb, options={'no_xhtml': True})
144 elif self.mode in ENTRY_RICH:
145 content['syntax'] = C.SYNTAX_XHTML
146 if self.new:
147 options = []
148 elif self.item.author_jid == self.blog.host.whoami.bare:
149 options = ['update_msg']
150 else:
151 options = ['read_only']
152 self.bubble = richtext.RichTextEditor(self.blog.host, content, modifiedCb=self._modifiedCb, afterEditCb=self._afterEditCb, options=options)
153 else:
154 log.error("Bad entry mode: %s" % self.mode)
155 self.bubble.addStyleName("bubble")
156 self.entry_dialog.add(self.bubble)
157 self.bubble.addEditListener(self._showWarning) # FIXME: remove edit listeners
158 self.setEditable(self.editable)
159
160 def _setIcons(self):
161 """Set the entry icons (delete, update, comment)"""
162 if self.new:
163 return
164
165 def addIcon(label, title):
166 label = Label(label)
167 label.setTitle(title)
168 label.addClickListener(self)
169 self.entry_actions.add(label)
170 return label
171
172 if self.item.comments:
173 self.comment_label = addIcon(u"↶", "Comment this message")
174 self.comment_label.setStyleName('mb_entry_action_larger')
175 else:
176 self.comment_label = None
177 is_publisher = self.item.author_jid == self.blog.host.whoami.bare
178 if is_publisher:
179 self.update_label = addIcon(u"✍", "Edit this message")
180 # TODO: add delete button if we are the owner of the node
181 self.delete_label = addIcon(u"✗", "Delete this message")
182 else:
183 self.update_label = self.delete_label = None
184
185 def _createCommentsPanel(self):
186 """Create the panel if it doesn't exists"""
187 if self.comments_panel is None:
188 self.comments_panel = VerticalPanel()
189 self.comments_panel.setStyleName('microblogPanel')
190 self.comments_panel.addStyleName('subPanel')
191 self.add(self.comments_panel)
192
193 def setEditable(self, editable=True):
194 """Toggle the bubble between display and edit mode.
195
196 @param editable (bool)
197 """
198 self.editable = editable
199 self.bubble.edit(self.editable)
200 self.updateIconsAndButtons()
201
202 def updateIconsAndButtons(self):
203 """Set the visibility of the icons and the button to switch between blog and microblog."""
204 try:
205 self.bubble_commands.removeFromParent()
206 except (AttributeError, TypeError):
207 pass
208 if self.editable:
209 if self.mode == C.ENTRY_MODE_TEXT:
210 html = _(u'<a style="color: blue;">switch to blog</a>')
211 title = _(u'compose a rich text message with a title - suitable for writing articles')
212 else:
213 html = _(u'<a style="color: blue;">switch to microblog</a>')
214 title = _(u'compose a short message without title - suitable for sharing news')
215 toggle_syntax_button = HTML(html, Title=title)
216 toggle_syntax_button.addClickListener(self.toggleContentSyntax)
217 toggle_syntax_button.addStyleName('mb_entry_toggle_syntax')
218 toggle_syntax_button.setStyleAttribute('top', '-20px') # XXX: need to force CSS
219 toggle_syntax_button.setStyleAttribute('left', '-20px')
220
221 self.bubble_commands = HorizontalPanel(Width="100%")
222
223 if self.mode == C.ENTRY_MODE_TEXT:
224 publish_button = HTML(_(u'<a style="color: blue;">shift + enter to publish</a>'), Title=_(u"... or click here"))
225 publish_button.addStyleName('mb_entry_publish_button')
226 publish_button.addClickListener(lambda dummy: self.bubble.edit(False))
227 publish_button.setStyleAttribute('top', '-20px') # XXX: need to force CSS
228 publish_button.setStyleAttribute('left', '20px')
229 self.bubble_commands.add(publish_button)
230
231 self.bubble_commands.add(toggle_syntax_button)
232 self.entry_dialog.add(self.bubble_commands)
233
234 # hide these icons while editing
235 try:
236 self.delete_label.setVisible(not self.editable)
237 except (TypeError, AttributeError):
238 pass
239 try:
240 self.update_label.setVisible(not self.editable)
241 except (TypeError, AttributeError):
242 pass
243 try:
244 self.comment_label.setVisible(not self.editable)
245 except (TypeError, AttributeError):
246 pass
247
248 def onClick(self, sender):
249
250 if sender == self:
251 self.blog.setSelectedEntry(self)
252 elif sender == self.delete_label:
253 self._onRetractClick()
254 elif sender == self.update_label:
255 self.setEditable(True)
256 elif sender == self.comment_label:
257 self._onCommentClick()
258 # elif sender == self.show_comments_link:
259 # self._blog_panel.loadAllCommentsForEntry(self)
260
261 def _modifiedCb(self, content):
262 """Send the new content to the backend
263
264 @return: False to restore the original content if a deletion has been cancelled
265 """
266 if not content['text']: # previous content has been emptied
267 if not self.new:
268 self._onRetractClick()
269 return False
270
271 self.item.content = self.item.content_rich = self.item.content_xhtml = None
272 self.item.title = self.item.title_rich = self.item.title_xhtml = None
273
274 if self.mode in ENTRY_RICH:
275 # TODO: if the user change his parameters after the message edition started,
276 # the message syntax could be different then the current syntax: pass the
277 # message syntax in mb_data for the frontend to use it instead of current syntax.
278 self.item.content_rich = content['text'] # XXX: this also works if the syntax is XHTML
279 self.item.title = content['title']
280 self.item.tags = list(data_format.dict2iter('tag', content))
281 else:
282 self.item.content = content['text']
283
284 self.send()
285
286 return True
287
288 def _afterEditCb(self, content):
289 """Post edition treatments
290
291 Remove the entry if it was an empty one (used for creating a new blog post).
292 Data for the actual new blog post will be received from the bridge
293 @param content(dict): edited content
294 """
295 if self.new:
296 if self.level == 0:
297 # we have a main item, we keep the edit entry
298 self.reset(None)
299 # FIXME: would be better to reset bubble
300 # but bubble.setContent() doesn't seem to work
301 self.bubble.removeFromParent()
302 self._setBubble()
303 else:
304 # we don't keep edit entries for comments
305 self.delete()
306 else:
307 self.editable = False
308 self.updateIconsAndButtons()
309
310 def _showWarning(self, sender, keycode, modifiers):
311 if keycode == keyb.KEY_ENTER & keyb.MODIFIER_SHIFT: # FIXME: fix edit_listeners, it's dirty (we have to check keycode/modifiers twice !)
312 self.blog.host.showWarning(None, None)
313 else:
314 # self.blog.host.showWarning(*self.blog.getWarningData(self.type == 'comment'))
315 self.blog.host.showWarning(*self.blog.getWarningData(False)) # FIXME: comments are not yet reimplemented
316
317 def _onRetractClick(self):
318 """Ask confirmation then retract current entry."""
319 assert not self.new
320
321 def confirm_cb(answer):
322 if answer:
323 self.retract()
324
325 entry_type = _("message") if self.level == 0 else _("comment")
326 and_comments = _(" All comments will be also deleted!") if self.item.comments else ""
327 text = _("Do you really want to delete this {entry_type}?{and_comments}").format(
328 entry_type=entry_type, and_comments=and_comments)
329 dialog.ConfirmDialog(confirm_cb, text=text).show()
330
331 def _onCommentClick(self):
332 """Add an empty entry for a new comment"""
333 if self._current_comment is None:
334 if not self.item.comments_service or not self.item.comments_node:
335 log.warning("Invalid service and node for comments, can't create a comment")
336 self._current_comment = self.addEntry(editable=True, service=self.item.comments_service, node=self.item.comments_node, edit_entry=True)
337 self.blog.setSelectedEntry(self._current_comment, True)
338 self._current_comment.bubble.setFocus(True) # FIXME: should be done elsewhere (automatically)?
339
340 def _changeMode(self, original_content, text):
341 self.mode = C.ENTRY_MODE_RICH if self.mode == C.ENTRY_MODE_TEXT else C.ENTRY_MODE_TEXT
342 if self.mode in ENTRY_RICH and not text:
343 text = ' ' # something different than empty string is needed to initialize the rich text editor
344 self.item.content = text
345 if self.mode in ENTRY_RICH:
346 self.item.content_rich = text # XXX: this also works if the syntax is XHTML
347 self.bubble.setDisplayContent() # needed in case the edition is aborted, to not end with an empty bubble
348 else:
349 self.item.content_xhtml = ''
350 self.bubble.removeFromParent()
351 self._setBubble()
352 self.bubble.setOriginalContent(original_content)
353
354 def toggleContentSyntax(self):
355 """Toggle the editor between raw and rich text"""
356 original_content = self.bubble.getOriginalContent()
357 rich = self.mode in ENTRY_RICH
358 if rich:
359 original_content['syntax'] = C.SYNTAX_XHTML
360
361 text = self.bubble.getContent()['text']
362
363 if not text.strip():
364 self._changeMode(original_content,'')
365 else:
366 if rich:
367 def confirm_cb(answer):
368 if answer:
369 self.blog.host.bridge.syntaxConvert(text, C.SYNTAX_CURRENT, C.SYNTAX_TEXT, profile=None,
370 callback=lambda converted: self._changeMode(original_content, converted))
371 dialog.ConfirmDialog(confirm_cb, text=_("Do you really want to lose the title and text formatting?")).show()
372 else:
373 self.blog.host.bridge.syntaxConvert(text, C.SYNTAX_TEXT, C.SYNTAX_XHTML, profile=None,
374 callback=lambda converted: self._changeMode(original_content, converted))
375
376 def update(self, entry=None):
377 """Update comments"""
378 self._createCommentsPanel()
379 self.entries.sort(key=lambda entry: entry.item.published)
380 # we put edit_entry at the end
381 edit_entry = [] if self.edit_entry is None else [self.edit_entry]
382 for idx, entry in enumerate(self.entries + edit_entry):
383 if not entry.displayed:
384 self.comments_panel.insert(entry, idx)
385 entry.displayed = True
386
387 def delete(self):
388 quick_blog.Entry.delete(self)
389
390 # _current comment is specific to libervia, we remove it
391 if isinstance(self.manager, Entry):
392 self.manager._current_comment = None
393
394 # now we remove the pyjamas widgets
395 parent = self.parent
396 assert isinstance(parent, VerticalPanel)
397 self.removeFromParent()
398 if not parent.children:
399 # the vpanel is empty, we remove it
400 parent.removeFromParent()
401 try:
402 if self.manager.comments_panel == parent:
403 self.manager.comments_panel = None
404 except AttributeError:
405 assert isinstance(self.manager, quick_blog.QuickBlog)
406
407
408 class Blog(quick_blog.QuickBlog, libervia_widget.LiberviaWidget, MouseHandler):
409 """Panel used to show microblog"""
410 warning_msg_public = "This message will be <b>PUBLIC</b> and everybody will be able to see it, even people you don't know"
411 warning_msg_group = "This message will be published for all the people of the following groups: <span class='warningTarget'>%s</span>"
412
413 def __init__(self, host, targets, profiles=None):
414 quick_blog.QuickBlog.__init__(self, host, targets, C.PROF_KEY_NONE)
415 title = ", ".join(targets) if targets else "Blog"
416 libervia_widget.LiberviaWidget.__init__(self, host, title, selectable=True)
417 MouseHandler.__init__(self)
418 self.vpanel = VerticalPanel()
419 self.vpanel.setStyleName('microblogPanel')
420 self.setWidget(self.vpanel)
421 if ((self._targets_type == C.ALL and self.host.mblog_available) or
422 (self._targets_type == C.GROUP and self.host.groupblog_available)):
423 self.addEntry(editable=True, edit_entry=True)
424
425 self.getAll()
426
427 # self.footer = HTML('', StyleName='microblogPanel_footer')
428 # self.footer.waiting = False
429 # self.footer.addClickListener(self)
430 # self.footer.addMouseListener(self)
431 # self.vpanel.add(self.footer)
432 # self.next_rsm_index = 0
433
434 def __str__(self):
435 return u"Blog Widget [targets: {}, profile: {}]".format(", ".join(self.targets) if self.targets else "meta blog", self.profile)
436
437 def update(self):
438 self.entries.sort(key=lambda entry: entry.item.published, reverse=True)
439
440 start_idx = 0
441 if self.edit_entry is not None:
442 start_idx = 1
443 if not self.edit_entry.displayed:
444 self.vpanel.insert(self.edit_entry, 0)
445 self.edit_entry.displayed = True
446
447 # XXX: enumerate is buggued in pyjamas (start is not used)
448 # we have to use idx
449 idx = start_idx
450 for entry in self.entries:
451 if not entry.displayed:
452 self.vpanel.insert(entry, idx)
453 entry.displayed = True
454 idx += 1
455
456 # def onDelete(self):
457 # quick_widgets.QuickWidget.onDelete(self)
458 # self.host.removeListener('avatar', self.avatarListener)
459
460 # def onAvatarUpdate(self, jid_, hash_, profile):
461 # """Called on avatar update events
462
463 # @param jid_: jid of the entity with updated avatar
464 # @param hash_: hash of the avatar
465 # @param profile: %(doc_profile)s
466 # """
467 # whoami = self.host.profiles[self.profile].whoami
468 # if self.isJidAccepted(jid_) or jid_.bare == whoami.bare:
469 # self.updateValue('avatar', jid_, hash_)
470
471 @staticmethod
472 def onGroupDrop(host, targets):
473 """Create a microblog panel for one, several or all contact groups.
474
475 @param host (SatWebFrontend): the SatWebFrontend instance
476 @param targets (tuple(unicode)): tuple of groups (empty for "all groups")
477 @return: the created MicroblogPanel
478 """
479 # XXX: pyjamas doesn't support use of cls directly
480 widget = host.displayWidget(Blog, targets, dropped=True)
481 return widget
482
483 # @property
484 # def accepted_groups(self):
485 # """Return a set of the accepted groups"""
486 # return set().union(*self.targets)
487
488 def getWarningData(self, comment):
489 """
490 @param comment: set to True if the composed message is a comment
491 @return: a couple (type, msg) for calling self.host.showWarning"""
492 if comment:
493 return ("PUBLIC", "This is a <span class='warningTarget'>comment</span> and keep the initial post visibility, so it is potentialy public")
494 elif self._targets_type == C.ALL:
495 # we have a meta MicroblogPanel, we publish publicly
496 return ("PUBLIC", self.warning_msg_public)
497 else:
498 # FIXME: manage several groups
499 return (self._targets_type, self.warning_msg_group % ' '.join(self.targets))
500
501 def ensureVisible(self, entry):
502 """Scroll to an entry to ensure its visibility
503
504 @param entry (MicroblogEntry): the entry
505 """
506 current = entry
507 while True:
508 parent = current.getParent()
509 if parent is None:
510 log.warning("Can't find any parent ScrollPanel")
511 return
512 elif isinstance(parent, ScrollPanel):
513 parent.ensureVisible(entry)
514 return
515 else:
516 current = parent
517
518 def setSelectedEntry(self, entry, ensure_visible=False):
519 """Select an entry.
520
521 @param entry (MicroblogEntry): the entry to select
522 @param ensure_visible (boolean): if True, also scroll to the entry
523 """
524 if ensure_visible:
525 self.ensureVisible(entry)
526
527 entry.addStyleName('selected_entry') # blink the clicked entry
528 clicked_entry = entry # entry may be None when the timer is done
529 Timer(500, lambda timer: clicked_entry.removeStyleName('selected_entry'))
530
531 # def updateValue(self, type_, jid_, value):
532 # """Update a jid value in entries
533
534 # @param type_: one of 'avatar', 'nick'
535 # @param jid_(jid.JID): jid concerned
536 # @param value: new value"""
537 # assert isinstance(jid_, jid.JID) # FIXME: temporary
538 # def updateVPanel(vpanel):
539 # avatar_url = self.host.getAvatarURL(jid_)
540 # for child in vpanel.children:
541 # if isinstance(child, MicroblogEntry) and child.author == jid_:
542 # child.updateAvatar(avatar_url)
543 # elif isinstance(child, VerticalPanel):
544 # updateVPanel(child)
545 # if type_ == 'avatar':
546 # updateVPanel(self.vpanel)
547
548 # def onClick(self, sender):
549 # if sender == self.footer:
550 # self.loadMoreMainEntries()
551
552 # def onMouseEnter(self, sender):
553 # if sender == self.footer:
554 # self.loadMoreMainEntries()
555
556
557 libervia_widget.LiberviaWidget.addDropKey("GROUP", lambda host, item: Blog.onGroupDrop(host, (item,)))
558 libervia_widget.LiberviaWidget.addDropKey("CONTACT_TITLE", lambda host, item: Blog.onGroupDrop(host, ()))
559 quick_blog.registerClass("ENTRY", Entry)
560 quick_widgets.register(quick_blog.QuickBlog, Blog)