# HG changeset patch # User Goffi # Date 1597390292 -7200 # Node ID fe353fceec3874ad05529bd94fb5553e173b6999 # Parent b525fdcb393bd02143cbeb624ac4b0013fe56e0b 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. diff -r b525fdcb393b -r fe353fceec38 libervia/pages/_browser/invitation.py --- /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") + ) diff -r b525fdcb393b -r fe353fceec38 libervia/pages/photos/album/_browser/__init__.py --- 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) diff -r b525fdcb393b -r fe353fceec38 libervia/server/restricted_bridge.py --- 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 )