Drop My Flickr Links!

Creates a hoverable dropdown menu that shows links to all available sizes for Flickr photos.

As of 2024-07-11. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        Drop My Flickr Links!
// @namespace   https://github.com/stanleyqubit/drop-my-flickr-links
// @license     MIT License
// @author      stanleyqubit
// @compatible  firefox Tampermonkey with UserScripts API Dynamic
// @compatible  chrome Violentmonkey or Tampermonkey
// @match       *://*.flickr.com/*
// @connect     www.flickr.com
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_addStyle
// @grant       GM_download
// @grant       GM_openInTab
// @grant       GM_notification
// @grant       GM.xmlHttpRequest
// @grant       GM_registerMenuCommand
// @version     1.5.1
// @icon        https://www.google.com/s2/favicons?sz=64&domain=flickr.com
// @description Creates a hoverable dropdown menu that shows links to all available sizes for Flickr photos.
// ==/UserScript==


/* The photos available for download through this userscript may be protected by
 * copyright laws. Downloading a photo constitutes your agreement to use the photo
 * in accordance with the license associated with it. Please check the individual
 * photo's license information before use.
 *
 * Note -- Firefox + Tampermonkey users: in order for the script to have full
 * access to the Flickr YUI `appContext` global variable and thus avoid having to
 * resort to workarounds which may result in incorrectly displayed links or
 * incomplete photo data, go to the Tampermonkey dashboard -> Settings, under
 * "Config mode" select "Advanced", then under "Content Script API" select
 * "UserScripts API Dynamic", then click "Save".
 *
 * FYI -- some authors may choose to disable photo downloads which means that
 * Flickr will not make certain photo sizes (e.g. originals) available for users
 * that aren't signed in with a Flickr account. */


console.log("Loaded.");
const scriptName = "Drop My Flickr Links!";


const defaultSettings = {
  IMMEDIATE: {
    value: true,
    name: 'Immediate',
    desc: 'On: get sizes for all photos as soon as they appear inside a page. ' +
          'Off: only get sizes on button hover.',
  },
  USE_CACHE: {
    value: true,
    name: 'Use cache',
    desc: 'Get sizes once for each photo and remember them for the current ' +
          'session (until page reload).',
  },
  REPLACE_FLICKR_DL_BUTTON: {
    value: false,
    name: 'Replace Flickr download button',
    desc: 'Whether to replace the Flickr download button shown in the main ' +
          'photo page with our button.',
  },
  PREPEND_AUTHOR_ID: {
    value: true,
    name: 'Prepend author ID to downloaded image file name',
    desc: 'Self-explanatory.',
  },
  SHOW_LICENSE_INFO: {
    value: true,
    name: 'Show license information',
    desc: 'Shows a hyperlink to the photo\'s license when in preview mode.',
  },
  UPDATE_INTERVAL: {
    value: 2000,
    name: 'Update interval',
    desc: 'Time interval (in milliseconds) at which scanning and processing ' +
          'for relevant nodes should be done. Used for the timeout function ' +
          'in the main script loop. Smaller values translate to faster ' +
          'processing and a higher workload, while the opposite applies for ' +
          'larger values. Recommended values should be in the range 500 - 2000.',
  },

  /* Dropdown "button" base appearance */

  BUTTON_WIDTH: {
    value: '25px',
    name: 'Button width',
    desc: 'CSS value.',
  },
  BUTTON_HEIGHT: {
    value: '25px',
    name: 'Button height',
    desc: 'CSS value.',
  },
  BUTTON_TEXT_SIZE: {
    value: '16px',
    name: 'Button text size',
    desc: 'CSS value.',
  },
  BUTTON_HOVER_OPACITY: {
    value: '0.85',
    name: 'Button opacity on hover',
    desc: 'CSS value.',
  },
  BUTTON_HOVER_BG_COLOR: {
    value: '#519c60',
    name: 'Button background color on hover',
    desc: 'CSS value.',
  },

  /* Dropdown "button" appearance in main photo page, lightbox */

  BUTTON_TEXT: {
    value: 'D',
    name: 'Button text main',
    desc: 'Text to be shown inside the button if not placed inside a thumbnail. ' +
          '(e.g. in main photo page, lightbox view)',
  },
  BUTTON_TEXT_COLOR: {
    value: '#ffffff',
    name: 'Button text color main',
    desc: 'CSS value.',
  },
  BUTTON_BG_COLOR: {
    value: '#6495ed',
    name: 'Button background color main',
    desc: 'CSS value.',
  },
  BUTTON_OPACITY: {
    value: '1',
    name: 'Button opacity main',
    desc: 'CSS value.',
  },
  BUTTON_JUSTIFY: {
    value: 'center',
    name: 'Button justify content main',
    desc: 'CSS value.',
  },
  BUTTON_ALIGN: {
    value: 'center',
    name: 'Button align items main',
    desc: 'CSS value.',
  },

  /* Dropdown "button" appearance on thumbnails */

  BUTTON_TEXT_ON_THUMBNAIL: {
    value: '. . .',
    name: 'Button text on thumbnail',
    desc: 'Text to be shown inside the button if placed inside a thumbnail. ' +
          '(e.g. in photostream page, etc)',
  },
  BUTTON_TEXT_COLOR_ON_THUMBNAIL: {
    value: '#ffffff',
    name: 'Button text color on thumbnail',
    desc: 'CSS value.',
  },
  BUTTON_BG_COLOR_ON_THUMBNAIL: {
    value: 'transparent',
    name: 'Button background color on thumbnail',
    desc: 'CSS value.',
  },
  BUTTON_OPACITY_ON_THUMBNAIL: {
    value: '0.7',
    name: 'Button opacity on thumbnail',
    desc: 'CSS value.',
  },
  BUTTON_JUSTIFY_ON_THUMBNAIL: {
    value: 'center',
    name: 'Button justify content on thumbnail',
    desc: 'CSS value.',
  },
  BUTTON_ALIGN_ON_THUMBNAIL: {
    value: 'center',
    name: 'Button align items on thumbnail',
    desc: 'CSS value.',
  },

  /* Dropdown menu content appearance */

  CONTENT_BG_COLOR: {
    value: '#f1f1f1',
    name: 'Menu content background color',
    desc: 'CSS value.',
  },
  CONTENT_TEXT_COLOR: {
    value: '#000000',
    name: 'Menu content text color',
    desc: 'CSS value.',
  },
  CONTENT_TEXT_SIZE: {
    value: '18px',
    name: 'Menu content text size',
    desc: 'CSS value.',
  },
  CONTENT_A_HOVER_BG_COLOR: {
    value: '#dddddd',
    name: 'Menu content anchor element background color on hover',
    desc: 'CSS value.',
  },
  CONTENT_DIV_HOVER_BG_COLOR: {
    value: '#5bc4eb',
    name: 'Menu content preview element background color on hover',
    desc: 'CSS value.',
  },
}


const getSettingValue = (key, settings) => {
  const value = settings[key]?.value;
  const defaultValue = defaultSettings[key].value;
  return (typeof value === typeof defaultValue) ? value : defaultValue;
}

const storedSettings = GM_getValue('settings', {});
const o = {};

for (const key in defaultSettings) {
  o[key] = getSettingValue(key, storedSettings);
}

const nodesProcessed = new Set();
const nodesPopulated = new Set();
const cache = Object.create(null);

let appInitOk = false;
let appInitRetryCount = 0;


const LICENSE_INFO = [
  {
    value: '0',
    text: 'All rights reserved',
    url: 'https://flickrhelp.com/hc/en-us/articles/4404078674324-Change-Your-Photo-s-License-in-Flickr'
  },
  {
    value: '1',
    text: 'Attribution-NonCommercial-ShareAlike',
    url: 'https://creativecommons.org/licenses/by-nc-sa/2.0/'
  },
  {
    value: '2',
    text: 'Attribution-NonCommercial',
    url: 'https://creativecommons.org/licenses/by-nc/2.0/'
  },
  {
    value: '3',
    text: 'Attribution-NonCommercial-NoDerivs',
    url: 'https://creativecommons.org/licenses/by-nc-nd/2.0/'
  },
  {
    value: '4',
    text: 'Attribution',
    url: 'https://creativecommons.org/licenses/by/2.0/'
  },
  {
    value: '5',
    text: 'Attribution-ShareAlike',
    url: 'https://creativecommons.org/licenses/by-sa/2.0/'
  },
  {
    value: '6',
    text: 'Attribution-NoDerivs',
    url: 'https://creativecommons.org/licenses/by-nd/2.0/'
  },
  {
    value: '7',
    text: 'No known copyright restrictions',
    url: '/commons/usage/'
  },
  {
    value: '8',
    text: 'United States government work',
    url: 'http://www.usa.gov/copyright.shtml'
  },
  {
    value: '9',
    text: 'Public Domain Dedication (CC0)',
    url: 'https://creativecommons.org/publicdomain/zero/1.0/'
  },
  {
    value: '10',
    text: 'Public Domain Work',
    url: 'https://creativecommons.org/publicdomain/mark/1.0/'
  }
];


function getRandomNumber(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

const sizesOrder = ["o", "8k", "7k", "6k", "5k", "4k", "3k", "k", "h", "l", "c", "z", "m", "w", "n", "s", "q", "t", "sq"];
async function appGetInfo(photoId) {
  if (!appInitOk) return;
  let ds, owner, ownerId, licenseInfo;
  try {
    const info = await unsafeWindow.appContext.getModel('photo-models', photoId);
    if (info.getValue) {
      ds = info.getValue('descendingSizes');
      owner = info.getValue('owner');
      ownerId = owner?.getValue('id') || owner?.getValue('nsid') || owner?.getValue('url')?.split('/')[2];
      licenseInfo = info.getPhotoLicenseInformation?.() || {};
    } else {
      const data = info.registry?._data?.[photoId];
      const sizes = data?.sizes;
      if (!sizes) {
        console.debug(`${photoId} : No sizes data in app registry`);
        return;
      }
      ds = Object.entries(sizes)
        .map(([key, value]) => { return { ...value, key } })
        .sort((a, b) => sizesOrder.indexOf(a) - sizesOrder.indexOf(b))
        .reverse();
      ownerId = data.owner?.id;
      licenseInfo = {'value': data.license};
    }
    if (ds) {
      console.debug(`${photoId} : Got info from app`);
      return {"descendingSizes": ds, "ownerId": ownerId, "licenseInfo": licenseInfo};
    }
  } catch(e) {
    if (typeof e === 'object' && e.stat === 'fail') {
      if (e.message === 'Photo not found') {
        console.debug(`${photoId} : YUI said: "${e.message}". Photo might be locked or is private. Trying xhr.`);
        return;
      }
      return e;
    }
    console.warn(e);
  }
}

async function xhrGetInfo(photoId, photoURL) {
  console.debug(`${photoId} : Sending xhr: ${photoURL}`);
  const response = await GM.xmlHttpRequest({
    method: "GET",
    url: photoURL
  }).catch(e => {
    console.log(`${photoId} : xhr error:`, e);
    return;
  });

  const dsMatch = response.responseText?.match(/descendingSizes":(\[.+?\])/);
  const ds = dsMatch?.[1];
  if (ds) {
    const licenseMatch = response.responseText?.match(/"license":(\d+)/);
    const licenseInfo = {"value": licenseMatch?.[1]};
    console.debug(`${photoId} : Got info from xhr`);
    return {"descendingSizes": JSON.parse(ds), "ownerId": response.finalUrl.split('/')[4], "licenseInfo": licenseInfo};
  } else {
    let msg = `${photoId} : No regex match at url ${response.finalUrl}`;
    if (response.finalUrl.endsWith('///')) msg += ' (photo not found)';
    if (response.responseText.match(/This photo is private/)) msg += ' (photo is private)';
    if (response.status != 200) msg += ` [status: ${response.status}]`;
    console.log(msg);
  }
}

function dl(downloadURL, downloadFilename) {
  let download;
  const checkStatus = (responseObject) => {
    const status = responseObject.status;
    if (/^[045]/.test(status)) /* Violentmonkey */ {
      download.abort();
      console.warn('Download failed.', {responseObject});
      GM_notification(`URL: ${downloadURL}\n\nDownload failed with status code: ${status}`, scriptName);
    }
    if (responseObject.error) /* Tampermonkey */ {
      const msg = `URL: ${downloadURL}\n\nDownload error: ${responseObject.error}`;
      console.warn(msg);
      GM_notification(msg, scriptName);
    }
  };
  download = GM_download({
    url: downloadURL,
    name: downloadFilename,
    onprogress: (res) => { checkStatus(res) },
    onerror: (res) => { checkStatus(res) },
  });
}

async function populate(data) {
  if (nodesPopulated.has(data.node)) return;
  nodesPopulated.add(data.node);

  /* First try to get sizes info from the YUI `appContext` global variable.
   * Some of this object's methods might not be available as the UserScripts API
   * implementation may differ across userscript managers. For Chromium-based web
   * browsers this shouldn't be an issue. However, if the YUI module is not
   * available, a xhr will be sent as a fallback.
   *
   * Also, see note at the top of the file. */

  const photoId = data.photoId;
  const photoURL = data.photoURL;

  const info = cache[photoId] || await appGetInfo(photoId) || await xhrGetInfo(photoId, photoURL);
  const author = data.author || info?.ownerId;

  let licenseInfo;
  if (o.SHOW_LICENSE_INFO && info?.licenseInfo?.value != null) {
    const licenseInfoValue = info.licenseInfo.value.toString();
    if (licenseInfoValue) {
      const isCompleteInfo = (info.licenseInfo.url != null) && (info.licenseInfo.text != null);
      licenseInfo = isCompleteInfo ? info.licenseInfo : LICENSE_INFO.find((item => item.value === licenseInfoValue));
    }
  }

  let descendingSizes = info?.descendingSizes;

  if (!Array.isArray(descendingSizes)) {
    let failMessage = `${photoId} : No sizes found.`;
    if (info?.message) {
      failMessage += ` YUI said: ${info.message}`;
    }
    console.log(failMessage);
    if (data.isImage) {
      console.log(`${photoId} : Adding image src as sole entry.`);
      const imageData = Object.create(null);
      imageData.src = data.node.src;
      imageData.width = data.node.getAttribute('width');
      imageData.height = data.node.getAttribute('height');
      imageData.key = '?';
      descendingSizes = [imageData];
    } else {
      return;
    }
  }

  if (o.USE_CACHE && !cache[photoId]) {
    cache[photoId] = {'descendingSizes': descendingSizes, 'ownerId': author, 'licenseInfo': licenseInfo};
  }

  for (const item of descendingSizes) {
    const imageURL = item.url || item.src || item.displayUrl;
    if (!imageURL) {
      console.log("descendingSizes item has no url");
      continue;
    }
    const filename = imageURL.split('/').pop();
    const extension = filename.split('.').pop();
    const entry = document.createElement('div');
    entry.className = 'dmfl-dropdown-entry';
    const anchor = document.createElement('a');
    let downloadURL = '';
    if (imageURL.startsWith('//')) {
      downloadURL += data.scheme;
    }
    downloadURL += imageURL.replace(/(\.[a-z]+)$/i, '_d$1');
    anchor.setAttribute('href', imageURL);
    anchor.textContent = `${item.width} x ${item.height} (${item.key})`;
    if (!extension.endsWith('jpg')) {
      anchor.textContent += ` [${extension}]`;
    }
    const downloadFilename = author && o.PREPEND_AUTHOR_ID ? `${author}_-_${filename}` : filename;
    anchor.addEventListener('click', (event) => {
      dl(downloadURL, downloadFilename);
      event.preventDefault();
    })
    entry.appendChild(anchor);
    const previewContainer = document.createElement('div');
    previewContainer.className = 'dmfl-preview-container';
    previewContainer.textContent = '[ + ]';
    previewContainer.addEventListener('click', () => {
      const previewBg = document.createElement('div');
      previewBg.className = 'dmfl-preview-background';
      const previewDl = document.createElement('a');
      previewDl.className = 'dmfl-preview-download';
      previewDl.innerText = '\u21e3';
      previewDl.addEventListener('click', (event) => {
        dl(downloadURL, downloadFilename);
        event.preventDefault();
      })
      previewBg.appendChild(previewDl);
      if (licenseInfo) {
        const previewLicenseInfo = document.createElement('a');
        previewLicenseInfo.className = 'dmfl-preview-license-info';
        previewLicenseInfo.setAttribute('href', licenseInfo.url);
        previewLicenseInfo.innerText = `License: ${licenseInfo.text}`;
        previewLicenseInfo.addEventListener('click', (event) => {
          GM_openInTab(previewLicenseInfo.href, false);
          event.preventDefault();
          event.stopPropagation();
        })
        previewBg.appendChild(previewLicenseInfo);
      }
      const previewImg = document.createElement('img');
      previewImg.className = 'dmfl-preview-image';
      previewImg.src = imageURL;
      previewBg.onclick = () => { previewBg.remove() };
      previewBg.appendChild(previewImg);
      document.body.appendChild(previewBg);
    })
    entry.appendChild(previewContainer);
    data.dropdownContent.appendChild(entry);
  }
}

function processNode(node) {

  const hasEngagementView = node.classList.contains('photo-engagement-view');
  const isMainPhotoPage = node.classList.contains('sub-photo-view') || hasEngagementView;
  const isLightbox = node.classList.contains('photo-card-engagement-view');
  const isDiscussionsPage = node.closest('div[class="message-text"]') !== null;

  const href = isMainPhotoPage || isLightbox ? document.URL :
             isDiscussionsPage ? node.parentNode?.href :
             node.querySelector('a.overlay')?.href || node.querySelector('a')?.href;

  if (!href) {
    /* Anchor elements aren't always available immediately.
     * Wait for page scripts to add relevant nodes. */
    return;
  }

  const photoIsLocked = (href.indexOf('flickr.com/gp/') >= 0);
  const photoIsUnlocked = (href.indexOf('flickr.com/photos/') >= 0);

  if (!photoIsLocked && !photoIsUnlocked) {
    console.debug(`(ignore) No valid href at ${document.URL} for node with className "${node.className}", href: ${href}`);
    if (isDiscussionsPage) nodesProcessed.add(node);
    return;
  }

  if (isDiscussionsPage && /\/(albums|groups|galleries)\//.test(href)) {
    nodesProcessed.add(node);
    return;
  }

  const isImage = (node.nodeName === 'IMG');
  if (isImage) {
    if (!node.src || node.src.indexOf('static') < 0) return; // Image sources might be added dynamically
    if (!/(live|farm[\d]*)\.static\.?flickr\.com\/[\d]+\/[\d]+_[a-z0-9]+(_[a-z0-9]{1,2})?\.[a-z0-9]{3,4}$/.test(node.src)) {
      console.debug(`Not a valid image source "${node.src}"`, node);
      nodesProcessed.add(node);
      return;
    }
  }

  const components = href.split('/');
  const scheme = components[0];
  const author = components[4];
  const photoId = isImage ? node.src.split('/').pop().split('_')[0] : components[5];

  if (!photoId) {
    console.debug("No photo ID found while processing node", node);
    nodesProcessed.add(node);
    return;
  }

  if (!photoIsLocked && isNaN(Number(photoId))) {
    console.debug(`Not a valid photoId "${photoId}"`, node);
    nodesProcessed.add(node);
    return;
  }

  components.length = Math.min(components.length, 5);
  components.push(photoId);

  const userSignedIn = Boolean(typeof unsafeWindow !== undefined ? unsafeWindow.appContext?.auth?.signedIn : null);
  const photoURL = photoIsLocked ? href :
                   userSignedIn ? `${window.location.origin}/photo.gne?id=${photoId}` :
                   components.join('/').replace('/gp/', '/photos/');

  const dropdownContainer = document.createElement('div');
  const dropdownButton = document.createElement('div');
  const dropdownContent = document.createElement('div');
  dropdownContainer.className = 'dmfl-dropdown-container';
  dropdownButton.className = 'dmfl-dropdown-button';
  dropdownButton.textContent = o.BUTTON_TEXT;
  dropdownButton.onclick = () => { showSettings() };
  dropdownContent.className = 'dmfl-dropdown-content';
  dropdownContainer.appendChild(dropdownButton);
  dropdownContainer.appendChild(dropdownContent);

  const dmflNodes = [dropdownContainer, dropdownButton, dropdownContent];

  if (isMainPhotoPage) {
    const flickrDlButton = node.querySelector('.engagement-item.download');
    if (!flickrDlButton) {
      console.debug("Waiting for Flickr download button...");
      return;
    }
    dmflNodes.forEach(n => n.classList.add('dmfl-main-photo-page'));
    if (o.REPLACE_FLICKR_DL_BUTTON) {
      node.replaceChild(dropdownContainer, flickrDlButton);
    } else {
      node.appendChild(dropdownContainer);
    }
  } else if (isLightbox) {
    const lightboxEngagement = node.querySelector('.photo-card-engagement');
    if (!lightboxEngagement) {
      console.debug("Waiting for lightbox photo card engagement...");
      return;
    }
    dmflNodes.forEach(n => n.classList.add('dmfl-lightbox-page'));
    lightboxEngagement.appendChild(dropdownContainer);
  } else if (isDiscussionsPage) {
    const imgAnchor = node.parentNode;
    const closestDiv = imgAnchor.closest('div');
    let photoContainer = imgAnchor;
    if (imgAnchor.parentNode.classList.contains('photo_container')) {
      photoContainer = closestDiv;
    }
    const discContainer = document.createElement('div');
    discContainer.className = 'dmfl-disc-container';
    discContainer.appendChild(dropdownContainer);
    dmflNodes.forEach(n => n.classList.add('dmfl-thumbnail-page'));
    dropdownButton.textContent = o.BUTTON_TEXT_ON_THUMBNAIL;
    photoContainer.parentNode.insertBefore(discContainer, photoContainer);
    discContainer.appendChild(photoContainer);
  } else {
    // Photostream, albums, faves, galleries, search page, explore page
    dmflNodes.forEach(n => n.classList.add('dmfl-thumbnail-page'));
    dropdownButton.textContent = o.BUTTON_TEXT_ON_THUMBNAIL;
    node.insertBefore(dropdownContainer, node.firstChild);
  }

  const data = Object.create(null);
  data.node = node;
  data.scheme = scheme;
  data.author = author;
  data.isImage = isImage;
  data.photoId = photoId;
  data.photoURL = photoURL;
  data.dropdownContent = dropdownContent;

  if (o.IMMEDIATE) {
    populate(data);
  }

  const zIndexDefault = node.style.getPropertyValue('z-index');
  let mouseEnterCount = 0;

  dropdownContainer.addEventListener("mouseenter", () => {
    mouseEnterCount += 1;
    node.style.zIndex = '9999';
    if (mouseEnterCount > 1 || dropdownContent.querySelectorAll('a').length > 0) return;
    populate(data);
  });

  dropdownContainer.addEventListener("mouseleave", () => {
    node.style.zIndex = zIndexDefault;
  });

  nodesProcessed.add(node);
}


const STYLE = `
  /*
   =================
  | Dropdown widget |
   =================
  */

  .dmfl-dropdown-container {
    z-index: 10000;
    cursor: pointer;
  }

  .dmfl-dropdown-button {
    display: flex;
    font-size: ${o.BUTTON_TEXT_SIZE};
    width: ${o.BUTTON_WIDTH};
    height: ${o.BUTTON_HEIGHT};
    z-index: 10002;
    border: none;
  }

  .dmfl-dropdown-content {
    display: none;
    z-index: 10003;
    width: max-content;
    height: max-content;
    background-color: ${o.CONTENT_BG_COLOR};
    box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);
    font-size: ${o.CONTENT_TEXT_SIZE};
    text-decoration: none;
  }

  .dmfl-dropdown-entry {
    z-index: 10004;
    padding: 3px 3px;
  }

  .dmfl-dropdown-content a {
    display: inline-block !important;
    color: ${o.CONTENT_TEXT_COLOR};
  }

  .dmfl-dropdown-content div.dmfl-preview-container {
    display: inline-block;
    color: ${o.CONTENT_TEXT_COLOR};
    margin-left: 10px;
    margin-right: 5px;
  }

  .dmfl-dropdown-content div.dmfl-preview-container:hover {
    background-color: ${o.CONTENT_DIV_HOVER_BG_COLOR};
    opacity: .9;
  }

  .dmfl-preview-background {
    background-color: rgb(0,0,0); /* Fallback color */
    background-color: rgba(0,0,0,0.9);
    display: flex;
    position: fixed;
    z-index: 30000;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
  }

  .dmfl-preview-image {
    max-width: 100vw;
    max-height: 100vh;
    object-fit: cover;
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }

  .dmfl-preview-download {
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 30px;
    height: 40px;
    width: 40px;
    color: honeydew !important;
    background-color: ${o.BUTTON_BG_COLOR};
    position: fixed;
    z-index: 30001;
    right: 20px;
    bottom: 20px;
  }

  .dmfl-preview-license-info {
    display: flex;
    color: honeydew !important;
    position: fixed;
    z-index: 30001;
    left: 20px;
    bottom: 20px;
  }

  .dmfl-dropdown-content a:hover {
    background-color: ${o.CONTENT_A_HOVER_BG_COLOR};
  }

  .dmfl-dropdown-container:hover .dmfl-dropdown-content {
    display: block;
  }

  .dmfl-dropdown-container:hover .dmfl-dropdown-button {
    background-color: ${o.BUTTON_HOVER_BG_COLOR};
    opacity: ${o.BUTTON_HOVER_OPACITY};
  }

  .dmfl-dropdown-container.dmfl-thumbnail-page {
    position: absolute;
    display: inline-block;
    width: max-content;
    height: max-content;
    padding: 3px;
  }

  .dmfl-dropdown-button.dmfl-thumbnail-page {
    position: relative;
    justify-content: ${o.BUTTON_JUSTIFY_ON_THUMBNAIL};
    align-items: ${o.BUTTON_ALIGN_ON_THUMBNAIL};
    color: ${o.BUTTON_TEXT_COLOR_ON_THUMBNAIL};
    background-color: ${o.BUTTON_BG_COLOR_ON_THUMBNAIL};
    opacity: ${o.BUTTON_OPACITY_ON_THUMBNAIL};
  }

  .dmfl-dropdown-content.dmfl-thumbnail-page {
    position: relative;
  }

  .dmfl-dropdown-container.dmfl-main-photo-page {
    position: relative;
    display: flex;
    align-items: center;
    margin-right: 12px;
    width: ${o.BUTTON_WIDTH};
    height: ${o.BUTTON_HEIGHT};
  }

  .dmfl-dropdown-button.dmfl-main-photo-page {
    position: absolute;
    justify-content: ${o.BUTTON_JUSTIFY};
    align-items: ${o.BUTTON_ALIGN};
    color: ${o.BUTTON_TEXT_COLOR};
    background-color: ${o.BUTTON_BG_COLOR};
    opacity: ${o.BUTTON_OPACITY};
  }

  .dmfl-dropdown-content.dmfl-main-photo-page {
    position: absolute;
    right: 0;
    bottom: ${o.BUTTON_HEIGHT};
  }

  .dmfl-dropdown-container.dmfl-lightbox-page {
    position: relative;
    display: flex;
    align-items: center;
    width: ${o.BUTTON_WIDTH};
    height: ${o.BUTTON_HEIGHT};
  }

  .dmfl-dropdown-button.dmfl-lightbox-page {
    position: absolute;
    justify-content: ${o.BUTTON_JUSTIFY};
    align-items: ${o.BUTTON_ALIGN};
    color: ${o.BUTTON_TEXT_COLOR};
    background-color: ${o.BUTTON_BG_COLOR};
    opacity: ${o.BUTTON_OPACITY};
  }

  .dmfl-dropdown-content.dmfl-lightbox-page {
    position: absolute;
    right: 0;
    bottom: ${o.BUTTON_HEIGHT};
  }

  .dmfl-disc-container {
    position: relative;
  }


  /*
   ================
  | Settings modal |
   ================
  */

  .dmfl-modal {
    display: flex;
    justify-content: center;
    align-items: center;
    position: fixed; /* Stay in place */
    z-index: 20000; /* Sit on top */
    left: 0;
    top: 0;
    width: 100%; /* Full width */
    height: 100%; /* Full height */
    overflow: auto; /* Enable scroll if needed */
    background-color: rgb(0,0,0); /* Fallback color */
    background-color: rgba(0,0,0,0.6); /* Black w/ opacity */
  }

  .dmfl-modal-content {
    position: absolute;
    background-color: #fefefe;
    padding: 20px;
    border: 1px solid #888;
    width: 60%;
    height: 80%;
    overflow: auto;
  }

  .dmfl-modal-content h3 {
    padding-left: 5px;
  }

  .dmfl-modal-body {
    overflow: auto;
    height: inherit;
    padding: 5px;
  }

  .dmfl-modal-footer {
    position: absolute;
    bottom: 10px;
  }

  .dmfl-modal-entry {
    display: block;
    margin-bottom: 15px;
    text-align: left;
    width: max-content;
  }

  .dmfl-modal-label {
    position: relative;
    display: inline-block;
  }

  .dmfl-modal-entry input {
    line-height: 1 !important;
    margin-left: 10px;
    vertical-align: middle;
  }

  .dmfl-modal-entry input[type="number"] {
    text-align: center;
    width: 65px;
    padding-block: 2px;
  }

  .dmfl-modal-tooltiptext {
    visibility: hidden;
    width: max-content;
    max-width: 300px;
    background-color: cornflowerblue;
    color: #fff;
    text-align: left;
    border-radius: 6px;
    padding: 10px;

    /* Position the tooltip */
    position: absolute;
    z-index: 1;
    left: calc(100% + 10px);
    top: -5px; /* 5px because the tooltip text has a top and bottom padding of 5px */

    /* Fade in tooltip */
    opacity: 0;
    transition: opacity 0.5s;
  }

  .dmfl-modal-label:hover .dmfl-modal-tooltiptext {
    visibility: visible;
    opacity: 1;
  }

  .dmfl-modal-color-picker {
    display: inline-block;
    margin-bottom: 5px;
  }

  /* The Close Button */
  .dmfl-modal-close {
    color: #aaaaaa;
    float: right;
    font-size: 28px;
    font-weight: bold;
  }

  .dmfl-modal-close:hover,
  .dmfl-modal-close:focus {
    color: #000;
    text-decoration: none;
    cursor: pointer;
  }
`;


const modalHTML = `
<form class="dmfl-modal-content" method="dialog">
  <span class="dmfl-modal-close">&times;</span>
  <h3>${scriptName}  \u27b2  Settings</h3><br>
  <div class="dmfl-modal-body"></div>
  <div class="dmfl-modal-footer">
    <button class="dmfl-modal-save-button" type="submit" disabled>Save &amp; Reload</button>
    <button class="dmfl-modal-restore-defaults-button">Restore defaults</button>
  </div>
</form>
`

function showSettings() {
  if (document.querySelector(".dmfl-modal")) return;

  const modal = document.createElement("div");
  modal.className = "dmfl-modal";
  modal.innerHTML = modalHTML;
  document.body.appendChild(modal);

  const modalContent = modal.querySelector('.dmfl-modal-content');
  const modalClose = modal.querySelector(".dmfl-modal-close");
  const modalSave = modal.querySelector(".dmfl-modal-save-button");
  const modalRestore = modal.querySelector(".dmfl-modal-restore-defaults-button");
  const modalBody = modal.querySelector(".dmfl-modal-body");
  const tempSettings = GM_getValue('settings', {});

  const fillBody = (settings) => {
    for (const [key, defaultSetting] of Object.entries(defaultSettings)) {
      const entry = document.createElement('div');
      entry.className = 'dmfl-modal-entry';
      const inputElement = document.createElement("input");
      inputElement.className = 'dmfl-modal-input';

      if (!tempSettings[key]) {
        tempSettings[key] = {};
      }

      let valGetter, valSetter;
      if (typeof defaultSetting.value === 'boolean') {
        inputElement.setAttribute('type', 'checkbox');
        valGetter = 'checked';
        valSetter = 'checked';
      } else if (typeof defaultSetting.value === 'number') {
        inputElement.setAttribute('type', 'number');
        inputElement.setAttribute('min', 100);
        inputElement.setAttribute('step', 100);
        inputElement.required = true;
        valGetter = 'valueAsNumber';
        valSetter = 'value';
      } else {
        inputElement.setAttribute('type', 'text');
        valGetter = 'value';
        valSetter = 'value';
      }

      const settingValue = getSettingValue(key, settings);
      inputElement[valSetter] = settingValue;
      tempSettings[key].value = settingValue;

      const label = document.createElement("label");
      label.className = 'dmfl-modal-label';
      label.textContent = defaultSetting.name;

      if (defaultSetting.desc) {
        const tooltipText = document.createElement('span');
        tooltipText.className = 'dmfl-modal-tooltiptext';
        tooltipText.innerText = `${defaultSetting.desc}\n\nDefault: ` +
                                `${String(defaultSetting.value).replace(/^true$/, 'On').replace(/^false$/, 'Off')}`;
        label.style.borderBottom = '1px dotted black';
        label.appendChild(tooltipText);
      }

      entry.appendChild(label);
      entry.appendChild(inputElement);

      if (key.indexOf('_COLOR') >= 0) {
        const colorPicker = document.createElement('input');
        colorPicker.className = 'dmfl-modal-color-picker';
        colorPicker.setAttribute('type', 'color');
        colorPicker.value = inputElement.value;
        colorPicker.addEventListener("input", () => {
          inputElement.value = colorPicker.value;
          inputElement.dispatchEvent(new Event('input'));
        })
        entry.appendChild(colorPicker);
      }

      inputElement.addEventListener("input", () => {
        modalSave.disabled = false;
        modalRestore.disabled = false;
        tempSettings[key].value = inputElement[valGetter];
      })
      modalBody.appendChild(entry);
    }
  }

  fillBody(tempSettings);

  modalClose.onclick = function() {
    modal.remove();
  }
  modalRestore.onclick = function() {
    modalBody.innerHTML = '';
    fillBody(defaultSettings);
    modalSave.disabled = false;
    modalRestore.disabled = true;
  }
  modalContent.onsubmit = function() {
    modalSave.disabled = true;
    modalRestore.disabled = true;
    GM_setValue('settings', tempSettings);
    window.location.reload();
  }
}

GM_registerMenuCommand('Settings', showSettings);

const INSERT_LOCATIONS = [
  'div.photo-list-photo-view', /* Thumbnails */
  'div.photo-list-tile-view',
  'div.photo-list-gallery-photo-view',
  'div.photo-list-description-view',
  'div.photo-card-engagement-view',
  'div.photo-engagement-view', /* Main page, Lighbox page */
  'div.group-discussion-topic-view .message-text img', /* Discussions page */
].join(', ');


// Ensure that the previous interval has completed before recursing
function main() {
  setTimeout(() => {
    document.querySelectorAll(INSERT_LOCATIONS).forEach(node => {
      if (!nodesProcessed.has(node)) processNode(node);
    });
    main();
  }, o.UPDATE_INTERVAL);
}


async function appInit() {
  if (typeof unsafeWindow !== undefined) {
    while (!appInitOk) {
      if (!unsafeWindow.appContext?.getModel) {
        appInitRetryCount++;
        if (appInitRetryCount == 10) break;
        console.log("Waiting for YUI appContext...");
        await sleep(1000);
      } else {
        appInitOk = true;
      }
    }
  }
  if (!appInitOk) {
    console.log("YUI failed to init. Running in xhr mode.");
  } else {
    console.log("YUI app ready.");
  }
}

(async () => {
  await appInit();
  console.log('Adding styles.');
  GM_addStyle(STYLE);
  console.log("Starting timer.");
  main();
})();