Multi-item posts; cut-and-paste; drag-and-drop

This commit is contained in:
Tony Garnock-Jones 2016-12-14 10:36:35 +13:00
parent b946bbec3c
commit 4454fe4c03
11 changed files with 347 additions and 50 deletions

View File

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

View File

@ -31,10 +31,10 @@
</div>
{{/showConversationList}}
{{#showConversationMain}}
<div class="col-md-8 h-100 column-container">
<div id="conversation-main" class="col-md-8 h-100 column-container">
{{#selected}}
<div class="column-fill post-backdrop">
<div class="column-fill post-backdrop {{^miniMode}}not-{{/miniMode}}mini-mode">
{{#miniMode}}
<div class="conversation-control-panel bg-primary text-white px-1 mb-1">
<div class="float-right dropdown">
@ -127,9 +127,13 @@
{{/showConversationPosts}}
</div>
{{#showConversationPosts}}
<div id="pending-draft-items">
</div>
<form id="message-input-form" class="form-inline pt-1" style="display: flex;">
<input type="text" autocomplete="off" id="message-input" class="form-control" style="flex: 1">
<button id="send-message-button" class="form-control btn btn-primary" style="max-width: 3em"><i class="icon ion-paper-airplane"></i></button>
<input type="file" style="display: none;" hidden id="attach-item-file">
<button type="button" id="attach-item-button" class="form-control btn btn-secondary" style="max-width: 3em; font-size: 120%;"><i class="icon ion-paperclip"></i></button>
<button type="submit" id="send-message-button" class="form-control btn btn-primary btn-default" style="max-width: 3em"><i class="icon ion-paper-airplane"></i></button>
</form>
{{/showConversationPosts}}

View File

@ -1,6 +0,0 @@
<div id="post-{{postId}}" class="post {{postClass}}">
<div class="post-body {{contentType}}">
<p>{{content}}</p>
<p class="align-right"><small>{{author}}<br>{{date}}</small></p>
</div>
</div>

View File

@ -0,0 +1,7 @@
<div id="post-{{postId}}" class="post {{#fromMe}}from-me{{/fromMe}}{{^fromMe}}to-me{{/fromMe}}">
<div class="post-body {{contentClass}} clearfix">
{{^fromMe}}<p class="post-author text-muted">{{author}}</p>{{/fromMe}}
<div class="post-item-container"></div>
<div class="post-date text-muted">{{time}}</div>
</div>
</div>

View File

@ -0,0 +1 @@
<img class="post-item-image" src="{{itemURL}}">

View File

@ -0,0 +1 @@
<p>{{item.data}}</p>

View File

@ -0,0 +1,4 @@
<div id="{{itemId}}" class="post-item {{#postInfo.isDraft}}post-item-draft{{/postInfo.isDraft}} {{contentClass}} clearfix">
{{#postInfo.isDraft}}<button class="btn close-draft"><i class="icon ion-close"></i></button>{{/postInfo.isDraft}}
<div class="post-item-body-container"></div>
</div>

View File

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

View File

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

View File

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

View File

@ -152,8 +152,7 @@
timestamp ;; Seconds
conversation-id ;; String
author ;; Principal
content-type ;; MimeTypeString
content ;; Any
items ;; Listof DataURLString
) #:prefab) ;; ASSERTION
;;---------------------------------------------------------------------------