about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/utils
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/utils')
-rw-r--r--app/javascript/flavours/glitch/utils/backend_links.js18
-rw-r--r--app/javascript/flavours/glitch/utils/base64.js10
-rw-r--r--app/javascript/flavours/glitch/utils/config.js10
-rw-r--r--app/javascript/flavours/glitch/utils/content_warning.js31
-rw-r--r--app/javascript/flavours/glitch/utils/dom_helpers.js14
-rw-r--r--app/javascript/flavours/glitch/utils/filters.js16
-rw-r--r--app/javascript/flavours/glitch/utils/hashtag.js8
-rw-r--r--app/javascript/flavours/glitch/utils/html.js5
-rw-r--r--app/javascript/flavours/glitch/utils/icons.js15
-rw-r--r--app/javascript/flavours/glitch/utils/idna.js10
-rw-r--r--app/javascript/flavours/glitch/utils/js_helpers.js5
-rw-r--r--app/javascript/flavours/glitch/utils/log_out.js34
-rw-r--r--app/javascript/flavours/glitch/utils/notifications.js30
-rw-r--r--app/javascript/flavours/glitch/utils/numbers.js79
-rw-r--r--app/javascript/flavours/glitch/utils/privacy_preference.js5
-rw-r--r--app/javascript/flavours/glitch/utils/react_helpers.js21
-rw-r--r--app/javascript/flavours/glitch/utils/resize_image.js189
-rw-r--r--app/javascript/flavours/glitch/utils/scrollbar.js34
18 files changed, 534 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/utils/backend_links.js b/app/javascript/flavours/glitch/utils/backend_links.js
new file mode 100644
index 000000000..2028a1e60
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/backend_links.js
@@ -0,0 +1,18 @@
+export const preferencesLink = '/settings/preferences';
+export const profileLink = '/settings/profile';
+export const signOutLink = '/auth/sign_out';
+export const privacyPolicyLink = '/privacy-policy';
+export const accountAdminLink = (id) => `/admin/accounts/${id}`;
+export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`;
+export const filterEditLink = (id) => `/filters/${id}/edit`;
+export const relationshipsLink = '/relationships';
+export const securityLink = '/auth/edit';
+export const preferenceLink = (setting_name) => {
+  switch (setting_name) {
+  case 'user_setting_expand_spoilers':
+  case 'user_setting_disable_swiping':
+    return `/settings/preferences/appearance#${setting_name}`;
+  default:
+    return preferencesLink;
+  }
+};
diff --git a/app/javascript/flavours/glitch/utils/base64.js b/app/javascript/flavours/glitch/utils/base64.js
new file mode 100644
index 000000000..8226e2c54
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/base64.js
@@ -0,0 +1,10 @@
+export const decode = base64 => {
+  const rawData = window.atob(base64);
+  const outputArray = new Uint8Array(rawData.length);
+
+  for (let i = 0; i < rawData.length; ++i) {
+    outputArray[i] = rawData.charCodeAt(i);
+  }
+
+  return outputArray;
+};
diff --git a/app/javascript/flavours/glitch/utils/config.js b/app/javascript/flavours/glitch/utils/config.js
new file mode 100644
index 000000000..932cd0cbf
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/config.js
@@ -0,0 +1,10 @@
+import ready from '../ready';
+
+export let assetHost = '';
+
+ready(() => {
+  const cdnHost = document.querySelector('meta[name=cdn-host]');
+  if (cdnHost) {
+    assetHost = cdnHost.content || '';
+  }
+});
diff --git a/app/javascript/flavours/glitch/utils/content_warning.js b/app/javascript/flavours/glitch/utils/content_warning.js
new file mode 100644
index 000000000..91d452baa
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/content_warning.js
@@ -0,0 +1,31 @@
+import { expandSpoilers } from 'flavours/glitch/initial_state';
+
+function _autoUnfoldCW(spoiler_text, skip_unfold_regex) {
+  if (!expandSpoilers)
+    return false;
+
+  if (!skip_unfold_regex)
+    return true;
+
+  let regex = null;
+
+  try {
+    regex = new RegExp(skip_unfold_regex.trim(), 'i');
+  } catch (e) {
+    // Bad regex, skip filters
+    return true;
+  }
+
+  return !regex.test(spoiler_text);
+}
+
+export function autoHideCW(settings, spoiler_text) {
+  return !_autoUnfoldCW(spoiler_text, settings.getIn(['content_warnings', 'filter']));
+}
+
+export function autoUnfoldCW(settings, status) {
+  if (!status)
+    return false;
+
+  return _autoUnfoldCW(status.get('spoiler_text'), settings.getIn(['content_warnings', 'filter']));
+}
diff --git a/app/javascript/flavours/glitch/utils/dom_helpers.js b/app/javascript/flavours/glitch/utils/dom_helpers.js
new file mode 100644
index 000000000..d94aeb9d4
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/dom_helpers.js
@@ -0,0 +1,14 @@
+//  Package imports.
+import { supportsPassiveEvents } from 'detect-passive-events';
+
+//  This will either be a passive lister options object (if passive
+//  events are supported), or `false`.
+export const withPassive = supportsPassiveEvents ? { passive: true } : false;
+
+//  Focuses the root element.
+export function focusRoot () {
+  let e;
+  if (document && (e = document.querySelector('.ui')) && (e = e.parentElement)) {
+    e.focus();
+  }
+}
diff --git a/app/javascript/flavours/glitch/utils/filters.js b/app/javascript/flavours/glitch/utils/filters.js
new file mode 100644
index 000000000..97b433a99
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/filters.js
@@ -0,0 +1,16 @@
+export const toServerSideType = columnType => {
+  switch (columnType) {
+  case 'home':
+  case 'notifications':
+  case 'public':
+  case 'thread':
+  case 'account':
+    return columnType;
+  default:
+    if (columnType.indexOf('list:') > -1) {
+      return 'home';
+    } else {
+      return 'public'; // community, account, hashtag
+    }
+  }
+};
diff --git a/app/javascript/flavours/glitch/utils/hashtag.js b/app/javascript/flavours/glitch/utils/hashtag.js
new file mode 100644
index 000000000..8f5665c46
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/hashtag.js
@@ -0,0 +1,8 @@
+export function recoverHashtags (recognizedTags, text) {
+  return recognizedTags.map(tag => {
+    const re = new RegExp(`(?:^|[^\/)\w])#(${tag.name})`, 'i');
+    const matched_hashtag = text.match(re);
+    return matched_hashtag ? matched_hashtag[1] : null;
+  },
+  ).filter(x => x !== null);
+}
diff --git a/app/javascript/flavours/glitch/utils/html.js b/app/javascript/flavours/glitch/utils/html.js
new file mode 100644
index 000000000..5159df9db
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/html.js
@@ -0,0 +1,5 @@
+export const unescapeHTML = (html) => {
+  const wrapper = document.createElement('div');
+  wrapper.innerHTML = html.replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n').replace(/<[^>]*>/g, '');
+  return wrapper.textContent;
+};
diff --git a/app/javascript/flavours/glitch/utils/icons.js b/app/javascript/flavours/glitch/utils/icons.js
new file mode 100644
index 000000000..c3e362e39
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/icons.js
@@ -0,0 +1,15 @@
+import React from 'react';
+
+// Copied from emoji-mart for consistency with emoji picker and since
+// they don't export the icons in the package
+export const loupeIcon = (
+  <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
+    <path d='M12.9 14.32a8 8 0 1 1 1.41-1.41l5.35 5.33-1.42 1.42-5.33-5.34zM8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12z' />
+  </svg>
+);
+
+export const deleteIcon = (
+  <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' width='13' height='13'>
+    <path d='M10 8.586L2.929 1.515 1.515 2.929 8.586 10l-7.071 7.071 1.414 1.414L10 11.414l7.071 7.071 1.414-1.414L11.414 10l7.071-7.071-1.414-1.414L10 8.586z' />
+  </svg>
+);
diff --git a/app/javascript/flavours/glitch/utils/idna.js b/app/javascript/flavours/glitch/utils/idna.js
new file mode 100644
index 000000000..efab5bacf
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/idna.js
@@ -0,0 +1,10 @@
+import punycode from 'punycode';
+
+const IDNA_PREFIX = 'xn--';
+
+export const decode = domain => {
+  return domain
+    .split('.')
+    .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
+    .join('.');
+};
diff --git a/app/javascript/flavours/glitch/utils/js_helpers.js b/app/javascript/flavours/glitch/utils/js_helpers.js
new file mode 100644
index 000000000..2ebd5b6c5
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/js_helpers.js
@@ -0,0 +1,5 @@
+//  This function returns the new value unless it is `null` or
+//  `undefined`, in which case it returns the old one.
+export function overwrite (oldVal, newVal) {
+  return newVal === null || typeof newVal === 'undefined' ? oldVal : newVal;
+}
diff --git a/app/javascript/flavours/glitch/utils/log_out.js b/app/javascript/flavours/glitch/utils/log_out.js
new file mode 100644
index 000000000..f82041150
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/log_out.js
@@ -0,0 +1,34 @@
+import Rails from '@rails/ujs';
+import { signOutLink } from 'flavours/glitch/utils/backend_links';
+
+export const logOut = () => {
+  const form = document.createElement('form');
+
+  const methodInput = document.createElement('input');
+  methodInput.setAttribute('name', '_method');
+  methodInput.setAttribute('value', 'delete');
+  methodInput.setAttribute('type', 'hidden');
+  form.appendChild(methodInput);
+
+  const csrfToken = Rails.csrfToken();
+  const csrfParam = Rails.csrfParam();
+
+  if (csrfParam && csrfToken) {
+    const csrfInput = document.createElement('input');
+    csrfInput.setAttribute('name', csrfParam);
+    csrfInput.setAttribute('value', csrfToken);
+    csrfInput.setAttribute('type', 'hidden');
+    form.appendChild(csrfInput);
+  }
+
+  const submitButton = document.createElement('input');
+  submitButton.setAttribute('type', 'submit');
+  form.appendChild(submitButton);
+
+  form.method = 'post';
+  form.action = signOutLink;
+  form.style.display = 'none';
+
+  document.body.appendChild(form);
+  submitButton.click();
+};
diff --git a/app/javascript/flavours/glitch/utils/notifications.js b/app/javascript/flavours/glitch/utils/notifications.js
new file mode 100644
index 000000000..7634cac21
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/notifications.js
@@ -0,0 +1,30 @@
+// Handles browser quirks, based on
+// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
+
+const checkNotificationPromise = () => {
+  try {
+    // eslint-disable-next-line promise/catch-or-return
+    Notification.requestPermission().then();
+  } catch(e) {
+    return false;
+  }
+
+  return true;
+};
+
+const handlePermission = (permission, callback) => {
+  // Whatever the user answers, we make sure Chrome stores the information
+  if(!('permission' in Notification)) {
+    Notification.permission = permission;
+  }
+
+  callback(Notification.permission);
+};
+
+export const requestNotificationPermission = (callback) => {
+  if (checkNotificationPromise()) {
+    Notification.requestPermission().then((permission) => handlePermission(permission, callback)).catch(console.warn);
+  } else {
+    Notification.requestPermission((permission) => handlePermission(permission, callback));
+  }
+};
diff --git a/app/javascript/flavours/glitch/utils/numbers.js b/app/javascript/flavours/glitch/utils/numbers.js
new file mode 100644
index 000000000..6ef563ad8
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/numbers.js
@@ -0,0 +1,79 @@
+// @ts-check
+
+export const DECIMAL_UNITS = Object.freeze({
+  ONE: 1,
+  TEN: 10,
+  HUNDRED: Math.pow(10, 2),
+  THOUSAND: Math.pow(10, 3),
+  MILLION: Math.pow(10, 6),
+  BILLION: Math.pow(10, 9),
+  TRILLION: Math.pow(10, 12),
+});
+
+const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10;
+const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
+
+/**
+ * @typedef {[number, number, number]} ShortNumber
+ * Array of: shorten number, unit of shorten number and maximum fraction digits
+ */
+
+/**
+ * @param {number} sourceNumber Number to convert to short number
+ * @returns {ShortNumber} Calculated short number
+ * @example
+ * shortNumber(5936);
+ * // => [5.936, 1000, 1]
+ */
+export function toShortNumber(sourceNumber) {
+  if (sourceNumber < DECIMAL_UNITS.THOUSAND) {
+    return [sourceNumber, DECIMAL_UNITS.ONE, 0];
+  } else if (sourceNumber < DECIMAL_UNITS.MILLION) {
+    return [
+      sourceNumber / DECIMAL_UNITS.THOUSAND,
+      DECIMAL_UNITS.THOUSAND,
+      sourceNumber < TEN_THOUSAND ? 1 : 0,
+    ];
+  } else if (sourceNumber < DECIMAL_UNITS.BILLION) {
+    return [
+      sourceNumber / DECIMAL_UNITS.MILLION,
+      DECIMAL_UNITS.MILLION,
+      sourceNumber < TEN_MILLIONS ? 1 : 0,
+    ];
+  } else if (sourceNumber < DECIMAL_UNITS.TRILLION) {
+    return [
+      sourceNumber / DECIMAL_UNITS.BILLION,
+      DECIMAL_UNITS.BILLION,
+      0,
+    ];
+  }
+
+  return [sourceNumber, DECIMAL_UNITS.ONE, 0];
+}
+
+/**
+ * @param {number} sourceNumber Original number that is shortened
+ * @param {number} division The scale in which short number is displayed
+ * @returns {number} Number that can be used for plurals when short form used
+ * @example
+ * pluralReady(1793, DECIMAL_UNITS.THOUSAND)
+ * // => 1790
+ */
+export function pluralReady(sourceNumber, division) {
+  // eslint-disable-next-line eqeqeq
+  if (division == null || division < DECIMAL_UNITS.HUNDRED) {
+    return sourceNumber;
+  }
+
+  let closestScale = division / DECIMAL_UNITS.TEN;
+
+  return Math.trunc(sourceNumber / closestScale) * closestScale;
+}
+
+/**
+ * @param {number} num
+ * @returns {number}
+ */
+export function roundTo10(num) {
+  return Math.round(num * 0.1) / 0.1;
+}
diff --git a/app/javascript/flavours/glitch/utils/privacy_preference.js b/app/javascript/flavours/glitch/utils/privacy_preference.js
new file mode 100644
index 000000000..51bdf072d
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/privacy_preference.js
@@ -0,0 +1,5 @@
+export const order = ['public', 'unlisted', 'private', 'direct'];
+
+export function privacyPreference (a, b) {
+  return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
+}
diff --git a/app/javascript/flavours/glitch/utils/react_helpers.js b/app/javascript/flavours/glitch/utils/react_helpers.js
new file mode 100644
index 000000000..ea11acdb6
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/react_helpers.js
@@ -0,0 +1,21 @@
+//  This function binds the given `handlers` to the `target`.
+export function assignHandlers (target, handlers) {
+  if (!target || !handlers) {
+    return;
+  }
+
+  //  We just bind each handler to the `target`.
+  const handle = target.handlers = {};
+  Object.keys(handlers).forEach(
+    key => handle[key] = handlers[key].bind(target),
+  );
+}
+
+//  This function only returns the component if the result of calling
+//  `test` with `data` is `true`.  Useful with funciton binding.
+export function conditionalRender (test, data, component) {
+  return test(data) ? component : null;
+}
+
+//  This object provides props to make the component not visible.
+export const hiddenComponent = { style: { display: 'none' } };
diff --git a/app/javascript/flavours/glitch/utils/resize_image.js b/app/javascript/flavours/glitch/utils/resize_image.js
new file mode 100644
index 000000000..fb8c3c11e
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/resize_image.js
@@ -0,0 +1,189 @@
+import EXIF from 'exif-js';
+
+const MAX_IMAGE_PIXELS = 2073600; // 1920x1080px
+
+const _browser_quirks = {};
+
+// Some browsers will automatically draw images respecting their EXIF orientation
+// while others won't, and the safest way to detect that is to examine how it
+// is done on a known image.
+// See https://github.com/w3c/csswg-drafts/issues/4666
+// and https://github.com/blueimp/JavaScript-Load-Image/commit/1e4df707821a0afcc11ea0720ee403b8759f3881
+const dropOrientationIfNeeded = (orientation) => new Promise(resolve => {
+  switch (_browser_quirks['image-orientation-automatic']) {
+  case true:
+    resolve(1);
+    break;
+  case false:
+    resolve(orientation);
+    break;
+  default:
+    // black 2x1 JPEG, with the following meta information set:
+    // - EXIF Orientation: 6 (Rotated 90° CCW)
+    const testImageURL =
+      'data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAA' +
+      'AAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA' +
+      'QEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE' +
+      'BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/x' +
+      'ABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAA' +
+      'AAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q==';
+    const img = new Image();
+    img.onload = () => {
+      const automatic = (img.width === 1 && img.height === 2);
+      _browser_quirks['image-orientation-automatic'] = automatic;
+      resolve(automatic ? 1 : orientation);
+    };
+    img.onerror = () => {
+      _browser_quirks['image-orientation-automatic'] = false;
+      resolve(orientation);
+    };
+    img.src = testImageURL;
+  }
+});
+
+// Some browsers don't allow reading from a canvas and instead return all-white
+// or randomized data. Use a pre-defined image to check if reading the canvas
+// works.
+const checkCanvasReliability = () => new Promise((resolve, reject) => {
+  switch(_browser_quirks['canvas-read-unreliable']) {
+  case true:
+    reject('Canvas reading unreliable');
+    break;
+  case false:
+    resolve();
+    break;
+  default:
+    // 2×2 GIF with white, red, green and blue pixels
+    const testImageURL =
+      'data:image/gif;base64,R0lGODdhAgACAKEDAAAA//8AAAD/AP///ywAAAAAAgACAAACA1wEBQA7';
+    const refData =
+      [255, 255, 255, 255,  255, 0, 0, 255,  0, 255, 0, 255,  0, 0, 255, 255];
+    const img = new Image();
+    img.onload = () => {
+      const canvas  = document.createElement('canvas');
+      const context = canvas.getContext('2d');
+      context.drawImage(img, 0, 0, 2, 2);
+      const imageData = context.getImageData(0, 0, 2, 2);
+      if (imageData.data.every((x, i) => refData[i] === x)) {
+        _browser_quirks['canvas-read-unreliable'] = false;
+        resolve();
+      } else {
+        _browser_quirks['canvas-read-unreliable'] = true;
+        reject('Canvas reading unreliable');
+      }
+    };
+    img.onerror = () => {
+      _browser_quirks['canvas-read-unreliable'] = true;
+      reject('Failed to load test image');
+    };
+    img.src = testImageURL;
+  }
+});
+
+const getImageUrl = inputFile => new Promise((resolve, reject) => {
+  if (window.URL && URL.createObjectURL) {
+    try {
+      resolve(URL.createObjectURL(inputFile));
+    } catch (error) {
+      reject(error);
+    }
+    return;
+  }
+
+  const reader = new FileReader();
+  reader.onerror = (...args) => reject(...args);
+  reader.onload  = ({ target }) => resolve(target.result);
+
+  reader.readAsDataURL(inputFile);
+});
+
+const loadImage = inputFile => new Promise((resolve, reject) => {
+  getImageUrl(inputFile).then(url => {
+    const img = new Image();
+
+    img.onerror = (...args) => reject(...args);
+    img.onload  = () => resolve(img);
+
+    img.src = url;
+  }).catch(reject);
+});
+
+const getOrientation = (img, type = 'image/png') => new Promise(resolve => {
+  if (!['image/jpeg', 'image/webp'].includes(type)) {
+    resolve(1);
+    return;
+  }
+
+  EXIF.getData(img, () => {
+    const orientation = EXIF.getTag(img, 'Orientation');
+    if (orientation !== 1) {
+      dropOrientationIfNeeded(orientation).then(resolve).catch(() => resolve(orientation));
+    } else {
+      resolve(orientation);
+    }
+  });
+});
+
+const processImage = (img, { width, height, orientation, type = 'image/png' }) => new Promise(resolve => {
+  const canvas  = document.createElement('canvas');
+
+  if (4 < orientation && orientation < 9) {
+    canvas.width  = height;
+    canvas.height = width;
+  } else {
+    canvas.width  = width;
+    canvas.height = height;
+  }
+
+  const context = canvas.getContext('2d');
+
+  switch (orientation) {
+  case 2: context.transform(-1, 0, 0, 1, width, 0); break;
+  case 3: context.transform(-1, 0, 0, -1, width, height); break;
+  case 4: context.transform(1, 0, 0, -1, 0, height); break;
+  case 5: context.transform(0, 1, 1, 0, 0, 0); break;
+  case 6: context.transform(0, 1, -1, 0, height, 0); break;
+  case 7: context.transform(0, -1, -1, 0, height, width); break;
+  case 8: context.transform(0, -1, 1, 0, 0, width); break;
+  }
+
+  context.drawImage(img, 0, 0, width, height);
+
+  canvas.toBlob(resolve, type);
+});
+
+const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) => {
+  const { width, height } = img;
+
+  const newWidth  = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (width / height)));
+  const newHeight = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (height / width)));
+
+  checkCanvasReliability()
+    .then(getOrientation(img, type))
+    .then(orientation => processImage(img, {
+      width: newWidth,
+      height: newHeight,
+      orientation,
+      type,
+    }))
+    .then(resolve)
+    .catch(reject);
+});
+
+export default inputFile => new Promise((resolve) => {
+  if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') {
+    resolve(inputFile);
+    return;
+  }
+
+  loadImage(inputFile).then(img => {
+    if (img.width * img.height < MAX_IMAGE_PIXELS) {
+      resolve(inputFile);
+      return;
+    }
+
+    resizeImage(img, inputFile.type)
+      .then(resolve)
+      .catch(() => resolve(inputFile));
+  }).catch(() => resolve(inputFile));
+});
diff --git a/app/javascript/flavours/glitch/utils/scrollbar.js b/app/javascript/flavours/glitch/utils/scrollbar.js
new file mode 100644
index 000000000..929b036d6
--- /dev/null
+++ b/app/javascript/flavours/glitch/utils/scrollbar.js
@@ -0,0 +1,34 @@
+/** @type {number | null} */
+let cachedScrollbarWidth = null;
+
+/**
+ * @return {number}
+ */
+const getActualScrollbarWidth = () => {
+  const outer = document.createElement('div');
+  outer.style.visibility = 'hidden';
+  outer.style.overflow = 'scroll';
+  document.body.appendChild(outer);
+
+  const inner = document.createElement('div');
+  outer.appendChild(inner);
+
+  const scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
+  outer.parentNode.removeChild(outer);
+
+  return scrollbarWidth;
+};
+
+/**
+ * @return {number}
+ */
+export const getScrollbarWidth = () => {
+  if (cachedScrollbarWidth !== null) {
+    return cachedScrollbarWidth;
+  }
+
+  const scrollbarWidth = getActualScrollbarWidth();
+  cachedScrollbarWidth = scrollbarWidth;
+
+  return scrollbarWidth;
+};