Conversation management and UI

This commit is contained in:
Tony Garnock-Jones 2016-12-07 10:06:32 +13:00
parent b87639b7a4
commit 132032b602
11 changed files with 361 additions and 44 deletions

View File

@ -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;
}

View File

@ -0,0 +1,8 @@
<div class="card conversation-card">
<div class="card-block {{#isSelected}}bg-primary text-white{{/isSelected}}">
<div class="card-title">{{title}}</div>
{{#members}}
{{.}}
{{/members}}
</div>
</div>

View File

@ -0,0 +1,7 @@
<div class="col-xs-12 col-md-6 col-lg-4 p-1">
<div class="cursor-interactive contact-list-present-{{isPresent}} toggle-invitee-status p-2 {{#isInvited}}bg-primary text-white{{/isInvited}} rounded">
{{#isInvited}}<span class="invited-tick"><i class="icon ion-checkmark"></i></span>{{/isInvited}}
{{^isInvited}}<img class="avatar" src="{{avatar}}">{{/isInvited}}
<span class="forcewrap">{{email}}</span>
</div>
</div>

View File

@ -8,10 +8,15 @@
</div>
</div>
<div class="col-xs-8">
{{#selectedCid}}
{{selectedCid}}
{{/selectedCid}}
{{^selectedCid}}
<p class="align-center">
Select a conversation from the column to the left,
or <a href="#/new-chat">create a new conversation</a>.
</p>
{{/selectedCid}}
</div>
</div>
</div>

View File

@ -1,4 +1,35 @@
<h2>New Conversation</h2>
<hr>
<h4>Select people to add</h4>
<div class="input-group">
<input class="form-control"
type="search"
id="search-contacts"
placeholder="Search contacts"
value="{{searchString}}">
<div class="input-group-addon"><i class="icon ion-search"></i></div>
</div>
<div class="container">
<div class="contact-list" class="row"></div>
</div>
<hr>
<h4>Configure the conversation</h4>
<form>
<div class="form-group">
<label for="conversation-title">Conversation Title</label>
<input type="text" class="form-control" id="conversation-title" placeholder="{{suggestedTitle}}">
</div>
<div class="form-group">
<label for="conversation-blurb">Conversation Description</label>
<textarea class="form-control" id="conversation-blurb" rows="3"></textarea>
</div>
<button type="submit" class="btn btn-success create-conversation {{#noInvitees}}disabled{{/noInvitees}}">Create conversation</button>
{{#noInvitees}}
<div class="alert alert-danger">
You must invite at least one person to the conversation.
</div>
{{/noInvitees}}
</form>

View File

@ -1,5 +1,11 @@
<h2>Questions</h2>
<p class="show-only-zero-count count{{questionCount}}">There are no questions waiting for you to answer.</p>
<div class="show-only-zero-count count{{questionCount}}">
<p>There are no questions waiting for you to answer.</p>
<ul>
<li><a href="#/conversations">Go to conversation list.</a></li>
<li><a href="#/contacts">Go to contacts list.</a></li>
</ul>
</div>
<div class="container">
<div id="question-container" class="row"></div>
</div>

View File

@ -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('');
}

View File

@ -8,6 +8,7 @@
(require "protocol.rkt")
(require "duplicate.rkt")
;; TODO: Move to protocol.rkt
(struct online () #:prefab)
(struct present (email) #:prefab)

View File

@ -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)))])))))))

View File

@ -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")))

View File

@ -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.