Conversation management and UI
This commit is contained in:
parent
b87639b7a4
commit
132032b602
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,12 +115,20 @@
|
|||
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,
|
||||
{
|
||||
assert c.html('#nav-ul', Mustache.render(entry, {
|
||||
email: sessionInfo.email,
|
||||
avatar: avatar(sessionInfo.email),
|
||||
questionCount: this.questionCount,
|
||||
|
@ -127,17 +142,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// 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(permissionRequest($issuer, sessionInfo.email, $permission)) {
|
||||
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--; }
|
||||
|
||||
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))
|
||||
during inbound(question($qid, $timestamp, $klass, sessionInfo.email, $title, $blurb, $qt))
|
||||
{
|
||||
on start { this.questionCount++; }
|
||||
on stop { this.questionCount--; }
|
||||
|
||||
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') {
|
||||
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, {}));
|
||||
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('');
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
(require "protocol.rkt")
|
||||
(require "duplicate.rkt")
|
||||
|
||||
;; TODO: Move to protocol.rkt
|
||||
(struct online () #:prefab)
|
||||
(struct present (email) #:prefab)
|
||||
|
||||
|
|
|
@ -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)))])))))))
|
||||
|
|
|
@ -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")))
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue