changeset 1331:fe353fceec38

browser (invitation, photos/album): invitation manager improvments: invitation manager has been moved to a separated module, it is generic so it can be used with other activities. It has been simplified, and contact to add are dynamically filtered using a text input. Invitation are done by email using a the new modal. New notification module is used to indicate when invitation has been done.
author Goffi <goffi@goffi.org>
date Fri, 14 Aug 2020 09:31:32 +0200
parents b525fdcb393b
children a75d82713a02
files libervia/pages/_browser/invitation.py libervia/pages/photos/album/_browser/__init__.py libervia/server/restricted_bridge.py
diffstat 3 files changed, 331 insertions(+), 131 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/pages/_browser/invitation.py	Fri Aug 14 09:31:32 2020 +0200
@@ -0,0 +1,317 @@
+from browser import document, window, timer
+from bridge import Bridge
+from template import Template
+from dialog import notification
+from cache import cache
+
+bridge = Bridge()
+
+
+class InvitationManager:
+
+    def __init__(self, invitation_type, invitation_data):
+        self.invitation_type = invitation_type
+        self.invitation_data = invitation_data
+        manager_panel_tpl = Template('invitation/manager.html')
+        self.manager_panel_elt = manager_panel_tpl.get_elt()
+        self.invite_by_email_tpl = Template('invitation/invite_by_email.html')
+        self.affiliation_tpl = Template('invitation/affiliation_item.html')
+        self.new_item_tpl = Template('invitation/new_item.html')
+        # list of item passing filter when adding a new contact
+        self._filtered_new_items = {}
+        self._active_new_item = None
+        self._idx = 0
+
+    def attach(self, affiliations=None):
+        if affiliations is None:
+            affiliations = {}
+        self.affiliations = affiliations
+        self.side_panel = self.manager_panel_elt.select_one(
+            '.invitation_manager_side_panel')
+        self.open()
+        for close_elt in self.manager_panel_elt.select('.click_to_close'):
+            close_elt.bind("click", self.on_manager_close)
+        self.side_panel.bind("click", lambda evt: evt.stopPropagation())
+
+        cache.fill_identities(affiliations.keys(), callback=self._set_affiliations)
+
+        contact_elt = self.manager_panel_elt.select_one('input[name="contact"]')
+        contact_elt.bind("input", self.on_contact_input)
+        contact_elt.bind("keydown", self.on_contact_keydown)
+        contact_elt.bind("focus", self.on_contact_focus)
+        contact_elt.bind("blur", self.on_contact_blur)
+        document['invite_email'].bind('click', self.on_invite_email_click)
+
+    def _set_affiliations(self):
+        for entity_jid, affiliation in self.affiliations.items():
+            self.set_affiliation(entity_jid, affiliation)
+
+    def open(self):
+        """Re-attach and show a closed panel"""
+        self._body_ori_style = document.body.style.height, document.body.style.overflow
+        document.body.style.height = '100vh'
+        document.body.style.overflow = 'hidden'
+        document.body <= self.manager_panel_elt
+        timer.set_timeout(lambda: self.side_panel.classList.add("open"), 0)
+
+    def _on_close_transition_end(self, evt):
+        self.manager_panel_elt.remove()
+        # FIXME: not working with Brython, to report upstream
+        # self.side_panel.unbind("transitionend", self._on_close_transition_end)
+        self.side_panel.unbind("transitionend")
+
+    def close(self):
+        """Hide the panel"""
+        document.body.style.height, document.body.style.overflow = self._body_ori_style
+        self.side_panel.classList.remove('open')
+        self.side_panel.bind("transitionend", self._on_close_transition_end)
+
+    def invite_jid(self, entity_jid):
+        if self.invitation_type == 'photos':
+            path = self.invitation_data["path"]
+            service = self.invitation_data["service"]
+            album_name = path.rsplit('/')[-1]
+            print(f"inviting {entity_jid}")
+            bridge.FISInvite(
+                entity_jid,
+                service,
+                "photos",
+                "",
+                path,
+                album_name,
+                '',
+                callback=lambda entity_jid=entity_jid:
+                    self._on_jid_invitation_success(entity_jid),
+                errback=lambda e: notification.show(f"invitation failed: {e}", "error")
+            )
+
+    def on_manager_close(self, evt):
+        self.close()
+
+    def _on_jid_invitation_success(self, entity_jid):
+        form_elt = document['invitation_form']
+        contact_elt = form_elt.select_one('input[name="contact"]')
+        contact_elt.value = ""
+        contact_elt.dispatchEvent(window.Event.new('input'))
+        notification.show(
+            f"{entity_jid} has been invited",
+            level="success",
+        )
+        if entity_jid not in self.affiliations:
+            self.set_affiliation(entity_jid, "member")
+
+    def on_contact_invite(self, evt, entity_jid):
+        """User is adding a contact"""
+        form_elt = document['invitation_form']
+        contact_elt = form_elt.select_one('input[name="contact"]')
+        contact_elt.value = ""
+        contact_elt.dispatchEvent(window.Event.new('input'))
+        self.invite_jid(entity_jid)
+
+    def on_contact_keydown(self, evt):
+        if evt.key == "Escape":
+            evt.target.value = ""
+            evt.target.dispatchEvent(window.Event.new('input'))
+        elif evt.key == "ArrowDown":
+            evt.stopPropagation()
+            evt.preventDefault()
+            content_elt = document['invitation_contact_search'].select_one(
+                ".search_dialog__content")
+            if self._active_new_item == None:
+                self._active_new_item = content_elt.firstElementChild
+                self._active_new_item.classList.add('selected')
+            else:
+                next_item = self._active_new_item.nextElementSibling
+                if next_item is not None:
+                    self._active_new_item.classList.remove('selected')
+                    self._active_new_item = next_item
+                    self._active_new_item.classList.add('selected')
+        elif evt.key == "ArrowUp":
+            evt.stopPropagation()
+            evt.preventDefault()
+            content_elt = document['invitation_contact_search'].select_one(
+                ".search_dialog__content")
+            if self._active_new_item == None:
+                self._active_new_item = content_elt.lastElementChild
+                self._active_new_item.classList.add('selected')
+            else:
+                previous_item = self._active_new_item.previousElementSibling
+                if previous_item is not None:
+                    self._active_new_item.classList.remove('selected')
+                    self._active_new_item = previous_item
+                    self._active_new_item.classList.add('selected')
+        elif evt.key == "Enter":
+            evt.stopPropagation()
+            evt.preventDefault()
+            if self._active_new_item is not None:
+                entity_jid = self._active_new_item.dataset.entityJid
+                self.invite_jid(entity_jid)
+
+    def on_contact_focus(self, evt):
+        search_dialog = document['invitation_contact_search']
+        search_dialog.classList.add('open')
+        self._active_new_item = None
+        evt.target.dispatchEvent(window.Event.new('input'))
+
+    def on_contact_blur(self, evt):
+        search_dialog = document['invitation_contact_search']
+        search_dialog.classList.remove('open')
+        for elt in self._filtered_new_items.values():
+            elt.remove()
+        self._filtered_new_items.clear()
+
+
+    def on_contact_input(self, evt):
+        text = evt.target.value.strip().lower()
+        search_dialog = document['invitation_contact_search']
+        content_elt = search_dialog.select_one(".search_dialog__content")
+        for (entity_jid, identity) in cache.identities.items():
+            if not cache.match_identity(entity_jid, text, identity):
+                # if the entity was present in last pass, we remove it
+                try:
+                    filtered_item = self._filtered_new_items.pop(entity_jid)
+                except KeyError:
+                    pass
+                else:
+                    filtered_item.remove()
+                continue
+            if entity_jid not in self._filtered_new_items:
+                # we only create a new element if the item was not already there
+                new_item_elt = self.new_item_tpl.get_elt({
+                    "entity_jid": entity_jid,
+                    "identities": cache.identities,
+                })
+                content_elt <= new_item_elt
+                self._filtered_new_items[entity_jid] = new_item_elt
+                for elt in new_item_elt.select('.click_to_ok'):
+                    # we use mousedown instead of click because otherwise it would be
+                    # ignored due to "blur" event manager (see
+                    # https://stackoverflow.com/a/9335401)
+                    elt.bind(
+                        "mousedown",
+                        lambda evt, entity_jid=entity_jid: self.on_contact_invite(
+                            evt, entity_jid),
+                    )
+
+        if ((self._active_new_item is not None
+             and not self._active_new_item.parentElement)):
+            # active item has been filtered out
+            self._active_new_item = None
+
+    def _on_email_invitation_success(self, invitee_jid, email, name):
+        self.set_affiliation(invitee_jid, "member")
+        notification.show(
+            f"{name} has been invited, he/she has received an email with a link",
+            level="success",
+        )
+
+    def invitationSimpleCreateCb(self, invitation_data, email, name):
+        if self.invitation_type == 'photos':
+            path = self.invitation_data["path"]
+            service = self.invitation_data["service"]
+            invitee_jid = invitation_data['jid']
+            album_name = path.rsplit('/')[-1]
+            bridge.FISInvite(
+                invitee_jid,
+                service,
+                "photos",
+                "",
+                path,
+                album_name,
+                '',
+                callback=lambda: self._on_email_invitation_success(invitee_jid, email, name),
+                errback=lambda e: window.alert(f"invitation failed for {email}: {e}")
+            )
+
+            # we update identities to have the name instead of the invitation jid in
+            # affiliations
+            cache.identities[invitee_jid] = {'nicknames': [name]}
+            cache.update()
+
+    def invite_by_email(self, email, name):
+        guest_url_tpl = f'{window.URL.new("/g", document.baseURI).href}/{{uuid}}'
+        bridge.invitationSimpleCreate(
+            email,
+            name,
+            guest_url_tpl,
+            '',
+            callback=lambda data: self.invitationSimpleCreateCb(data, email, name),
+            errback=lambda e: window.alert(f"can't send email invitation: {e}")
+        )
+
+    def on_invite_email_submit(self, evt, invite_email_elt):
+        evt.stopPropagation()
+        evt.preventDefault()
+        form = document['email_invitation_form']
+        try:
+            reportValidity = form.reportValidity
+        except AttributeError:
+            print("reportValidity is not supported by this browser!")
+        else:
+            if not reportValidity():
+                return
+        email = form.select_one('input[name="email"]').value
+        name = form.select_one('input[name="name"]').value
+        self.invite_by_email(email, name)
+        invite_email_elt.remove()
+        self.open()
+
+    def on_invite_email_close(self, evt, invite_email_elt):
+        evt.stopPropagation()
+        evt.preventDefault()
+        invite_email_elt.remove()
+        self.open()
+
+    def on_invite_email_click(self, evt):
+        evt.stopPropagation()
+        evt.preventDefault()
+        invite_email_elt = self.invite_by_email_tpl.get_elt()
+        document.body <= invite_email_elt
+        document['email_invitation_submit'].bind(
+            'click', lambda evt: self.on_invite_email_submit(evt, invite_email_elt)
+        )
+        for close_elt in invite_email_elt.select('.click_to_close'):
+            close_elt.bind(
+                "click", lambda evt: self.on_invite_email_close(evt, invite_email_elt))
+        self.close()
+
+    ## affiliations
+
+    def set_affiliation(self, entity_jid, affiliation):
+        if affiliation not in ('owner', 'member'):
+            raise NotImplementedError(
+                f'{affiliation} affiliation can not be set with this method for the '
+                'moment')
+        if entity_jid not in self.affiliations:
+            self.affiliations[entity_jid] = affiliation
+        affiliation_elt = self.affiliation_tpl.get_elt({
+            "entity_jid": entity_jid,
+            "affiliation": affiliation,
+            "identities": cache.identities,
+        })
+        document['affiliations'] <= affiliation_elt
+        for elt in affiliation_elt.select(".click_to_delete"):
+            elt.bind(
+                "click",
+                lambda evt, entity_jid=entity_jid, affiliation_elt=affiliation_elt:
+                self.on_affiliation_remove(entity_jid, affiliation_elt)
+            )
+
+    def _on_affiliation_remove_success(self, affiliation_elt, entity_jid):
+        affiliation_elt.remove()
+        del self.affiliations[entity_jid]
+
+    def on_affiliation_remove(self, entity_jid, affiliation_elt):
+        if self.invitation_type == 'photos':
+            path = self.invitation_data["path"]
+            service = self.invitation_data["service"]
+            bridge.FISAffiliationsSet(
+                service,
+                "",
+                path,
+                {entity_jid: "none"},
+                callback=lambda: self._on_affiliation_remove_success(
+                    affiliation_elt, entity_jid),
+                errback=lambda e: notification.show(
+                    f"can't remove affiliation: {e}", "error")
+            )
--- a/libervia/pages/photos/album/_browser/__init__.py	Fri Aug 14 09:31:32 2020 +0200
+++ b/libervia/pages/photos/album/_browser/__init__.py	Fri Aug 14 09:31:32 2020 +0200
@@ -1,12 +1,18 @@
-from browser import document, window, bind, html, DOMNode, timer
+from browser import document, window, bind, html, DOMNode
 from javascript import JSON
 from bridge import Bridge
 from template import Template
 import dialog
 from slideshow import SlideShow
+from invitation import InvitationManager
 
+cache_path = window.cache_path
 files_service = window.files_service
 files_path = window.files_path
+try:
+    affiliations = window.affiliations.to_dict()
+except AttributeError:
+    pass
 bridge = Bridge()
 
 # file upload
@@ -177,136 +183,10 @@
 
 # manage
 
-def on_manager_close(manager_panel_elt):
-    side_panel = manager_panel_elt.select_one('.invitation_manager_side_panel')
-    side_panel.classList.remove('open')
-    side_panel.bind("transitionend", lambda evt: manager_panel_elt.remove())
-
-def _on_invitation_cb(field_elt, entity):
-    print(f"invitation for {entity!r} sent successfully")
-    submit_elt = document['invitation_submit']
-    submit_elt.disabled = False
-    form_elt = document['invitation_form']
-    form_elt.disabled = False
-    jids_elt = form_elt.select_one('*[name="jids"]')
-    emails_elt = form_elt.select_one('*[name="emails"]')
-    new = [d.strip() for d in field_elt.value.split('\n') if d and d.strip() != entity]
-    field_elt.value = '\n'.join(new)
-    if not jids_elt.value.strip() and not emails_elt.value.strip():
-        # FIXME: Q&D notification, needs to do this properly in a separated module with
-        #   some animations
-        notifs_elt = document['invitation_notifications']
-        notification_elt = html.DIV(Class="notification is-success has-text-centered")
-        notification_elt <= "invitations sent successfully"
-        notifs_elt <= notification_elt
-        timer.set_timeout(lambda: notification_elt.remove(), 5000)
-
-
-def invitationSimpleCreateCb(invitation_data, email):
-    invitee_jid = invitation_data['jid']
-    album_name = files_path.rsplit('/')[-1]
-    form_elt = document['invitation_form']
-    emails_elt = form_elt.select_one('*[name="emails"]')
-    bridge.FISInvite(
-        invitee_jid,
-        files_service,
-        "photos",
-        "",
-        files_path,
-        album_name,
-        '',
-        callback=lambda: _on_invitation_cb(emails_elt, email),
-        errback=lambda e: window.alert(f"invitation failed for {email}: {e}")
-    )
-
-
-def invite_by_email(email):
-    guest_url_tpl = f'{window.URL.new("/g", document.baseURI).href}/{{uuid}}'
-    bridge.invitationSimpleCreate(
-        email,
-        guest_url_tpl,
-        '',
-        callback=lambda data: invitationSimpleCreateCb(data, email),
-        errback=lambda e: window.alert(f"can't send email invitation: {e}")
-    )
-
-
-def on_invitation_submit(evt, manager_panel_elt):
-    evt.stopPropagation()
-    evt.preventDefault()
-    submit_elt = document['invitation_submit']
-    submit_elt.disabled = True
-    form_elt = document['invitation_form']
-    form_elt.disabled = True
-    jids_elt = form_elt.select_one('*[name="jids"]')
-    emails_elt = form_elt.select_one('*[name="emails"]')
-    jids = [j.strip() for j in jids_elt.value.split('\n') if j.strip()]
-    emails = [e.strip() for e in emails_elt.value.split('\n') if e.strip()]
-    album_name = files_path.rsplit('/')[-1]
-    for entity_jid in jids:
-        print(f"inviting {entity_jid}")
-        bridge.FISInvite(
-            entity_jid,
-            files_service,
-            "photos",
-            "",
-            files_path,
-            album_name,
-            '',
-            callback=lambda entity=entity_jid: _on_invitation_cb(jids_elt, entity),
-            errback=lambda e: window.alert(f"invitation failed: {e}")
-        )
-
-    for email in emails:
-        invite_by_email(email)
-
-    print(f"{jids=}, {emails=}")
-
-
-def _FISAffiliationSetCb(affiliation_elt):
-    affiliation_elt.remove()
-
-
-def on_affiliation_remove(entity_jid, affiliation_elt):
-    bridge.FISAffiliationsSet(
-        files_service,
-        "",
-        files_path,
-        {entity_jid: "none"},
-        callback=lambda: _FISAffiliationSetCb(affiliation_elt),
-        errback=lambda e: window.alert(f"can't remove affiliation: {e}")
-    )
-
 
 @bind("#button_manage", "click")
 def manage_click(evt):
     evt.stopPropagation()
     evt.preventDefault()
-    manager_panel_tpl = Template('invitation/manager.html')
-    manager_panel_elt = manager_panel_tpl.get_elt()
-    document.body <= manager_panel_elt
-    document['invitation_submit'].bind(
-        "click", lambda evt: on_invitation_submit(evt, manager_panel_elt))
-    side_panel = manager_panel_elt.select_one('.invitation_manager_side_panel')
-    timer.set_timeout(lambda: side_panel.classList.add("open"), 0)
-    for close_elt in manager_panel_elt.select('.click_to_close'):
-        close_elt.bind("click", lambda evt: on_manager_close(manager_panel_elt))
-    side_panel.bind("click", lambda evt: evt.stopPropagation())
-    affiliations = window.affiliations.to_dict()
-    affiliation_tpl = Template('invitation/affiliation_item.html')
-    for entity_jid, affiliation in affiliations.items():
-        affiliation_elt = affiliation_tpl.get_elt({
-            "entity_jid": entity_jid,
-            "affiliation": affiliation,
-        })
-        document['affiliations'] <= affiliation_elt
-        for elt in affiliation_elt.select(".click_to_delete"):
-            elt.bind(
-                "click",
-                lambda evt,
-                       entity_jid=entity_jid,
-                       affiliation_elt=affiliation_elt: on_affiliation_remove(
-                    entity_jid,
-                    affiliation_elt
-                )
-            )
+    manager = InvitationManager("photos", {"service": files_service, "path": files_path})
+    manager.attach(affiliations=affiliations)
--- a/libervia/server/restricted_bridge.py	Fri Aug 14 09:31:32 2020 +0200
+++ b/libervia/server/restricted_bridge.py	Fri Aug 14 09:31:32 2020 +0200
@@ -78,7 +78,10 @@
             "FISAffiliationsSet", service_s, namespace, path, affiliations, profile
         )
 
-    async def invitationSimpleCreate(self, invitee_email, url_template, extra_s, profile):
+    async def invitationSimpleCreate(
+        self, invitee_email, invitee_name, url_template, extra_s, profile
+    ):
         return await self.host.bridgeCall(
-            "invitationSimpleCreate", invitee_email, url_template, extra_s, profile
+            "invitationSimpleCreate", invitee_email, invitee_name, url_template, extra_s,
+            profile
         )