changeset 1642:c03297bb8d19

server, browser (forums): Redesign of the forum feature: Forum has been fully redesigned, it now uses pubsub relationships and pubsub extended discovery to handle the hierarchy. Categories lead to topics (which are the name of the items of a blog-like nodes). Topics have comment nodes which are used to show the topic messages. Subscription state is retrieve when a thread is shown, and can be changed easily with as ingle button click. Quill editor is used, and is extended with Quill-Mention to easily send a mention to an XMPP entity. Attachments and tags are handled with buttons added to Quill editor. Search in a thread is using Pubsub MAM, is the same way as for blogs. rel 463
author Goffi <goffi@goffi.org>
date Sat, 06 Sep 2025 16:30:46 +0200
parents b30878c8b633
children 0e11dbbcefe7
files libervia/web/pages/chat/_browser/__init__.py libervia/web/pages/forums/list/page_meta.py libervia/web/pages/forums/topics/page_meta.py libervia/web/pages/forums/view/_browser/__init__.py libervia/web/pages/forums/view/page_meta.py libervia/web/server/pages.py libervia/web/server/restricted_bridge.py
diffstat 7 files changed, 582 insertions(+), 87 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/web/pages/chat/_browser/__init__.py	Sat Sep 06 12:12:42 2025 +0200
+++ b/libervia/web/pages/chat/_browser/__init__.py	Sat Sep 06 16:30:46 2025 +0200
@@ -287,7 +287,9 @@
 
         # attachments
         self.file_uploader = FileUploader(
-            "", "chat/attachment_preview.html", on_delete_cb=self.on_attachment_delete
+            "",
+            "components/attachment_preview.html",
+            on_delete_cb=self.on_attachment_delete
         )
         self.attachments_elt = document["attachments"]
         self.message_input = document["message_input_area"]
--- a/libervia/web/pages/forums/list/page_meta.py	Sat Sep 06 12:12:42 2025 +0200
+++ b/libervia/web/pages/forums/list/page_meta.py	Sat Sep 06 16:30:46 2025 +0200
@@ -1,9 +1,10 @@
 #!/usr/bin/env python3
 
+from twisted.words.protocols.jabber import jid
 from libervia.web.server.constants import Const as C
 from libervia.backend.core.log import getLogger
 from libervia.backend.core.i18n import _
-from libervia.backend.tools.common import uri as xmpp_uri
+from libervia.backend.tools.common import data_format, uri as xmpp_uri
 
 log = getLogger(__name__)
 import json
@@ -12,16 +13,15 @@
 
 name = "forums"
 access = C.PAGES_ACCESS_PUBLIC
-template = "forum/overview.html"
+template = "forum/forum.html"
 
 
 def parse_url(self, request):
     self.get_path_args(
         request,
-        ["service", "node", "forum_key"],
+        ["service", "node"],
         service="@jid",
         node="@",
-        forum_key="",
     )
 
 
@@ -31,36 +31,88 @@
     pass
 
 
-def get_links(self, forums):
-    for forum in forums:
-        try:
-            uri = forum["uri"]
-        except KeyError:
-            pass
+def process_disco_data(page, service: jid.JID, ps_ext_disco_data: list) -> list:
+    """Convert pubsub disco data data into template-friendly structure"""
+
+    def process_node(node_data, depth=0):
+        name = node_data["name"]
+        is_main_category = name.startswith("urn:xmpp:forum:category:")
+        metadata = node_data.get("metadata", {})
+        title = metadata.get("title", name)
+        description = metadata.get("description", "")
+
+        forum = {
+            "title": title,
+            "forum_type": "main_category" if is_main_category else "sub_category",
+            "description": description,
+            "node": name,
+            "last_activity": metadata.get("last_item_publication_date"),
+            "depth": depth,
+            "http_url": page.get_page_by_name("forum_topics").get_url(
+                service.full(), name
+            )
+        }
+        if "nb_items" in metadata:
+            forum["nb_items"] = metadata["nb_items"]
+
+        if is_main_category:
+            forum_children = forum["children"] = []
+            for child in node_data.get("children", []):
+                processed = process_node(child, depth + 1)
+                if processed:
+                    forum_children.append(processed)
+
+        return forum
+
+    forums = []
+    for node_data in ps_ext_disco_data:
+        # We skip the root node (urn:xmpp:forum:0).
+        if node_data.get("name") == "urn:xmpp:forum:0":
+            for child in node_data.get("children", []):
+                processed = process_node(child, 0)
+                if processed:
+                    forums.append(processed)
         else:
-            uri = xmpp_uri.parse_xmpp_uri(uri)
-            service = uri["path"]
-            node = uri["node"]
-            forum["http_url"] = self.get_page_by_name("forum_topics").get_url(service, node)
-        if "sub-forums" in forum:
-            get_links(self, forum["sub-forums"])
+            processed = process_node(node_data, 0)
+            if processed:
+                forums.append(processed)
+
+    return forums
 
 
 async def prepare_render(self, request):
     data = self.get_r_data(request)
     template_data = request.template_data
-    service, node, key = data["service"], data["node"], data["forum_key"]
+    service, node = data["service"], data["node"]
     profile = self.get_profile(request) or C.SERVICE_PROFILE
 
     try:
-        forums_raw = await self.host.bridge_call(
-            "forums_get", service.full() if service else "", node, key, profile
+        ps_disco_data_s = await self.host.bridge_call(
+            "ps_disco_get",
+            service.full() if service else "",
+            node,
+            data_format.serialise(
+                {
+                    "depth": 3,
+                }
+            ),
+            profile,
         )
     except Exception as e:
         log.warning(_("Can't retrieve forums: {msg}").format(msg=e))
-        forums = []
+        ps_disco_data = []
     else:
-        forums = json.loads(forums_raw)
-    get_links(self, forums)
+        ps_disco_data = json.loads(ps_disco_data_s)
+
+    template_data["forums"] = process_disco_data(self, service, ps_disco_data)
 
-    template_data["forums"] = forums
+async def on_data_post(self, request):
+    jid_str = self.get_posted_data(request, "jid")
+    try:
+        jid_ = jid.JID(jid_str)
+    except RuntimeError:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+        return
+    url = self.get_url(jid_.full(), "@")
+    self.http_redirect(request, url)
+
--- a/libervia/web/pages/forums/topics/page_meta.py	Sat Sep 06 12:12:42 2025 +0200
+++ b/libervia/web/pages/forums/topics/page_meta.py	Sat Sep 06 16:30:46 2025 +0200
@@ -21,11 +21,7 @@
 
 
 def add_breadcrumb(self, request, breadcrumbs):
-    data = self.get_r_data(request)
-    breadcrumbs.append({
-        "label": label,
-        "url": self.get_url(data["service"].full(), data["node"])
-    })
+    return None
 
 
 async def prepare_render(self, request):
@@ -36,31 +32,35 @@
     template_data = request.template_data
     page_max = data.get("page_max", 20)
     extra = self.get_pubsub_extra(request, page_max=page_max)
-    topics, metadata = await self.host.bridge_call(
-        "forum_topics_get",
-        service.full(),
-        node,
-        extra,
-        profile
+    if not self.use_cache(request):
+        extra[C.KEY_USE_CACHE] = False
+    blog_data = data_format.deserialise(
+        await self.host.bridge_call(
+            'mb_get',
+            service.userhost(),
+            node,
+            C.NO_LIMIT,
+            [],
+            data_format.serialise(extra),
+            profile
+        )
     )
-    metadata = data_format.deserialise(metadata)
-    self.set_pagination(request, metadata)
-    identities = self.host.get_session_data(
-        request, session_iface.IWebSession
-    ).identities
-    for topic in topics:
-        parsed_uri = xmpp_uri.parse_xmpp_uri(topic["uri"])
-        author = topic["author"]
-        topic["http_uri"] = self.get_page_by_name("forum_view").get_url(
-            parsed_uri["path"], parsed_uri["node"]
+    for item in blog_data["items"]:
+        try:
+            # We check that there is a comment node for this topic.
+            item["comments"][0]["service"]
+            item["comments"][0]["node"]
+        except (KeyError, IndexError):
+            log.warning(f"Can't get comments node for item: {item}")
+            continue
+        else:
+            item["http_url"] = self.get_page_by_name("forum_view").get_url(
+            service.full(), node, item["id"]
         )
-        if author not in identities:
-            id_raw = await self.host.bridge_call(
-                "identity_get", author, [], True, profile
-            )
-            identities[topic["author"]] = data_format.deserialise(id_raw)
+    self.set_pagination(request, blog_data)
+    await self.fill_missing_identities(request, [i['author_jid'] for i in blog_data['items']])
 
-    template_data["topics"] = topics
+    template_data['blog_items'] = blog_data
     template_data["url_topic_new"] = self.get_sub_page_url(request, "forum_topic_new")
 
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/forums/view/_browser/__init__.py	Sat Sep 06 16:30:46 2025 +0200
@@ -0,0 +1,338 @@
+from bridge import AsyncBridge as Bridge
+from browser import aio, console as log, document, window
+from cache import cache, identities
+import dialog
+import editor
+import json
+from file_uploader import FileUploader
+from js_modules.quill import Quill
+
+# We need to import `quill_mention` after `quill` to make it install the extensions.
+from js_modules import quill_mention
+from template import Template
+
+
+log.warning = log.warn
+bridge = Bridge()
+service = window.service
+node = window.node
+
+
+class LiberviaForumView:
+
+    def __init__(self) -> None:
+        self.message_editor = Quill.new(
+            "#message_editor",
+            {
+                "theme": "snow",
+                "modules": {
+                    "toolbar": [
+                        [{"header": [1, 2, 3, False]}],
+                        ["bold", "italic", "underline", "strike", "code", "link"],
+                        [
+                            "blockquote",
+                            "code-block",
+                            {"list": "ordered"},
+                            {"list": "bullet"},
+                        ],
+                        ["clean"],
+                    ],
+                    "mention": {
+                        "allowedChars": window.RegExp(r"^[\p{L}\p{N}\-_\+%. ]*$", "u"),
+                        "mentionDenotationChars": ["@"],
+                        "source": lambda search_term, render_list, mention_char: aio.run(
+                            self.get_mentions(search_term, render_list, mention_char)
+                        ),
+                    },
+                },
+            },
+        )
+        self._add_custom_buttons()
+
+        self.subscribe_btn_tpl = Template("forum/subscribe_button.html")
+
+        # Tags
+        self.tag_editor = editor.TagsEditor("#tags")
+
+        # Attachments
+        self.file_uploader = FileUploader(
+            "",
+            "components/attachment_preview.html",
+            on_delete_cb=self.on_attachment_delete,
+        )
+        self.attachments_elt = document["forum-attachments"]
+
+        # Search
+
+        self.search_field = document["search_thread"]
+        self.search_field.bind("keydown", self.on_search_field_keydown)
+        document["search_thread_button"].bind("click", self.on_search)
+
+
+        document["send-message-button"].bind("click", self._send_message)
+        document["subscribe-button"].bind("click", self._toggle_subscription)
+        document["forum-file-input"].bind("change", self.on_file_selected)
+
+        editor_container = document.select_one("#message_editor")
+        attachments_area = document.select_one("#attachments-area")
+
+        editor_container.bind("drop", self.on_file_drop)
+        editor_container.bind("dragover", self.on_drag_over)
+        attachments_area.bind("drop", self.on_file_drop)
+        attachments_area.bind("dragover", self.on_drag_over)
+
+    def _identity_to_mention_item(
+        self, entity_jid: str, entity_data: dict | None = None
+    ) -> dict:
+        """Convert entity data to an item usable for mentions.
+
+        @param entity_jid: JID of the matching entity.
+        @param entity_data: Data of the matching entity.
+            If None, retrieve data from ``identities`` cache.
+        @return: data usable for mentions.
+        """
+        if entity_data is None:
+            entity_data = identities.get(entity_jid) or {}
+
+        try:
+            name = entity_data["nicknames"][0]
+        except (KeyError, IndexError):
+            name = str(entity_data)
+        return {"id": str(entity_jid), "value": name}
+
+    async def get_mentions(self, search_term, render_list, mention_char) -> None:
+        """Retrieve mentions based on search term and mention character.
+
+        This method is called by editor when a mention is requested.
+        @param search_term: The term to search for in the mentions.
+        @param render_list: Function to render the list of mentions.
+        @param mention_char: The character indicating the type of mention ("@" or "#")
+        """
+        if not search_term:
+            render_list(
+                [
+                    self._identity_to_mention_item(entity_jid, entity_data)
+                    for entity_jid, entity_data in identities.items()
+                ]
+            )
+        else:
+            render_list(
+                [
+                    self._identity_to_mention_item(entity_jid)
+                    for entity_jid in cache.matching_identities(search_term)
+                ]
+            )
+
+    def _add_custom_buttons(self) -> None:
+
+        extra_buttons_span = document.createElement("span")
+        extra_buttons_span.className = "extra-buttons"
+
+        # Tag button
+        tag_btn = document.createElement("button")
+        tag_btn.className = "extra-btn-tag"
+        tag_btn.innerHTML = '<i class="fas fa-tags"></i>'
+        tag_btn.title = "Add Tag"
+        tag_btn.onclick = self.on_add_tag
+        extra_buttons_span.appendChild(tag_btn)
+
+        # Attachment button
+        attachment_btn = document.createElement("button")
+        attachment_btn.className = "extra-btn-attachment"
+        attachment_btn.innerHTML = '<i class="fas fa-paperclip"></i>'
+        attachment_btn.title = "Add Attachment"
+        attachment_btn.onclick = self.on_add_attachment
+        extra_buttons_span.appendChild(attachment_btn)
+
+        toolbar = self.message_editor.getModule("toolbar")
+        toolbar.container.appendChild(extra_buttons_span)
+
+    def _clean_html(self, html: str) -> str:
+        """Remove unwanted elements added by Quill."""
+        # FIXME: this is a Q&D method to clean the elements added in particular by
+        #   Quill-Mention. This would probably be done in a cleaner way by rewriting the
+        #   Mention Blot, and setting the new one with `blotName`.
+
+        temp_container = document.createElement("div")
+        temp_container.innerHTML = html
+
+        spans_to_remove = []
+        all_spans = temp_container.querySelectorAll("span")
+
+        for span in all_spans:
+            # Check for unwanted elements.
+            classes = span.className.split() if span.className else []
+            # Those classes are added by Quill-Mention
+            if any(
+                cls in ["mention", "ql-mention-denotation-char", "ql-mention-value"]
+                for cls in classes
+            ):
+                spans_to_remove.append(span)
+            # Quill-Mention also adds a <span contenteditable=false> element.
+            elif span.hasAttribute("contenteditable"):
+                spans_to_remove.append(span)
+
+        # Remove the spans by replacing them with their content
+        for span in spans_to_remove:
+            if span.parentNode:
+                fragment = document.createDocumentFragment()
+                while span.firstChild:
+                    fragment.appendChild(span.firstChild)
+                span.parentNode.replaceChild(fragment, span)
+
+        return temp_container.innerHTML
+
+    def _send_message(self, evt) -> None:
+        evt.preventDefault()
+        aio.run(self.send_message())
+
+    async def send_message(self) -> None:
+        mb_data = {
+            "service": service,
+            "node": node,
+            "content_xhtml": self._clean_html(self.message_editor.getSemanticHTML()),
+            "tags": list(self.tag_editor.current_tags)
+        }
+
+        # Attachments.
+        attachments = []
+        for attachment_elt in self.attachments_elt.children:
+            file_data = json.loads(attachment_elt.getAttribute("data-file"))
+            attachments.append({
+                "sources": [{"url": file_data["url"]}],
+                "media_type": file_data["mime_type"],
+                "desc": file_data["name"]
+
+            })
+        if attachments:
+            mb_data["attachments"] = attachments
+
+        # Mentions.
+        mentions = []
+        delta = dict(self.message_editor.getContents())
+        ops = delta.get("ops", [])
+        for op in ops:
+            window.op = op
+            op = dict(op)
+            try:
+                mention_data = op["insert"]["mention"]
+            except (TypeError, KeyError):
+                continue
+            else:
+                mentions.append(mention_data["id"])
+        if "mentions":
+            mb_data["mentions"] = mentions
+
+        log.debug(f"Sending message:\n{mb_data}")
+        try:
+            await bridge.mb_send(json.dumps(mb_data))
+        except Exception as e:
+            dialog.notification.show(f"Can't send message: {e}.", "error")
+        else:
+            dialog.notification.show("Your message has been sent correctly.")
+
+    def _toggle_subscription(self, evt) -> None:
+        aio.run(self.toggle_subscription())
+
+    async def toggle_subscription(self) -> None:
+        """Un/subscribe from/to the node.
+
+        The subscription button will be disabled the time to send the request, the
+        regenerated according to the new status (or an error will be shown).
+        """
+        button = document["subscribe-button"]
+        button.disabled = True
+        action = None
+        try:
+            if window.subscribed:
+                action = "unsubscribe"
+                await bridge.ps_unsubscribe(service, node)
+            else:
+                action = "subscribe"
+                await bridge.ps_subscribe(service, node, "")
+        except Exception as e:
+            dialog.notification.show(f"Can't {action}: {e}.", "error")
+            button.disabled = False
+            return
+
+        window.subscribed = not window.subscribed
+        new_button = self.subscribe_btn_tpl.get_elt({"subscribed": window.subscribed})
+        new_button.bind("click", self._toggle_subscription)
+        button.replaceWith(new_button)
+
+    def on_search_field_keydown(self, evt) -> None:
+        if evt.key == "Enter":
+            self.on_search(evt)
+
+    def on_search(self, evt) -> None:
+        """Reload the page with search query.
+
+        The search params are added to current URL and the page is reloaded to show
+        filtered results.
+        """
+        evt.preventDefault()
+        evt.stopPropagation()
+        search_value = self.search_field.value.strip()
+        if not search_value:
+            return
+        current_url = window.location.href
+        url = window.URL.new(current_url)
+        url.searchParams.set('search', search_value)
+        window.location.href = url.href
+
+
+    def on_add_tag(self, evt) -> None:
+        """Handle action on click on editor's "tag" button.
+
+        It will open the tag element so user can add tags.
+        @param evt: Event data.
+        """
+        document["tags_container"].classList.remove("is-hidden")
+
+    def on_add_attachment(self, evt) -> None:
+        """Handle action on click on editor's "attachment" button.
+
+        It will open the file dialog so user can select one or more files, then upload
+        them with FileUploader and update the attachments section.
+        @param evt: Event data.
+        """
+        document["forum-file-input"].click()
+
+    def on_file_selected(self, evt):
+        """Handle file selection for forum attachments"""
+        files = evt.currentTarget.files
+        self.file_uploader.upload_files(files, self.attachments_elt)
+        self.update_attachments_visibility()
+
+    def on_attachment_delete(self, evt):
+        """Handle deletion of attachment"""
+        evt.stopPropagation()
+        target = evt.currentTarget
+        item_elt = target.closest(".attachment-preview")
+        if item_elt:
+            item_elt.remove()
+        self.update_attachments_visibility()
+
+    def update_attachments_visibility(self):
+        """Show/hide attachments based on whether there are attachments"""
+        if len(self.attachments_elt.children):
+            self.attachments_elt.parentElement.style.display = "flex"
+        else:
+            self.attachments_elt.parentElement.style.display = "none"
+
+    def on_file_drop(self, evt) -> None:
+        print("on_file_drop")
+        evt.preventDefault()
+        files = evt.dataTransfer.files
+        if files:
+            print(f"Dropped {len(files)} files")
+            self.file_uploader.upload_files(files, self.attachments_elt)
+            self.update_attachments_visibility()
+
+    def on_drag_over(self, evt) -> None:
+        print("on_drag_over")
+        evt.dataTransfer.dropEffect = "move"
+        evt.preventDefault()
+
+
+LiberviaForumView()
--- a/libervia/web/pages/forums/view/page_meta.py	Sat Sep 06 12:12:42 2025 +0200
+++ b/libervia/web/pages/forums/view/page_meta.py	Sat Sep 06 16:30:46 2025 +0200
@@ -10,22 +10,104 @@
 
 name = "forum_view"
 label = D_("View")
-access = C.PAGES_ACCESS_PUBLIC
-template = "forum/view.html"
+access = C.PAGES_ACCESS_PROFILE
+template = "forum/view_messages.html"
 
 
 def parse_url(self, request):
-    self.get_path_args(request, ["service", "node"], 2, service="jid")
+    self.get_path_args(request, ["service", "node", "item"], 3, service="jid")
+
+
+def add_breadcrumb(self, request, breadcrumbs):
+    return None
 
 
 async def prepare_render(self, request):
+    profile = self.get_profile(request) or C.SERVICE_PROFILE
     data = self.get_r_data(request)
-    data["show_comments"] = False
-    blog_page = self.get_page_by_name("blog_view")
-    request.args[b"before"] = [b""]
-    request.args[b"reverse"] = [b"1"]
-    await blog_page.prepare_render(self, request)
-    request.template_data["login_url"] = self.get_page_redirect_url(request)
+    parent_service, parent_node, parent_item_id = (
+        data["service"],
+        data["node"],
+        data["item"],
+    )
+    parent_item_data = data_format.deserialise(
+        await self.host.bridge_call(
+            "mb_get",
+            parent_service.userhost(),
+            parent_node,
+            C.NO_LIMIT,
+            [parent_item_id],
+            data_format.serialise({}),
+            profile,
+        )
+    )
+    parent_item = parent_item_data["items"][0]
+    try:
+        service_s = parent_item["comments"][0]["service"]
+        node = parent_item["comments"][0]["node"]
+    except (KeyError, IndexError) as e:
+        log.warning(f"No comment node found {e}:\n{parent_item=}")
+        self.page_error(request, C.HTTP_SERVICE_UNAVAILABLE)
+        return
+
+    page_max = data.get("page_max", 20)
+    extra = self.get_pubsub_extra(request, page_max=page_max)
+    self.handle_search(request, extra)
+    if not self.use_cache(request):
+        extra[C.KEY_USE_CACHE] = False
+    blog_data = data_format.deserialise(
+        await self.host.bridge_call(
+            "mb_get",
+            service_s,
+            node,
+            C.NO_LIMIT,
+            [],
+            data_format.serialise(extra),
+            profile,
+        )
+    )
+    for parent_item in blog_data["items"]:
+        try:
+            comments_service = parent_item["comments"][0]["service"]
+            comments_node = parent_item["comments"][0]["node"]
+        except (KeyError, IndexError):
+            log.warning(f"Can't get comments node for item: {parent_item}")
+            continue
+        else:
+            parent_item["http_url"] = self.get_page_by_name("forum_view").get_url(
+                comments_service, comments_node
+            )
+    self.set_pagination(request, blog_data)
+    await self.fill_missing_identities(
+        request, [i["author_jid"] for i in blog_data["items"]]
+    )
+
+    # We check if we are subscribed to the service.
+    try:
+        subscriptions = data_format.deserialise(
+            await self.host.bridge_call(
+                "ps_subscriptions_get",
+                service_s,
+                node,
+                profile,
+            )
+        )
+    except Exception as e:
+        log.warning("Can't retrieve subscriptions, assuming that we are not subscribed.")
+        subscribed = False
+    else:
+        subscribed = any(s.get("state") == "subscribed" for s in subscriptions)
+
+    request.template_data.update(
+        {
+            "topic_title": parent_item["title"],
+            "service": service_s,
+            "node": node,
+            "blog_items": blog_data,
+            "subscribed": subscribed,
+        }
+    )
+    self.expose_to_scripts(request, node=node, service=service_s, subscribed=subscribed)
 
 
 async def on_data_post(self, request):
@@ -41,7 +123,8 @@
         mb_data = {"service": service, "node": node, "content_rich": body}
         try:
             await self.host.bridge_call(
-                "mb_send", data_format.serialise(mb_data), profile)
+                "mb_send", data_format.serialise(mb_data), profile
+            )
         except Exception as e:
             if "forbidden" in str(e):
                 self.page_error(request, 401)
--- a/libervia/web/server/pages.py	Sat Sep 06 12:12:42 2025 +0200
+++ b/libervia/web/server/pages.py	Sat Sep 06 16:30:46 2025 +0200
@@ -26,9 +26,7 @@
 import time
 import traceback
 from typing import List, Optional, Union
-import urllib.error
 import urllib.parse
-import urllib.request
 
 from twisted.internet import defer
 from twisted.python import failure
--- a/libervia/web/server/restricted_bridge.py	Sat Sep 06 12:12:42 2025 +0200
+++ b/libervia/web/server/restricted_bridge.py	Sat Sep 06 16:30:46 2025 +0200
@@ -220,6 +220,14 @@
             "message_send", to_jid_s, message, subject, mess_type, extra_s, profile
         )
 
+    async def ps_invite(
+        self, invitee_jid_s, service_s, node, item_id, name, extra_s, profile
+    ):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "ps_invite", invitee_jid_s, service_s, node, item_id, name, extra_s, profile
+        )
+
     async def ps_node_delete(self, service_s, node, profile):
         self.no_service_profile(profile)
         return await self.host.bridge_call(
@@ -235,11 +243,49 @@
         return await self.host.bridge_call(
             "ps_item_retract", service_s, node, item_id, notify, profile)
 
+    async def ps_attachments_get(
+        self, service_s: str, node: str, item: str, senders_s: list[str], extra_s: str,
+        profile: str
+    ) -> None:
+        return await self.host.bridge_call(
+            "ps_attachments_get", service_s, node, item, senders_s, extra_s, profile
+        )
+
+    async def ps_attachments_set(
+        self, attachments_s: str, profile: str
+    ) -> None:
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "ps_attachments_set", attachments_s, profile
+        )
+
+    async def ps_subscribe(
+        self, service_s, node, options_s, profile: str
+    ) -> str:
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "ps_subscribe", service_s, node, options_s, profile
+        )
+
+    async def ps_unsubscribe(
+        self, service_s, node, profile: str
+    ) -> str:
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "ps_unsubscribe", service_s, node, profile
+        )
+
     async def mb_preview(self, data, profile):
         return await self.host.bridge_call(
             "mb_preview", data, profile
         )
 
+    async def mb_send(self, data_s, profile):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "mb_send", data_s, profile
+        )
+
     async def list_set(self, service_s, node, values, schema, item_id, extra, profile):
         self.no_service_profile(profile)
         return await self.host.bridge_call(
@@ -315,14 +361,6 @@
             "muc_join", room_jid_s, nick, options, profile
         )
 
-    async def ps_invite(
-        self, invitee_jid_s, service_s, node, item_id, name, extra_s, profile
-    ):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "ps_invite", invitee_jid_s, service_s, node, item_id, name, extra_s, profile
-        )
-
     async def fis_invite(
         self, invitee_jid_s, service_s, repos_type, namespace, path, name, extra_s,
         profile
@@ -374,22 +412,6 @@
             "jid_search", search_term, options_s, profile
         )
 
-    async def ps_attachments_get(
-        self, service_s: str, node: str, item: str, senders_s: list[str], extra_s: str,
-        profile: str
-    ) -> None:
-        return await self.host.bridge_call(
-            "ps_attachments_get", service_s, node, item, senders_s, extra_s, profile
-        )
-
-    async def ps_attachments_set(
-        self, attachments_s: str, profile: str
-    ) -> None:
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "ps_attachments_set", attachments_s, profile
-        )
-
     async def remote_control_start(
         self, peer_jid_s: str, extra_s: str, profile: str
     ) -> None: