// attach.js is the attachment section's two enhancements: the
// lightbox, and the upload that goes straight to the bucket.
//
// Without it every image is a plain link to its inline view, which
// opens the picture, and the form posts the file to quarry, which
// carries it to the bucket (RFC 0016 §Writing, the floor). With it the
// picture opens in place, and the file never touches the forge: the
// browser hashes it, asks begin for a presigned PUT, sends the bytes
// to the bucket with the two signed headers, and commits. Nothing
// here is load-bearing.
(function () {
'use strict';
function hex(buf) {
return Array.prototype.map.call(new Uint8Array(buf), function (b) {
return ('0' + b.toString(16)).slice(-2);
}).join('');
}
// Replace the section with a fresh render of itself, so what the
// page shows after an upload is exactly what a reload would show.
function refresh(section) {
var repo = section.getAttribute('data-repo');
var q = new URLSearchParams({
target: section.getAttribute('data-target'),
family: section.getAttribute('data-family') || '',
compact: section.classList.contains('attach-compact') ? '1' : '0',
back: location.pathname + '#attachments'
});
return fetch('/r/' + repo + '/attach.frag?' + q.toString(), { credentials: 'same-origin' })
.then(function (res) { if (!res.ok) throw new Error('frag ' + res.status); return res.text(); })
.then(function (html) {
var tpl = document.createElement('template');
tpl.innerHTML = html.trim();
var next = tpl.content.firstElementChild;
if (!next) throw new Error('empty fragment');
section.replaceWith(next);
enhance(next);
})
.catch(function () { location.reload(); });
}
function post(url, body) {
return fetch(url, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: new URLSearchParams(body).toString()
}).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (j) {
if (!res.ok) throw new Error(j.error || ('refused (' + res.status + ')'));
return j;
});
});
}
function upload(form) {
var section = form.closest('.attach');
var repo = section.getAttribute('data-repo');
var target = section.getAttribute('data-target');
var input = form.querySelector('input[type=file]');
var file = input.files && input.files[0];
var bar = form.querySelector('progress');
var err = form.querySelector('[data-hook=attach-error]');
var go = form.querySelector('.attach-go');
if (!file) return Promise.resolve();
err.hidden = true; err.textContent = '';
bar.hidden = false; bar.value = 0; bar.removeAttribute('value');
go.disabled = true; input.disabled = true;
var done = function () { go.disabled = false; input.disabled = false; bar.hidden = true; };
var refuse = function (e) { done(); err.textContent = e.message || String(e); err.hidden = false; };
return file.arrayBuffer()
.then(function (buf) { return crypto.subtle.digest('SHA-256', buf); })
.then(function (digest) {
return post('/r/' + repo + '/attach/begin', {
target: target, name: file.name, size: String(file.size),
type: file.type || 'application/octet-stream', sha256: hex(digest)
});
})
.then(function (b) {
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(b.method || 'PUT', b.url);
// Content-Length is the browser's to send; the type is ours,
// and both are in the signature.
xhr.setRequestHeader('Content-Type', b.headers['Content-Type']);
xhr.upload.onprogress = function (ev) {
if (ev.lengthComputable) bar.value = Math.round(100 * ev.loaded / ev.total);
};
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) resolve(b);
else reject(new Error('the bucket answered ' + xhr.status));
};
xhr.onerror = function () { reject(new Error('the upload to the bucket failed')); };
xhr.send(file);
});
})
.then(function (b) { return post('/r/' + repo + '/attach/commit', { ticket: b.ticket }); })
.then(function () { done(); form.reset(); return refresh(section); })
.catch(refuse);
}
// Wire one section: its form uploads through the bucket, and its
// card buttons post without leaving the page. A destructive button
// asks first — the settings page's data-confirm idiom, with the
// browser's own dialog since these pages carry no other.
function enhance(section) {
var form = section.querySelector('[data-hook=attach-form]');
if (form && window.crypto && crypto.subtle) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
upload(form);
});
}
section.querySelectorAll('.attach-inline-form').forEach(function (f) {
f.addEventListener('submit', function (ev) {
ev.preventDefault();
ev.stopPropagation();
var b = ev.submitter;
if (b && b.hasAttribute('data-confirm') && !window.confirm(b.getAttribute('data-confirm'))) return;
var body = {};
new FormData(f).forEach(function (v, k) { body[k] = v; });
post(f.getAttribute('action'), body)
.then(function () { return refresh(section); })
.catch(function (e) {
var err = section.querySelector('[data-hook=attach-error]');
if (err) { err.textContent = e.message; err.hidden = false; } else { alert(e.message); }
});
});
});
}
document.querySelectorAll('.attach[data-hook=attachments]').forEach(enhance);
// Every image on the page that an attachment route serves: the cards'
// previews and the inline images a body resolved from attach:. They
// are collected in document order, which is the order prev/next walk.
function shots() {
var out = [];
document.querySelectorAll('.attach-card[data-kind="image"]:not([data-held]) .attach-prev').forEach(function (a) {
var img = a.querySelector('img');
out.push({
src: a.getAttribute('href'),
name: a.getAttribute('data-attach-name') || (img && img.alt) || '',
sub: a.getAttribute('data-attach-sub') || '',
dl: a.getAttribute('data-attach-dl') || a.getAttribute('href'),
from: a
});
});
document.querySelectorAll('article img, .tkt-body img, .f-post-b img').forEach(function (img) {
var src = img.getAttribute('src') || '';
if (!/\/attachment\/[^/]+\/view$/.test(src)) return;
out.push({
src: src,
name: img.getAttribute('alt') || '',
sub: '',
dl: src.replace(/\/view$/, ''),
from: img
});
});
return out;
}
var all = shots();
if (!all.length) return;
var dlg = document.createElement('dialog');
dlg.className = 'attach-lightbox';
dlg.innerHTML =
'<figure><img alt=""></figure>' +
'<div class="attach-lb-bar">' +
'<button type="button" class="attach-lb-prev" aria-label="previous image">‹</button>' +
'<span class="attach-lb-meta"><span class="attach-lb-name"></span> <span class="attach-lb-sub"></span></span>' +
'<a class="attach-lb-dl" download>Download</a>' +
'<button type="button" class="attach-lb-next" aria-label="next image">›</button>' +
'<button type="button" class="attach-lb-close" aria-label="close">×</button>' +
'</div>';
document.body.appendChild(dlg);
var img = dlg.querySelector('img');
var nameEl = dlg.querySelector('.attach-lb-name');
var subEl = dlg.querySelector('.attach-lb-sub');
var dlEl = dlg.querySelector('.attach-lb-dl');
var at = 0;
var opener = null;
function show(i) {
if (i < 0) i = all.length - 1;
if (i >= all.length) i = 0;
at = i;
var s = all[at];
img.setAttribute('src', s.src);
img.setAttribute('alt', s.name);
nameEl.textContent = s.name;
subEl.textContent = s.sub;
dlEl.setAttribute('href', s.dl);
// One image is not a carousel; hide the arrows rather than offering
// two controls that do nothing.
var many = all.length > 1;
dlg.querySelector('.attach-lb-prev').hidden = !many;
dlg.querySelector('.attach-lb-next').hidden = !many;
}
function open(i, from) {
opener = from || null;
show(i);
if (typeof dlg.showModal === 'function') dlg.showModal();
else dlg.setAttribute('open', '');
dlg.querySelector('.attach-lb-close').focus();
}
// Focus goes back where it came from. A reader who opened the fourth
// card with the keyboard lands on the fourth card again, not at the
// top of the document.
dlg.addEventListener('close', function () {
if (opener && typeof opener.focus === 'function') opener.focus();
opener = null;
});
all.forEach(function (s, i) {
var target = s.from.tagName === 'IMG' && s.from.parentElement &&
s.from.parentElement.tagName === 'A' ? s.from.parentElement : s.from;
target.addEventListener('click', function (e) {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
open(i, target);
});
});
dlg.querySelector('.attach-lb-prev').addEventListener('click', function () { show(at - 1); });
dlg.querySelector('.attach-lb-next').addEventListener('click', function () { show(at + 1); });
dlg.querySelector('.attach-lb-close').addEventListener('click', function () { dlg.close(); });
// The backdrop is the dialog itself: a click that lands on the
// element rather than on its contents is a click outside the picture.
dlg.addEventListener('click', function (e) { if (e.target === dlg) dlg.close(); });
dlg.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { e.preventDefault(); show(at - 1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); show(at + 1); }
});
})();