Skip to content
quarry Artifact [9c6bd79fe4]
Login

Artifact [9c6bd79fe4]

Artifact 9c6bd79fe43506c693772c771fac9653d9e1687fb0cccad03102bb9a519f5437:


// 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">&#8249;</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">&#8250;</button>' +
    '<button type="button" class="attach-lb-close" aria-label="close">&#215;</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); }
  });
})();