diff --git a/examples/webchat/htdocs/style.css b/examples/webchat/htdocs/style.css index 37c4bfc..f018ba5 100644 --- a/examples/webchat/htdocs/style.css +++ b/examples/webchat/htdocs/style.css @@ -56,3 +56,20 @@ img.avatar { .big-icon { font-size: 1.75rem; } + +.invited-tick { + font-size: 2rem; + width: 48px; + height: 48px; + display: inline-block; + border-radius: 24px; + color: white; + background: darkgreen; + text-align: center; + line-height: 0px; +} + +.invited-tick .icon { + position: relative; + top: 0.5rem; +} diff --git a/examples/webchat/htdocs/templates/conversation-index-entry.html b/examples/webchat/htdocs/templates/conversation-index-entry.html new file mode 100644 index 0000000..3fde15f --- /dev/null +++ b/examples/webchat/htdocs/templates/conversation-index-entry.html @@ -0,0 +1,8 @@ +
+
+
{{title}}
+ {{#members}} + {{.}} + {{/members}} +
+
diff --git a/examples/webchat/htdocs/templates/invitee-entry.html b/examples/webchat/htdocs/templates/invitee-entry.html new file mode 100644 index 0000000..f674c1d --- /dev/null +++ b/examples/webchat/htdocs/templates/invitee-entry.html @@ -0,0 +1,7 @@ +
+
+ {{#isInvited}}{{/isInvited}} + {{^isInvited}}{{/isInvited}} + {{email}} +
+
diff --git a/examples/webchat/htdocs/templates/page-conversations.html b/examples/webchat/htdocs/templates/page-conversations.html index 2f4bf12..1a25019 100644 --- a/examples/webchat/htdocs/templates/page-conversations.html +++ b/examples/webchat/htdocs/templates/page-conversations.html @@ -8,10 +8,15 @@
+ {{#selectedCid}} + {{selectedCid}} + {{/selectedCid}} + {{^selectedCid}}

Select a conversation from the column to the left, or create a new conversation.

+ {{/selectedCid}}
diff --git a/examples/webchat/htdocs/templates/page-new-chat.html b/examples/webchat/htdocs/templates/page-new-chat.html index 4a161ab..79e7e4b 100644 --- a/examples/webchat/htdocs/templates/page-new-chat.html +++ b/examples/webchat/htdocs/templates/page-new-chat.html @@ -1,4 +1,35 @@

New Conversation

+
+ +

Select people to add

+
+ +
+
+ +
+

Configure the conversation

+
+
+ + +
+
+ + +
+ + + {{#noInvitees}} +
+ You must invite at least one person to the conversation. +
+ {{/noInvitees}} +
diff --git a/examples/webchat/htdocs/templates/page-questions.html b/examples/webchat/htdocs/templates/page-questions.html index f621828..176a992 100644 --- a/examples/webchat/htdocs/templates/page-questions.html +++ b/examples/webchat/htdocs/templates/page-questions.html @@ -1,5 +1,11 @@

Questions

-

There are no questions waiting for you to answer.

+
+

There are no questions waiting for you to answer.

+ +
diff --git a/examples/webchat/htdocs/webchat.syndicate.js b/examples/webchat/htdocs/webchat.syndicate.js index 3c3e279..a4d25eb 100644 --- a/examples/webchat/htdocs/webchat.syndicate.js +++ b/examples/webchat/htdocs/webchat.syndicate.js @@ -11,6 +11,11 @@ assertion type grant(issuer, grantor, grantee, permission, isDelegable); assertion type permissionRequest(issuer, grantee, permission) = "permission-request"; + assertion type conversation(id, title, creator, blurb); + assertion type invitation(conversationId, inviter, invitee); + assertion type inConversation(conversationId, member) = "in-conversation"; + assertion type post(id, timestamp, conversationId, contentType, content); + message type createResource(description) = "create-resource"; message type updateResource(description) = "update-resource"; message type deleteResource(description) = "delete-resource"; @@ -29,6 +34,8 @@ assertion type textQuestion(isMultiline) = "text-question"; assertion type acknowledgeQuestion() = "acknowledge-question"; + //--------------------------------------------------------------------------- + var brokerConnected = Syndicate.Broker.brokerConnected; var brokerConnection = Syndicate.Broker.brokerConnection; var toBroker = Syndicate.Broker.toBroker; @@ -108,36 +115,33 @@ on stop { this.globallyVisible = false; } } + during inbound(question($qid, _, _, sessionInfo.email, _, _, _)) { + on start { this.questionCount++; } + on stop { this.questionCount--; } + } + + during inbound(permissionRequest($issuer, sessionInfo.email, $permission)) { + on start { this.myRequestCount++; } + on stop { this.myRequestCount--; } + } + during inbound(uiTemplate("nav-account.html", $entry)) { var c = this.ui.context('nav', 0, 'account'); assert outbound(online()) when (this.locallyVisible); - assert c.html('#nav-ul', Mustache.render( - entry, - { - email: sessionInfo.email, - avatar: avatar(sessionInfo.email), - questionCount: this.questionCount, - myRequestCount: this.myRequestCount, - otherRequestCount: this.otherRequestCount, - globallyVisible: this.globallyVisible, - locallyVisible: this.locallyVisible - })); + assert c.html('#nav-ul', Mustache.render(entry, { + email: sessionInfo.email, + avatar: avatar(sessionInfo.email), + questionCount: this.questionCount, + myRequestCount: this.myRequestCount, + otherRequestCount: this.otherRequestCount, + globallyVisible: this.globallyVisible, + locallyVisible: this.locallyVisible + })); on message c.event('.toggleInvisible', 'click', _) { this.locallyVisible = !this.locallyVisible; } } - // assert mainpage_c.html('div#main-div', Mustache.render( - // mainpage, - // { - // questionCount: this.questionCount, - // myRequestCount: this.myRequestCount, - // otherRequestCount: this.otherRequestCount, - // globallyVisible: this.globallyVisible, - // showRequestsFromOthers: this.showRequestsFromOthers - // })); - - during Syndicate.UI.locationHash('/contacts') { during inbound(uiTemplate("page-contacts.html", $mainEntry)) { assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, {})); @@ -180,7 +184,7 @@ during mainpage_c.fragmentVersion($mainpageVersion) { during inputValue('#add-contact-email', $rawContact) { - var contact = rawContact.trim(); + var contact = rawContact && rawContact.trim(); if (contact) { on message mainpage_c.event('#add-contact', 'click', _) { :: outbound(createResource(grant(sessionInfo.email, @@ -245,12 +249,9 @@ })); } - during inbound(uiTemplate("permission-request-out-GENERIC.html", $genericEntry)) { - during mainpage_c.fragmentVersion($mainpageVersion) { - during inbound(permissionRequest($issuer, sessionInfo.email, $permission)) { - on start { this.myRequestCount++; } - on stop { this.myRequestCount--; } - + during inbound(permissionRequest($issuer, sessionInfo.email, $permission)) { + during inbound(uiTemplate("permission-request-out-GENERIC.html", $genericEntry)) { + during mainpage_c.fragmentVersion($mainpageVersion) { var c = this.ui.context(mainpageVersion, 'my-permission-request', issuer, permission); field this.entry = genericEntry; assert c.html('#my-permission-requests', Mustache.render(this.entry, { @@ -321,13 +322,9 @@ } } - during mainpage_c.fragmentVersion($mainpageVersion) { - during - inbound(question($qid, $timestamp, $klass, sessionInfo.email, $title, $blurb, $qt)) - { - on start { this.questionCount++; } - on stop { this.questionCount--; } - + during inbound(question($qid, $timestamp, $klass, sessionInfo.email, $title, $blurb, $qt)) + { + during mainpage_c.fragmentVersion($mainpageVersion) { var c = this.ui.context(mainpageVersion, 'question', timestamp, qid); switch (qt.meta.label) { @@ -354,15 +351,124 @@ } } - during Syndicate.UI.locationHash('/conversations') { - during inbound(uiTemplate("page-conversations.html", $mainEntry)) { - assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, {})); + var conversations_re = /^\/conversations(\/(.*))?/; + during Syndicate.UI.locationHash($locationHash) { + var m = locationHash.match(conversations_re); + if (m) { + var selectedCid = m[2] || null; + + during inbound(uiTemplate("page-conversations.html", $mainEntry)) { + assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, { + selectedCid: selectedCid + })); + } + + during inbound(uiTemplate("conversation-index-entry.html", $indexEntry)) { + during mainpage_c.fragmentVersion($mainpageVersion) { + during inbound(inConversation($cid, sessionInfo.email)) { + field this.members = Immutable.Set(); + on asserted inbound(inConversation(cid, $who)) { + this.members = this.members.add(who); + } + on retracted inbound(inConversation(cid, $who)) { + this.members = this.members.remove(who); + } + during inbound(conversation(cid, $title, $creator, $blurb)) { + var c = this.ui.context(mainpageVersion, 'conversationIndex', cid); + assert c.html('#conversation-list', Mustache.render(indexEntry, { + isSelected: selectedCid === cid, + selectedCid: selectedCid, + cid: cid, + title: title, + creator: creator, + members: this.members.toArray() + })); + on message c.event('.card-block', 'click', _) { + if (selectedCid === cid) { + :: Syndicate.UI.setLocationHash('/conversations'); + } else { + :: Syndicate.UI.setLocationHash('/conversations/' + cid); + } + } + } + } + } + } } } during Syndicate.UI.locationHash('/new-chat') { + field this.invitees = Immutable.Set(); + field this.searchString = ''; + field this.displayedSearchString = ''; // avoid resetting HTML every keystroke. YUCK + field this.suggestedTitle = ''; + + dataflow { + this.suggestedTitle = this.invitees.toArray().join(', '); + } + during inbound(uiTemplate("page-new-chat.html", $mainEntry)) { - assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, {})); + assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, { + noInvitees: this.invitees.isEmpty(), + searchString: this.displayedSearchString, + suggestedTitle: this.suggestedTitle + })); + } + + during mainpage_c.fragmentVersion($mainpageVersion) { + on message Syndicate.UI.globalEvent('#search-contacts', 'keyup', $e) { + this.searchString = e.target.value.trim(); + } + + on message Syndicate.UI.globalEvent('.create-conversation', 'click', _) { + // TODO: ^ Would like to use + // mainpage_c.event('.create-conversation', 'click', _) + // here, but the DOM nodes aren't created in time, it seems + if (!this.invitees.isEmpty()) { + var title = $('#conversation-title').val() || this.suggestedTitle; + var blurb = $('#conversation-blurb').val(); + var cid = random_hex_string(32); + :: outbound(createResource(conversation(cid, title, sessionInfo.email, blurb))); + :: outbound(createResource(inConversation(cid, sessionInfo.email))); + this.invitees.forEach(function (invitee) { + :: outbound(createResource(invitation(cid, sessionInfo.email, invitee))); + }); + :: Syndicate.UI.setLocationHash('/conversations/' + cid); + } + } + } + + during inbound(uiTemplate("invitee-entry.html", $entry)) { + during mainpage_c.fragmentVersion($mainpageVersion) { + during inbound(contactListEntry(sessionInfo.email, $contact)) { + field this.isPresent = false; + field this.isInvited = false; + dataflow { + this.isInvited = this.invitees.contains(contact); + } + during inbound(present(contact)) { + on start { this.isPresent = true; } + on stop { this.isPresent = false; } + } + var c = this.ui.context(mainpageVersion, 'all-contacts', contact); + assert c.html('.contact-list', Mustache.render(entry, { + email: contact, + avatar: avatar(contact), + isPresent: this.isPresent, + isInvited: this.isInvited + })) when (this.isInvited || + !this.searchString || + contact.indexOf(this.searchString) !== -1); + on message c.event('.toggle-invitee-status', 'click', _) { + if (this.invitees.contains(contact)) { + this.invitees = this.invitees.remove(contact); + } else { + this.invitees = this.invitees.add(contact); + } + this.displayedSearchString = this.searchString; + } + } + } } } } @@ -381,7 +487,7 @@ assertion type inputValue(selector, value); function spawnInputChangeMonitor() { function valOf(e) { - return e.type === 'checkbox' ? e.checked : e.value; + return e ? (e.type === 'checkbox' ? e.checked : e.value) : null; } actor { @@ -394,3 +500,16 @@ function spawnInputChangeMonitor() { } } } + +/////////////////////////////////////////////////////////////////////////// + +function random_hex_string(halfLength) { + var bs = new Uint8Array(halfLength); + var encoded = []; + crypto.getRandomValues(bs); + for (var i = 0; i < bs.length; i++) { + encoded.push("0123456789abcdef"[(bs[i] >> 4) & 15]); + encoded.push("0123456789abcdef"[bs[i] & 15]); + } + return encoded.join(''); +} diff --git a/examples/webchat/server/contacts.rkt b/examples/webchat/server/contacts.rkt index 2be4a64..b7d42ef 100644 --- a/examples/webchat/server/contacts.rkt +++ b/examples/webchat/server/contacts.rkt @@ -8,6 +8,7 @@ (require "protocol.rkt") (require "duplicate.rkt") +;; TODO: Move to protocol.rkt (struct online () #:prefab) (struct present (email) #:prefab) diff --git a/examples/webchat/server/conversation.rkt b/examples/webchat/server/conversation.rkt index fdc600a..c9799ed 100644 --- a/examples/webchat/server/conversation.rkt +++ b/examples/webchat/server/conversation.rkt @@ -1,7 +1,127 @@ #lang syndicate/actor +(require racket/port) +(require markdown) + (require/activate syndicate/reload) (require/activate syndicate/supervise) (require/activate "trust.rkt") (require "protocol.rkt") +(require "duplicate.rkt") +(require "util.rkt") + +(supervise + (actor #:name 'take-conversation-instructions + (stop-when-reloaded) + + (on (message (api (session $creator _) (create-resource (? conversation? $c)))) + (when (equal? creator (conversation-creator c)) + (send! (create-resource c)))) + (on (message (api (session $creator _) (delete-resource (? conversation? $c)))) + (when (equal? creator (conversation-creator c)) + (send! (delete-resource c)))) + + (on (message (api (session $joiner _) (create-resource (? in-conversation? $i)))) + (when (equal? joiner (in-conversation-member i)) + (send! (create-resource i)))) + (on (message (api (session $leaver _) (delete-resource (? in-conversation? $i)))) + (when (equal? leaver (in-conversation-member i)) + (send! (delete-resource i)))) + + (on (message (api (session $inviter _) (create-resource (? invitation? $i)))) + (when (equal? inviter (invitation-inviter i)) + (send! (create-resource i)))) + (on (message (api (session $who _) (delete-resource (? invitation? $i)))) + (when (or (equal? who (invitation-inviter i)) + (equal? who (invitation-invitee i))) + (send! (delete-resource i)))))) + +(supervise + (actor #:name 'relay-conversation-state + (stop-when-reloaded) + + (during (invitation $cid $inviter $invitee) + (assert (api (session invitee _) (invitation cid inviter invitee))) + (during ($ c (conversation cid _ _ _)) + (assert (api (session invitee _) c)))) + + (during (in-conversation $cid $who) + (during ($ i (invitation cid _ _)) + (assert (api (session who _) i))) + (during ($ i (in-conversation cid _)) + (assert (api (session who _) i))) + (during ($ c (conversation cid _ _ _)) + (assert (api (session who _) c))) + (during ($ p (post _ _ cid _ _)) + (assert (api (session who _) p)))))) + +(supervise + (actor #:name 'conversation-factory + (stop-when-reloaded) + (on (message (create-resource ($ c0 (conversation $cid $title0 $creator $blurb0)))) + (actor #:name c0 + (field [title title0] + [blurb blurb0]) + (define/dataflow c (conversation cid (title) creator (blurb))) + (on-start (log-info "~v created" (c))) + (on-stop (log-info "~v deleted" (c))) + (assert (c)) + (stop-when-duplicate (list 'conversation cid)) + (stop-when (message (delete-resource (conversation cid _ _ _)))))))) + +(supervise + (actor #:name 'in-conversation-factory + (stop-when-reloaded) + (on (message (create-resource ($ i (in-conversation $cid $who)))) + (actor #:name i + (on-start (log-info "~s joins conversation ~a" who cid)) + (on-stop (log-info "~s leaves conversation ~a" who cid)) + (assert i) + (stop-when-duplicate i) + (stop-when (message (delete-resource i))))))) + +(supervise + (actor #:name 'invitation-factory + (stop-when-reloaded) + (on (message (create-resource ($ i (invitation $cid $inviter $invitee)))) + (actor #:name i + (on-start (log-info "~s invited to conversation ~a by ~s" invitee cid inviter)) + (on-stop (log-info "invitation of ~s to conversation ~a by ~s retracted" + invitee cid inviter)) + (assert i) + (stop-when-duplicate i) + (stop-when (message (delete-resource i))) + (stop-when (asserted (in-conversation cid invitee))))))) + +(supervise + (actor #:name 'conversation:questions + (stop-when-reloaded) + ;; TODO: CHECK THE FOLLOWING: When the `invitation` vanishes (due to satisfaction + ;; or rejection), this should remove the question from all eligible answerers at once + (during (invitation $cid $inviter $invitee) + ;; `inviter` has invited `invitee` to conversation `cid`... + (define qid (random-hex-string 32)) ;; Fix qid and timestamp even as title/creator vary + (define timestamp (current-seconds)) + (during (conversation cid $title $creator _) + ;; ...and it exists... + (during (permitted invitee inviter (p:follow invitee) _) + ;; ...and they are permitted to do so + (assert (question qid timestamp "q-invitation" invitee + (format "Invitation from ~a" inviter) + (with-output-to-string + (lambda () + (display-xexpr + `(div + (p "You have been invited by " (b ,inviter) + " to join a conversation started by " (b ,creator) ".") + (p "The conversation is titled " + (i "\"" ,title "\"") "."))))) + (option-question (list (list "join" "Join conversation") + (list "decline" "Decline invitation"))))) + (stop-when (asserted (answer qid $v)) + (match v + ["join" + (send! (create-resource (in-conversation cid invitee)))] + ["decline" + (send! (delete-resource (invitation cid inviter invitee)))]))))))) diff --git a/examples/webchat/server/pages.rkt b/examples/webchat/server/pages.rkt index 71ebf2c..c3b6eaa 100644 --- a/examples/webchat/server/pages.rkt +++ b/examples/webchat/server/pages.rkt @@ -46,6 +46,9 @@ (script ((src "https://cdnjs.cloudflare.com/ajax/libs/blueimp-md5/2.6.0/js/md5.min.js") (integrity "sha256-I0CACboBQ1ky299/4LVi2tzEhCOfx1e7LbCcFhn7M8Y=") (crossorigin "anonymous"))) + (script ((src "https://cdnjs.cloudflare.com/ajax/libs/immutable/3.8.1/immutable.min.js") + (integrity "sha256-13JFytp+tj8jsxr6GQOVLCgcYfMUo2Paw4jVrnXLUPE=") + (crossorigin "anonymous"))) (script ((src "/linkify.min.js"))) (script ((src "/linkify-string.min.js"))) ;; (script ((src "/syndicatecompiler.min.js"))) diff --git a/examples/webchat/server/protocol.rkt b/examples/webchat/server/protocol.rkt index b0fab5e..fe1e94e 100644 --- a/examples/webchat/server/protocol.rkt +++ b/examples/webchat/server/protocol.rkt @@ -141,8 +141,8 @@ ;; (conversation String String Principal Markup Boolean (struct conversation (id title creator blurb) #:prefab) ;; ASSERTION -;; (invitation String Principal) -(struct invitation (conversation-id invitee) #:prefab) ;; ASSERTION +;; (invitation String Principal Principal) +(struct invitation (conversation-id inviter invitee) #:prefab) ;; ASSERTION ;; (in-conversation String Principal) ;; Records conversation membership.