From 4454fe4c03df01f9862685546eee18fab4f5b7f4 Mon Sep 17 00:00:00 2001 From: Tony Garnock-Jones Date: Wed, 14 Dec 2016 10:36:35 +1300 Subject: [PATCH] Multi-item posts; cut-and-paste; drag-and-drop --- examples/webchat/htdocs/style.css | 42 ++- .../htdocs/templates/page-conversations.html | 10 +- .../templates/post-entry-text-plain.html | 6 - .../webchat/htdocs/templates/post-entry.html | 7 + .../htdocs/templates/post-item-image.html | 1 + .../templates/post-item-text-plain.html | 1 + .../webchat/htdocs/templates/post-item.html | 4 + examples/webchat/htdocs/webchat.syndicate.js | 307 ++++++++++++++++-- examples/webchat/server/conversation.rkt | 14 +- examples/webchat/server/pages.rkt | 2 +- examples/webchat/server/protocol.rkt | 3 +- 11 files changed, 347 insertions(+), 50 deletions(-) delete mode 100644 examples/webchat/htdocs/templates/post-entry-text-plain.html create mode 100644 examples/webchat/htdocs/templates/post-entry.html create mode 100644 examples/webchat/htdocs/templates/post-item-image.html create mode 100644 examples/webchat/htdocs/templates/post-item-text-plain.html create mode 100644 examples/webchat/htdocs/templates/post-item.html diff --git a/examples/webchat/htdocs/style.css b/examples/webchat/htdocs/style.css index f74c8b6..756f92a 100644 --- a/examples/webchat/htdocs/style.css +++ b/examples/webchat/htdocs/style.css @@ -127,6 +127,7 @@ img.avatar { border-radius: 1.5rem; padding: 1rem; margin: 0 0px; + min-height: 60px; } .post p { @@ -165,7 +166,44 @@ img.avatar { display: block; } -.post-body > img { +.post-date { + float: right; + height: 0.25em; + display: block; + font-size: 0.75rem; + padding-right: 0.5em; +} + +.post-author { + /* font-weight: bold; */ + font-size: 0.75rem; + position: relative; + top: -0.75em; + height: 0.75em; +} + +.post-item { +} + +.post-item-draft { + /* background: #e8e8ff; */ + background: white; + border: solid #d3d3d3 1px; + border-radius: 1.5rem; + padding: 1rem; + margin: 1rem 0 0 0; +} + +.post-item-draft .close-draft { + float: right; +} + +.post-item-image { max-width: 100%; - max-height: 100%; + max-height: 50vh; +} + +.post-item-draft .post-item-image { + max-width: 80%; + max-height: 30vh; } \ No newline at end of file diff --git a/examples/webchat/htdocs/templates/page-conversations.html b/examples/webchat/htdocs/templates/page-conversations.html index e88b5ea..da99196 100644 --- a/examples/webchat/htdocs/templates/page-conversations.html +++ b/examples/webchat/htdocs/templates/page-conversations.html @@ -31,10 +31,10 @@ {{/showConversationList}} {{#showConversationMain}} -
+
{{#selected}} -
+
{{#miniMode}}
{{#showConversationPosts}} +
+
- + + +
{{/showConversationPosts}} diff --git a/examples/webchat/htdocs/templates/post-entry-text-plain.html b/examples/webchat/htdocs/templates/post-entry-text-plain.html deleted file mode 100644 index 6b12468..0000000 --- a/examples/webchat/htdocs/templates/post-entry-text-plain.html +++ /dev/null @@ -1,6 +0,0 @@ -
-
-

{{content}}

-

{{author}}
{{date}}

-
-
diff --git a/examples/webchat/htdocs/templates/post-entry.html b/examples/webchat/htdocs/templates/post-entry.html new file mode 100644 index 0000000..462cfa9 --- /dev/null +++ b/examples/webchat/htdocs/templates/post-entry.html @@ -0,0 +1,7 @@ +
+
+ {{^fromMe}}{{/fromMe}} +
+ +
+
diff --git a/examples/webchat/htdocs/templates/post-item-image.html b/examples/webchat/htdocs/templates/post-item-image.html new file mode 100644 index 0000000..b8fa923 --- /dev/null +++ b/examples/webchat/htdocs/templates/post-item-image.html @@ -0,0 +1 @@ + diff --git a/examples/webchat/htdocs/templates/post-item-text-plain.html b/examples/webchat/htdocs/templates/post-item-text-plain.html new file mode 100644 index 0000000..90efaa7 --- /dev/null +++ b/examples/webchat/htdocs/templates/post-item-text-plain.html @@ -0,0 +1 @@ +

{{item.data}}

diff --git a/examples/webchat/htdocs/templates/post-item.html b/examples/webchat/htdocs/templates/post-item.html new file mode 100644 index 0000000..5d6a6ad --- /dev/null +++ b/examples/webchat/htdocs/templates/post-item.html @@ -0,0 +1,4 @@ +
+ {{#postInfo.isDraft}}{{/postInfo.isDraft}} +
+
diff --git a/examples/webchat/htdocs/webchat.syndicate.js b/examples/webchat/htdocs/webchat.syndicate.js index 0d52b33..a1bb940 100644 --- a/examples/webchat/htdocs/webchat.syndicate.js +++ b/examples/webchat/htdocs/webchat.syndicate.js @@ -14,7 +14,7 @@ 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, author, contentType, content); + assertion type post(id, timestamp, conversationId, author, items); message type createResource(description) = "create-resource"; message type updateResource(description) = "update-resource"; @@ -40,6 +40,9 @@ assertion type selectedCid(cid); // currently-selected conversation ID, or null message type windowWidthChanged(newWidth); + assertion type draftItem(timestamp, dataURL); + message type draftSent(); + //--------------------------------------------------------------------------- var brokerConnected = Syndicate.Broker.brokerConnected; @@ -75,6 +78,10 @@ /////////////////////////////////////////////////////////////////////////// + document.addEventListener('dragover', function (e) { + e.preventDefault(); // make it so drag-and-drop doesn't load the dropped object into the browser + }); + window.addEventListener('load', function () { if (document.body.id === 'webchat-main') { $('head meta').each(function (_i, tag) { @@ -432,6 +439,10 @@ field this.latestPostTimestamp = 0; field this.latestPostId = null; + field this.draftItems = Immutable.Map(); + on asserted draftItem($ts, $d) { this.draftItems = this.draftItems.set(ts, d); } + on retracted draftItem($ts, _) { this.draftItems = this.draftItems.remove(ts); } + assert mainpage_c.html('div#main-div', Mustache.render(mainEntry, { miniMode: this.miniMode, showConversationList: !this.miniMode, @@ -458,17 +469,81 @@ setTimeout(function () { e.target.scrollIntoView(false); }, 500); } + var spawnItemFromDataURL = (function (ui) { + return function (dataURL) { + var timestamp = +(new Date()); + actor { + field this.ui = ui.context('draft-post', timestamp); + assert draftItem(timestamp, dataURL); + manifestPostItem(this.ui, + '#pending-draft-items', + { + isDraft: true, + postId: 'draft', + timestamp: timestamp, + fromMe: true, + author: sessionInfo.email + }, + dataURL); + stop on message draftSent(); + stop on message this.ui.event('.close-draft', 'click', _); + } + }; + })(this.ui); + + var handleDataTransfer = function (dataTransfer) { + return dataTransferFiles(dataTransfer, Syndicate.Dataspace.wrap( + function (dataURLs) { + dataURLs.forEach(spawnItemFromDataURL); + })); + }; + + on message mainpage_c.event('#conversation-main', 'drop', $e) { + handleDataTransfer.call(this, e.dataTransfer); + } + + on message mainpage_c.event('#message-input', '+paste', $e) { + if (handleDataTransfer.call(this, e.clipboardData)) { + e.preventDefault(); + } + } + + on message mainpage_c.event('#attach-item-button', 'click', _) { + console.log('clickenating'); + $('#attach-item-file').click(); + } + on message mainpage_c.event('#attach-item-file', 'change', $e) { + if (e.target.files) { + for (var i = 0; i < e.target.files.length; i++) { + var file = e.target.files[i]; + var reader = new FileReader(); + reader.addEventListener('load', Syndicate.Dataspace.wrap(function (e) { + spawnItemFromDataURL(e.target.result); + })); + reader.readAsDataURL(file); + } + } + } + on message mainpage_c.event('#send-message-button', 'click', _) { + var timestamp = +(new Date()); + var items = this.draftItems.entrySeq().toArray(); + items.sort(function (a, b) { return a[0] - b[0]; }); var message = ($("#message-input").val() || '').trim(); if (message) { + var b64 = btoa(unescape(encodeURIComponent(message))); // utf-8, then base64 + items.push([timestamp, + "data:text/plain;charset=utf-8;base64," + encodeURIComponent(b64)]); + } + if (items.length) { :: outbound(createResource(post(random_hex_string(16), - +(new Date()), + timestamp, cid, sessionInfo.email, - "text/plain", - message))); + items.map(function (di) { return di[1]; })))); } $("#message-input").val('').focus(); + :: draftSent(); } on message mainpage_c.event('.invite-to-conversation', 'click', _) { @@ -540,32 +615,33 @@ this.editingBlurb = false; } - during inbound(post($pid, $timestamp, cid, $author, $contentType, $content)) { - if (timestamp > this.latestPostTimestamp) { - this.latestPostTimestamp = timestamp; - this.latestPostId = pid; - } - during mainpage_c.fragmentVersion($mainpageVersion) { - function cleanContentType(t) { - return t.replace('/', '-'); + during mainpage_c.fragmentVersion($mainpageVersion) { + during inbound(post($pid, $timestamp, cid, $author, $items)) { + var fromMe = (author === sessionInfo.email); + var postInfo = { + isDraft: false, + postId: pid, + timestamp: timestamp, + date: new Date(timestamp).toString(), + time: new Date(timestamp).toTimeString().substr(0, 8), + fromMe: fromMe, + author: author + }; + if (timestamp > this.latestPostTimestamp) { + this.latestPostTimestamp = timestamp; + this.latestPostId = pid; } - during inbound( - uiTemplate("post-entry-" + cleanContentType(contentType) + ".html", $entry)) - { - var c = this.ui.context(mainpageVersion, 'post', timestamp, pid); - assert c.html('.posts', Mustache.render(entry, { - postId: pid, - date: new Date(timestamp).toString(), - postClass: (author === sessionInfo.email) ? "from-me" : "to-me", - author: author, - contentType: cleanContentType(contentType), - content: content - })); - on asserted c.fragmentVersion(_) { - if ((this.latestPostTimestamp === timestamp) && - (this.latestPostId === pid)) { - $("#post-" + pid)[0].scrollIntoView(false); - } + var c = this.ui.context(mainpageVersion, 'post', timestamp, pid); + during inbound(uiTemplate("post-entry.html", $postEntryTemplate)) { + assert c.html('.posts', Mustache.render(postEntryTemplate, postInfo)); + during c.fragmentVersion(_) { + var itemCounter = 0; + items.forEach((function (itemURL) { + manifestPostItem(c.context('item', itemCounter++), + '#post-' + pid + ' .post-item-container', + postInfo, + itemURL); + }).bind(this)); } } } @@ -668,6 +744,49 @@ // $("#debug-space").text(Syndicate.prettyTrie(mux.routingTable)); // }); } + + var nextItemid = 0; + function manifestPostItem(uiContext, containerSelector, postInfo, itemURL) { + function cleanContentType(t) { + t = t.toLowerCase(); + if (t.startsWith('image/')) { + t = 'image'; + } else { + t = t.replace('/', '-'); + } + return t; + } + + var item = parseDataURL(itemURL); + var itemId = 'post-' + postInfo.postId + '-item-' + nextItemid++; + var contentClass = cleanContentType(item.type); + var itemInfo = { + itemId: itemId, + postInfo: postInfo, + contentClass: contentClass, + item: item, + itemURL: itemURL + }; + + during inbound(uiTemplate("post-item.html", $postItemTemplate)) { + during inbound(uiTemplate("post-item-" + contentClass + ".html", $entry)) { + assert uiContext.html(containerSelector, Mustache.render(postItemTemplate, itemInfo)); + on asserted uiContext.fragmentVersion(_) { + var innerContext = uiContext.context('item-body'); + assert innerContext.html('#' + itemId + ' .post-item-body-container', + Mustache.render(entry, itemInfo)); + if (!postInfo.isDraft) { + on asserted innerContext.fragmentVersion(_) { + if ((this.latestPostTimestamp === postInfo.timestamp) && + (this.latestPostId === postInfo.postId)) { + setTimeout(function () { $("#post-" + postInfo.postId)[0].scrollIntoView(false); }, 1); + } + } + } + } + } + } + } })(); /////////////////////////////////////////////////////////////////////////// @@ -703,3 +822,133 @@ function random_hex_string(halfLength) { } return encoded.join(''); } + +/////////////////////////////////////////////////////////////////////////// + +function parseDataURL(u) { + var pieces; + + if (!u.startsWith('data:')) return null; + u = u.substr(5); + + pieces = u.split(','); + if (pieces.length !== 2) return null; + + var mimeType = pieces[0]; + var data = decodeURIComponent(pieces[1]); + var isBase64 = false; + + if (mimeType.endsWith(';base64')) { + mimeType = mimeType.substr(0, mimeType.length - 7); + isBase64 = true; + } + + if (isBase64) { + data = atob(data); + } + + pieces = mimeType.split(';'); + var type = pieces[0]; + + var parameters = {}; + for (var i = 1; i < pieces.length; i++) { + var m = pieces[i].match(/^([^=]+)=(.*)$/); + if (m) { + parameters[m[1].toLowerCase()] = m[2]; + } + } + + if (type.startsWith('text/')) { + var charset = (parameters.charset || 'US-ASCII').toLowerCase(); + switch (charset) { + case 'utf-8': + data = decodeURIComponent(escape(data)); + break; + case 'us-ascii': + case 'ascii': + case 'latin1': + case 'iso-8859-1': + break; + default: + console.warn('Unknown charset while decoding data URL:', charset); + break; + } + } + + return { + type: type, + parameters: parameters, + data: data + }; +} + +/////////////////////////////////////////////////////////////////////////// + +// Extract file contents from a DataTransfer object +function dataTransferFiles(d, k) { + var items = d.items; + var types = d.types; + var files = d.files; + + var results = []; + var expectedCount = files.length; + var completedCount = 0; + + function completeOne() { + completedCount++; + if (completedCount === expectedCount) { + k(results); + } + } + + for (var i = 0; i < items.length; i++) { + (function (i) { + var item = items[i]; + var type = types[i]; + if (type === 'text/uri-list') { + expectedCount++; + item.getAsString(function (itemstr) { + var firstChunk = itemstr.substr(0, 6).toLowerCase(); + if (firstChunk.startsWith('http:') || firstChunk.startsWith('https:')) { + $.ajax({ + type: "GET", + url: itemstr, + beforeSend: function (xhr) { + xhr.overrideMimeType('text/plain; charset=x-user-defined'); + }, + success: function (_data, _status, xhr) { + var contentType = xhr.getResponseHeader('content-type'); + var rawdata = xhr.responseText; + var data = []; + for (var j = 0; j < rawdata.length; j++) { + data = data + String.fromCharCode(rawdata.charCodeAt(j) & 0xff); + } + results.push('data:' + contentType + ';base64,' + encodeURIComponent(btoa(data))); + completeOne(); + }, + error: function () { + completeOne(); + } + }); + } else { + completeOne(); + } + }); + } + })(i); + } + + for (var i = 0; i < files.length; i++) { + (function (i) { + var file = files[i]; + var reader = new FileReader(); + reader.addEventListener('load', function (e) { + results.push(e.target.result); + completeOne(); + }); + reader.readAsDataURL(file); + })(i); + } + + return (expectedCount > 0); +} diff --git a/examples/webchat/server/conversation.rkt b/examples/webchat/server/conversation.rkt index 8c08173..3941b08 100644 --- a/examples/webchat/server/conversation.rkt +++ b/examples/webchat/server/conversation.rkt @@ -70,7 +70,7 @@ (assert (api (session who _) i))) (during ($ c (conversation cid _ _ _)) (assert (api (session who _) c))) - (during ($ p (post _ _ cid _ _ _)) + (during ($ p (post _ _ cid _ _)) (assert (api (session who _) p)))))) (supervise @@ -120,16 +120,16 @@ (actor #:name 'post-factory (stop-when-reloaded) (on (message (create-resource - ($ p0 (post $pid $timestamp $cid $author $content-type $content0)))) + ($ p0 (post $pid $timestamp $cid $author $items0)))) (actor #:name p0 - (field [content content0]) - (define/dataflow p (post pid timestamp cid author content-type (content))) + (field [items items0]) + (define/dataflow p (post pid timestamp cid author (items))) (assert (p)) (stop-when-duplicate (list 'post cid pid)) - (stop-when (message (delete-resource (post pid _ cid _ _ _)))) + (stop-when (message (delete-resource (post pid _ cid _ _)))) (stop-when (message (delete-resource (conversation cid _ _ _)))) - (on (message (update-resource (post pid _ cid _ _ $newcontent))) - (content newcontent)))))) + (on (message (update-resource (post pid _ cid _ $newitems))) + (items newitems)))))) (supervise (actor #:name 'conversation:questions diff --git a/examples/webchat/server/pages.rkt b/examples/webchat/server/pages.rkt index 5209d39..795469a 100644 --- a/examples/webchat/server/pages.rkt +++ b/examples/webchat/server/pages.rkt @@ -62,7 +62,7 @@ `())) (div ((class "container main-container")) (div ((class "header clearfix")) - (nav ((class "navbar bg-faded")) + (nav ((class "navbar")) ;; (span ((id "nav-heading") (class "navbar-brand text-muted")) ,nav-heading) (ul ((id "nav-ul") (class "nav navbar-nav nav-pills float-xs-right")) ;; (li ((class "nav-item")) (a ((class "nav-link active") (href "#")) "Home " (span ((class "sr-only")) "(current)"))) diff --git a/examples/webchat/server/protocol.rkt b/examples/webchat/server/protocol.rkt index 364cd33..127c792 100644 --- a/examples/webchat/server/protocol.rkt +++ b/examples/webchat/server/protocol.rkt @@ -152,8 +152,7 @@ timestamp ;; Seconds conversation-id ;; String author ;; Principal - content-type ;; MimeTypeString - content ;; Any + items ;; Listof DataURLString ) #:prefab) ;; ASSERTION ;;---------------------------------------------------------------------------