Multi-item posts; cut-and-paste; drag-and-drop
This commit is contained in:
parent
b946bbec3c
commit
4454fe4c03
|
@ -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 {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
.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: 50vh;
|
||||
}
|
||||
|
||||
.post-item-draft .post-item-image {
|
||||
max-width: 80%;
|
||||
max-height: 30vh;
|
||||
}
|
|
@ -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}}
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1 @@
|
|||
<img class="post-item-image" src="{{itemURL}}">
|
|
@ -0,0 +1 @@
|
|||
<p>{{item.data}}</p>
|
|
@ -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>
|
|
@ -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)) {
|
||||
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 mainpage_c.fragmentVersion($mainpageVersion) {
|
||||
function cleanContentType(t) {
|
||||
return t.replace('/', '-');
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)")))
|
||||
|
|
|
@ -152,8 +152,7 @@
|
|||
timestamp ;; Seconds
|
||||
conversation-id ;; String
|
||||
author ;; Principal
|
||||
content-type ;; MimeTypeString
|
||||
content ;; Any
|
||||
items ;; Listof DataURLString
|
||||
) #:prefab) ;; ASSERTION
|
||||
|
||||
;;---------------------------------------------------------------------------
|
||||
|
|
Loading…
Reference in New Issue