Skip to content
quarry Artifact [83c5b5f60d]
Login

Artifact [83c5b5f60d]

Artifact 83c5b5f60dc1eff39fd22c4099bdff82fc3a14c2056c967859317c71f5d99bf7:


// attach.js is the attachment section's enhancements: the lightbox,
// the upload that goes straight to the bucket, and paste and drop.
//
// 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.
//
// Paste and drop send nothing at the time. A file pasted or dropped
// into a composer is previewed under the box and referenced in it;
// when the form is submitted the script uploads each pending file —
// begin with no target and the family, then the PUT — and submits the
// form with the tickets as hidden `attach` inputs, which the op that
// writes the object commits. A file pasted or dropped on a section
// lands in its file picker, and the Upload button is the one control
// that sends at once. Nothing here is load-bearing.
(function () {
  'use strict';

  var canHash = !!(window.crypto && crypto.subtle && window.FormData && window.URLSearchParams);

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

  // A clipboard image arrives as "image.png" from every browser, and a
  // page of them would be twenty files with one name. Name it by the
  // moment instead, in UTC so two people's screenshots sort together.
  function pastedName(file) {
    if (!/^image\.[a-z0-9]+$/i.test(file.name || '')) return file.name || 'pasted';
    var d = new Date(), z = function (n) { return ('0' + n).slice(-2); };
    return 'pasted-' + d.getUTCFullYear() + z(d.getUTCMonth() + 1) + z(d.getUTCDate()) +
      '-' + z(d.getUTCHours()) + z(d.getUTCMinutes()) + z(d.getUTCSeconds()) +
      (file.name.slice(file.name.lastIndexOf('.')) || '.png');
  }

  function sizeWords(n) {
    if (n < 1024) return n + ' B';
    if (n < 1048576) return Math.round(n / 1024) + ' KB';
    return (n / 1048576).toFixed(1) + ' MB';
  }

  // Hash, begin and PUT: the road every upload takes. Resolves to the
  // begin answer (ticket and all) once the bucket has the bytes. An
  // empty target with a family defers the ticket for an object that
  // does not exist yet.
  function send(repo, target, family, file, name, bar) {
    return file.arrayBuffer()
      .then(function (buf) { return crypto.subtle.digest('SHA-256', buf); })
      .then(function (digest) {
        return post('/r/' + repo + '/attach/begin', {
          target: target || '', family: family || '', name: 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) 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);
        });
      });
  }

  // The progress bar and the error line of whatever holds the upload:
  // the section's form, or the composer's control.
  function gauge(el) {
    var bar = el.querySelector('progress');
    var err = el.querySelector('[data-hook=attach-error]');
    return {
      start: function () {
        if (err) { err.hidden = true; err.textContent = ''; }
        if (bar) { bar.hidden = false; bar.value = 0; bar.removeAttribute('value'); }
      },
      stop: function () { if (bar) bar.hidden = true; },
      refuse: function (e) {
        if (bar) bar.hidden = true;
        if (err) { err.textContent = e.message || String(e); err.hidden = false; } else { alert(e.message || e); }
      },
      bar: bar
    };
  }

  // Put the reference the markup resolves (internal/markup/attach.go)
  // at the caret: an image inline, anything else as a link. The input
  // event tells the composer's preview and the slash menu.
  // Run fn over items one after another, so the bar and the error line
  // mean one file at a time. Resolves when the last is done or the
  // first refuses.
  function each(items, fn) {
    return Array.prototype.reduce.call(items, function (p, it) {
      return p.then(function () { return fn(it); });
    }, Promise.resolve());
  }

  // The section's own form: one file through the bucket, then the
  // section re-rendered.
  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 go = form.querySelector('.attach-go');
    if (!file) return Promise.resolve();
    var g = gauge(form);
    g.start();
    go.disabled = true; input.disabled = true;
    return send(repo, target, '', file, file.name, g.bar)
      .then(function (b) { return post('/r/' + repo + '/attach/commit', { ticket: b.ticket }); })
      .then(function () { g.stop(); form.reset(); return refresh(section); })
      .catch(function (e) { g.refuse(e); go.disabled = false; input.disabled = false; });
  }

  // A file pasted or dropped on a section goes into its picker, where
  // the Upload button sends it: the one path that uploads is still the
  // one the person presses. A browser that cannot fill a picker keeps
  // the picker as it was.
  function pick(section, files) {
    var input = section.querySelector('[data-hook=attach-form] input[type=file]');
    if (!input || !files.length || !window.DataTransfer) return;
    try {
      var dt = new DataTransfer();
      dt.items.add(files[0]);
      input.files = dt.files;
      input.dispatchEvent(new Event('change', { bubbles: true }));
    } catch (e) { /* the picker stays empty; the person chooses the file */ }
  }

  // Files pasted or dropped into a composer: previewed in the pending
  // list, referenced in the box, and kept on the row for the submit.
  // Nothing leaves the browser here.
  function stage(ctl, files) {
    var list = ctl.querySelector('[data-hook=attach-pending]');
    var ta = composerBox(ctl);
    Array.prototype.forEach.call(files, function (file) {
      var name = pastedName(file);
      var isImage = /^image\//.test(file.type);
      var li = document.createElement('li');
      li.setAttribute('data-name', name);
      li.file = file;
      if (isImage && window.URL && URL.createObjectURL) {
        var img = document.createElement('img');
        img.className = 'attach-thumb';
        img.alt = '';
        img.src = URL.createObjectURL(file);
        // The thumbnail enlarges on a tap, the way a card's preview
        // does once the file is posted.
        var look = document.createElement('button');
        look.type = 'button'; look.className = 'attach-look';
        look.setAttribute('aria-label', 'enlarge ' + name);
        look.appendChild(img);
        look.addEventListener('click', function () { enlarge(img.src, name); });
        li.appendChild(look);
      }
      var n = document.createElement('span');
      n.className = 'attach-name'; n.textContent = name;
      var sz = document.createElement('span');
      sz.className = 'attach-size'; sz.textContent = sizeWords(file.size);
      var b = document.createElement('button');
      b.type = 'button'; b.textContent = 'Remove';
      b.setAttribute('aria-label', 'take ' + name + ' out');
      b.addEventListener('click', function () {
        var img = li.querySelector('img');
        if (img && URL.revokeObjectURL) URL.revokeObjectURL(img.src);
        li.remove();
      });
      li.appendChild(n); li.appendChild(sz); li.appendChild(b);
      list.appendChild(li);
    });
  }

  // One pending picture, large, in a dialog of its own: the page's
  // lightbox is built from the cards it found at load, and this file
  // is not a card yet.
  function enlarge(src, name) {
    var dlg = document.createElement('dialog');
    dlg.className = 'attach-lightbox';
    dlg.innerHTML = '<figure><img alt=""></figure><div class="attach-lb-bar"><span class="attach-lb-meta"><span class="attach-lb-name"></span></span><button type="button" class="attach-lb-close" aria-label="close">&#215;</button></div>';
    dlg.querySelector('img').src = src;
    dlg.querySelector('.attach-lb-name').textContent = name;
    dlg.querySelector('.attach-lb-close').addEventListener('click', function () { dlg.close(); });
    dlg.addEventListener('click', function (e) { if (e.target === dlg) dlg.close(); });
    dlg.addEventListener('close', function () { dlg.remove(); });
    document.body.appendChild(dlg);
    if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
  }

  // After a post that carried files, the page may arrive before the
  // index has folded their A cards: ?attaching=TARGET:N says so. The
  // section for that target shows N placeholders and asks for its
  // fragment until the cards are there, then swaps them in.
  function awaitCards() {
    var m = /[?&]attaching=([^&:]+):(\d+)/.exec(location.search);
    if (!m) return;
    var target = decodeURIComponent(m[1]), want = +m[2];
    try { history.replaceState(null, '', location.pathname + location.search.replace(/[?&]attaching=[^&]*/, '').replace(/^&/, '?') + location.hash); } catch (e) {}
    var find = function () {
      return Array.prototype.filter.call(document.querySelectorAll('.attach[data-hook=attachments]'), function (s) {
        return (s.getAttribute('data-target') || '') === target;
      })[0];
    };
    var count = function (s) { return s.querySelectorAll('[data-hook=attachment]').length; };
    var section = find();
    if (!section || count(section) >= want) return;
    var grid = section.querySelector('.attach-grid');
    if (!grid) { grid = document.createElement('div'); grid.className = 'attach-grid'; section.insertBefore(grid, section.querySelector('.attach-form, .attach-unbound, .attach-cors')); }
    for (var i = count(section); i < want; i++) {
      var ph = document.createElement('article');
      ph.className = 'attach-card attach-loading';
      ph.setAttribute('data-hook', 'attach-loading');
      ph.setAttribute('aria-busy', 'true');
      ph.innerHTML = '<span class="attach-prev"><span class="attach-spin" aria-hidden="true"></span></span><div class="attach-info"><span class="attach-name">arriving\u2026</span></div>';
      grid.appendChild(ph);
    }
    var tries = 0;
    var tick = function () {
      var s = find();
      if (!s) return;
      if (count(s) >= want || tries++ > 30) return;
      var repo = s.getAttribute('data-repo');
      var q = new URLSearchParams({ target: target, family: s.getAttribute('data-family') || '', compact: s.classList.contains('attach-compact') ? '1' : '0', back: location.pathname + '#attachments' });
      fetch('/r/' + repo + '/attach.frag?' + q.toString(), { credentials: 'same-origin' })
        .then(function (res) { return res.ok ? res.text() : ''; })
        .then(function (html) {
          var tpl = document.createElement('template');
          tpl.innerHTML = html.trim();
          var next = tpl.content.firstElementChild;
          if (next && count(next) >= want) { s.replaceWith(next); enhance(next); return; }
          setTimeout(tick, 700);
        })
        .catch(function () { setTimeout(tick, 1500); });
    };
    setTimeout(tick, 500);
  }

  // The submit: every pending row without a ticket is uploaded in
  // turn, and the form goes only when all are in the bucket. A refusal
  // names the file on the error line and marks its row; the person
  // takes it out or tries again, and rows already uploaded keep their
  // ticket and are not sent twice.
  function submitWithUploads(ctl, form, ev) {
    if (form.getAttribute('data-attach-ready')) return;
    var rows = Array.prototype.filter.call(ctl.querySelectorAll('[data-hook=attach-pending] > li'), function (li) {
      return !li.getAttribute('data-ticket');
    });
    if (!rows.length) return;
    ev.preventDefault();
    var submitter = ev.submitter || null;
    var repo = ctl.getAttribute('data-repo');
    var family = ctl.getAttribute('data-family');
    // An existing object (a ticket's comment box, the wiki editor) is
    // named at begin; one not yet written is begun by family alone.
    var target = ctl.getAttribute('data-target') || '';
    var buttons = form.querySelectorAll('button[type=submit], input[type=submit]');
    var g = gauge(ctl);
    g.start();
    buttons.forEach(function (b) { b.disabled = true; });
    each(rows, function (li) {
      li.removeAttribute('data-state');
      return send(repo, target, family, li.file, li.getAttribute('data-name'), g.bar)
        .then(function (b) {
          li.setAttribute('data-ticket', b.ticket);
          var input = document.createElement('input');
          input.type = 'hidden'; input.name = 'attach'; input.value = b.ticket;
          li.appendChild(input);
        })
        .catch(function (e) {
          li.setAttribute('data-state', 'failed');
          throw new Error(li.getAttribute('data-name') + ': ' + (e.message || e));
        });
    })
      .then(function () {
        g.stop();
        form.setAttribute('data-attach-ready', '1');
        if (typeof form.requestSubmit === 'function') { form.requestSubmit(submitter); return; }
        // Without requestSubmit the pressed button's own name and value
        // would be lost (a ticket's "Comment & close" is one), so they
        // are carried as a hidden field.
        if (submitter && submitter.name) {
          var hidden = document.createElement('input');
          hidden.type = 'hidden'; hidden.name = submitter.name; hidden.value = submitter.value;
          form.appendChild(hidden);
        }
        form.submit();
      })
      .catch(function (e) {
        g.refuse(e);
        buttons.forEach(function (b) { b.disabled = false; });
      });
  }

  // The composer's textarea: the one in the same composer, else the
  // first in the same form (the ticket form's plain Comment box).
  function composerBox(ctl) {
    var host = ctl.closest('.f-composer') || ctl.closest('form');
    return host ? host.querySelector('textarea') : null;
  }

  // The element a composer's control frames while a file is over it.
  function zoneOf(ctl) {
    return ctl.closest('.f-composer') || ctl.closest('form') || ctl;
  }

  // Drop handling shared by sections and composers: the zone's
  // data-drop is "over" while a file is above it and back to what the
  // markup said ("ready", or nothing) after. dragleave fires for every
  // child crossed, so it is reset only when the pointer has left the
  // zone itself.
  function droppable(zone, fn) {
    if (!window.DataTransfer) return;
    var idle = zone.getAttribute('data-drop');
    var reset = function () {
      if (idle) zone.setAttribute('data-drop', idle); else zone.removeAttribute('data-drop');
    };
    zone.addEventListener('dragover', function (ev) {
      if (!ev.dataTransfer || Array.prototype.indexOf.call(ev.dataTransfer.types, 'Files') < 0) return;
      ev.preventDefault();
      ev.dataTransfer.dropEffect = 'copy';
      zone.setAttribute('data-drop', 'over');
    });
    zone.addEventListener('dragleave', function (ev) {
      if (ev.relatedTarget && zone.contains(ev.relatedTarget)) return;
      reset();
    });
    zone.addEventListener('drop', function (ev) {
      reset();
      if (!ev.dataTransfer || !ev.dataTransfer.files || !ev.dataTransfer.files.length) return;
      ev.preventDefault();
      fn(Array.prototype.slice.call(ev.dataTransfer.files));
    });
  }

  // Wire one section: its form uploads through the bucket, a drop
  // fills its picker, 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 && canHash) {
      form.addEventListener('submit', function (ev) {
        ev.preventDefault();
        upload(form);
      });
      droppable(section, function (files) { pick(section, files); });
    }
    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);
  awaitCards();

  // The composers' controls: drawn hidden, shown once the browser can
  // do the work, a drop zone over the box they sit under, and the
  // submit that uploads what was staged.
  if (canHash) {
    document.querySelectorAll('[data-hook=attach-composer]').forEach(function (ctl) {
      var form = ctl.closest('form');
      if (!form) return;
      ctl.hidden = false;
      var zone = zoneOf(ctl);
      if (zone !== ctl) zone.setAttribute('data-drop', 'ready');
      droppable(zone, function (files) { stage(ctl, files); });
      var pick = ctl.querySelector('[data-hook=attach-choose] input[type=file]');
      if (pick) {
        pick.addEventListener('change', function () {
          if (pick.files && pick.files.length) stage(ctl, pick.files);
          // Cleared so choosing the same file twice in a row still
          // fires a change, and so the input never submits a file the
          // form has already staged.
          pick.value = '';
        });
      }
      form.addEventListener('submit', function (ev) { submitWithUploads(ctl, form, ev); });
    });
  }

  // Which section a paste lands in when the page has several (a
  // thread): the one around the focused element, else the first that
  // offers an upload at all.
  function sectionFor(active) {
    var s = active && active.closest && active.closest('.attach[data-hook=attachments]');
    if (s && s.querySelector('[data-hook=attach-form]')) return s;
    var form = document.querySelector('.attach[data-hook=attachments] [data-hook=attach-form]');
    return form ? form.closest('.attach') : null;
  }

  // A paste with files: staged in the composer the focus is in, else
  // put in the picker of the section the rule above picks. Text
  // pastes are the browser's.
  if (canHash) {
    document.addEventListener('paste', function (ev) {
      var cd = ev.clipboardData;
      if (!cd || !cd.files || !cd.files.length) return;
      var files = Array.prototype.slice.call(cd.files);
      var active = document.activeElement;
      var host = active && active.closest && (active.closest('.f-composer') || active.closest('form'));
      var ctl = host && host.querySelector('[data-hook=attach-composer]');
      if (ctl) {
        ev.preventDefault();
        stage(ctl, files);
        return;
      }
      var section = sectionFor(active);
      if (!section) return;
      ev.preventDefault();
      pick(section, files);
    });
  }

  // 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); }
  });
})();