changeset 488:beedff600d2b

blog: blog widget implementation: this patch implements a basic blog widget. The search bare can be used to change node (only node for now, will be improved to do search and all). Publication on current node can be done by pressing the pencil icon. A checkbox can be activated to use end-to-end encryption. No pagination or comments are supported for now. Due to lack of HTML rendering in Kivy, only simple formatting is supported. If item is end-2-end encrypted, a green closed locker is shown next to publication date. rel 380
author Goffi <goffi@goffi.org>
date Sat, 15 Oct 2022 20:20:10 +0200
parents 38ca44d96752
children 8f3cb8cd84d6
files cagou/plugins/plugin_wid_blog.kv cagou/plugins/plugin_wid_blog.py
diffstat 2 files changed, 360 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cagou/plugins/plugin_wid_blog.kv	Sat Oct 15 20:20:10 2022 +0200
@@ -0,0 +1,168 @@
+# desktop/mobile frontend for Libervia XMPP client
+# Copyright (C) 2016-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+#:import date_fmt sat.tools.common.date_utils.date_fmt
+
+<SearchButton>:
+    size_hint: None, 1
+    symbol: "search"
+    width: dp(30)
+    font_size: dp(25)
+    color: 0.4, 0.4, 0.4, 1
+
+
+<NewPostButton>:
+    size_hint: None, 1
+    symbol: "pencil"
+    width: dp(30)
+    font_size: dp(25)
+    color: 0.4, 0.4, 0.4, 1
+
+<NewPosttMenu>:
+    padding: dp(20)
+    spacing: dp(10)
+    e2ee: e2ee_checkbox
+    Label:
+        size_hint: 1, None
+        color: 1, 1, 1, 1
+        text: _("Publish a new post on {node} node of {service}").format(node=root.blog.node or "personal blog", service=root.blog.service or root.blog.profile)
+        text_size: root.width, None
+        size: self.texture_size
+        halign: "center"
+        bold: True
+    TextInput:
+        id: title
+        size_hint: 1, None
+        height: sp(30)
+        hint_text: _("title of your post (optional)")
+    TextInput:
+        id: content
+        size_hint: 1, None
+        height: sp(300)
+        hint_text: _("body of your post (markdown syntax allowed)")
+    BoxLayout
+        id: e2ee
+        size_hint: 1, None
+        padding_y: None
+        height: dp(25)
+        Widget:
+        CheckBox:
+            id: e2ee_checkbox
+            size_hint: None, 1
+            width: dp(20)
+            active: False
+            color: 1, 1, 1, 1
+        Label:
+            size_hint: None, None
+            text: _("encrypt post")
+            text_size: None, None
+            size: self.texture_size
+            padding_x: dp(10)
+            font_size: sp(15)
+            color: 1, 1, 1, 1
+        Widget:
+    Button:
+        size_hint: 1, None
+        height: sp(50)
+        text: _("publish")
+        on_release: root.publish(title.text, content.text, e2ee=e2ee_checkbox.active)
+    Widget:
+
+
+<BlogPostAvatar>:
+    size_hint: None, None
+    size: dp(30), dp(30)
+    canvas.before:
+        Color:
+            rgba: (0.87,0.87,0.87,1)
+        RoundedRectangle:
+            radius: [dp(5)]
+            pos: self.pos
+            size: self.size
+
+<BlogPostWidget>:
+    size_hint: 1, None
+    avatar: avatar
+    header_box: header_box
+    height: self.minimum_height
+    orientation: "vertical"
+    Label:
+        color: 0, 0, 0, 1
+        bold: True
+        font_size: root.title_font_size
+        text_size: None, None
+        size_hint: None, None
+        size: self.texture_size[0], self.texture_size[1] if root.blog_data.get("title") else 0
+        opacity: 1 if root.blog_data.get("title") else 0
+        padding: dp(5), 0
+        text: root.blog_data.get("title", "")
+    BoxLayout:
+        id: header_box
+        size_hint: 1, None
+        height: dp(40)
+        BoxLayout:
+            orientation: 'vertical'
+            width: avatar.width
+            size_hint: None, 1
+            BlogPostAvatar:
+                id: avatar
+                source: app.default_avatar
+        Label:
+            id: created_ts
+            color: (0, 0, 0, 1)
+            font_size: root.font_size
+            text_size: None, None
+            size_hint: None, None
+            size: self.texture_size
+            padding: dp(5), 0
+            markup: True
+            valign: 'middle'
+            text: f"published on [b]{date_fmt(root.blog_data.get('published', 0), 'auto_day')}[/b]"
+        Symbol:
+            size_hint: None, None
+            height: created_ts.height
+            width: self.height
+            id: encrypted
+            symbol: 'lock-filled' if root.blog_data.get("encrypted") else 'lock-open'
+            font_size: created_ts.height
+            opacity: 1 if root.blog_data.get("encrypted") else 0
+            color: 0.29,0.87,0.0,1
+    SimpleXHTMLWidget:
+        size_hint: 1, None
+        height: self.minimum_height
+        xhtml: root.blog_data.get("content_xhtml") or self.escape(root.blog_data.get("content", ""))
+        color: (0, 0, 0, 1)
+        padding: dp(5), dp(5)
+
+
+<Blog>:
+    float_layout: float_layout
+    orientation: 'vertical'
+    posts_widget: posts_widget
+    FloatLayout:
+        id: float_layout
+        ScrollView:
+            size_hint: 1, 1
+            pos_hint: {'x': 0, 'y': 0}
+            do_scroll_x: False
+            scroll_type: ['bars', 'content']
+            bar_width: dp(6)
+            BoxLayout:
+                id: posts_widget
+                orientation: "vertical"
+                size_hint: 1, None
+                height: self.minimum_height
+                spacing: dp(10)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cagou/plugins/plugin_wid_blog.py	Sat Oct 15 20:20:10 2022 +0200
@@ -0,0 +1,192 @@
+#!/usr/bin/env python3
+
+#desktop/mobile frontend for Libervia XMPP client
+# Copyright (C) 2016-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from functools import partial
+import json
+from typing import Any, Dict, Optional
+
+from kivy import properties
+from kivy.metrics import sp
+from kivy.uix.behaviors import ButtonBehavior
+from kivy.uix.boxlayout import BoxLayout
+from sat.core import log as logging
+from sat.core.i18n import _
+from sat.tools.common import data_format
+from sat_frontends.bridge.bridge_frontend import BridgeException
+from sat_frontends.quick_frontend import quick_widgets
+from sat_frontends.tools import jid
+
+from cagou import G
+from cagou.core.menu import SideMenu
+
+from ..core import cagou_widget
+from ..core.common import SymbolButton
+from ..core.constants import Const as C
+from ..core.image import Image
+
+log = logging.getLogger(__name__)
+
+PLUGIN_INFO = {
+    "name": _("blog"),
+    "main": "Blog",
+    "description": _("(micro)blog"),
+    "icon_symbol": "pencil",
+}
+
+
+class SearchButton(SymbolButton):
+    blog = properties.ObjectProperty()
+
+    def on_release(self, *args):
+        self.blog.header_input.dispatch('on_text_validate')
+
+
+class NewPostButton(SymbolButton):
+    blog = properties.ObjectProperty()
+
+    def on_release(self, *args):
+        self.blog.show_new_post_menu()
+
+
+class NewPosttMenu(SideMenu):
+    blog = properties.ObjectProperty()
+    size_hint_close = (1, 0)
+    size_hint_open = (1, 0.9)
+
+    def _publish_cb(self, item_id: str) -> None:
+        G.host.addNote(
+            _("blog post published"),
+            _("your blog post has been published with ID {item_id}").format(
+                item_id=item_id
+            )
+        )
+        self.blog.load_blog()
+
+    def _publish_eb(self, exc: BridgeException) -> None:
+        G.host.addNote(
+            _("Problem while publish blog post"),
+            _("Can't publish blog post at {node!r} from {service}: {problem}").format(
+                node=self.blog.node or G.host.ns_map.get("microblog"),
+                service=(
+                    self.blog.service if self.blog.service
+                    else G.host.profiles[self.blog.profile].whoami,
+                ),
+                problem=exc
+            ),
+            C.XMLUI_DATA_LVL_ERROR
+        )
+
+    def publish(
+            self,
+            title: str,
+            content: str,
+            e2ee: bool = False
+    ) -> None:
+        self.hide()
+        mb_data: Dict[str, Any] = {"content_rich": content}
+        if e2ee:
+            mb_data["encrypted"] = True
+        title = title.strip()
+        if title:
+            mb_data["title_rich"] = title
+        G.host.bridge.mbSend(
+            self.blog.service,
+            self.blog.node,
+            data_format.serialise(mb_data),
+            self.blog.profile,
+            callback=self._publish_cb,
+            errback=self._publish_eb,
+        )
+
+
+class BlogPostAvatar(ButtonBehavior, Image):
+    pass
+
+
+class BlogPostWidget(BoxLayout):
+    blog_data = properties.DictProperty()
+    font_size = properties.NumericProperty(sp(12))
+    title_font_size = properties.NumericProperty(sp(14))
+
+
+class Blog(quick_widgets.QuickWidget, cagou_widget.CagouWidget):
+    posts_widget = properties.ObjectProperty()
+    service = properties.StringProperty()
+    node = properties.StringProperty()
+    use_header_input = True
+
+    def __init__(self, host, target, profiles):
+        quick_widgets.QuickWidget.__init__(self, G.host, target, profiles)
+        cagou_widget.CagouWidget.__init__(self)
+        search_btn = SearchButton(blog=self)
+        self.headerInputAddExtra(search_btn)
+        new_post_btn = NewPostButton(blog=self)
+        self.headerInputAddExtra(new_post_btn)
+        self.load_blog()
+
+    def on_kv_post(self, __):
+        self.bind(
+            service=lambda __, value: self.load_blog(),
+            node=lambda __, value: self.load_blog(),
+        )
+
+    def onHeaderInput(self):
+        text = self.header_input.text.strip()
+        # for now we only use text as node
+        self.node = text
+
+    def show_new_post_menu(self):
+        """Show the "add a contact" menu"""
+        NewPosttMenu(blog=self).show()
+
+    def _mb_get_cb(self, blog_data_s: str) -> None:
+        blog_data = json.loads(blog_data_s)
+        for item in blog_data["items"]:
+            self.posts_widget.add_widget(BlogPostWidget(blog_data=item))
+
+    def _mb_get_eb(
+        self,
+        exc: BridgeException,
+    ) -> None:
+        G.host.addNote(
+            _("Problem while getting blog data"),
+            _("Can't get blog for {node!r} at {service}: {problem}").format(
+                node=self.node or G.host.ns_map.get("microblog"),
+                service=self.service if self.service else G.host.profiles[self.profile].whoami,
+                problem=exc
+            ),
+            C.XMLUI_DATA_LVL_ERROR
+        )
+
+    def load_blog(
+        self,
+    ) -> None:
+        """Retrieve a blog and display it"""
+        extra = {}
+        self.posts_widget.clear_widgets()
+        G.host.bridge.mbGet(
+            self.service,
+            self.node,
+            20,
+            [],
+            data_format.serialise(extra),
+            self.profile,
+            callback=self._mb_get_cb,
+            errback=self._mb_get_eb,
+        )