FuturePost

Extension to 4chanX that adds support for delayed posts. Useful for delaying your posts when you want to keep a slow thread bumped while idle.

// ==UserScript==
// @name     FuturePost
// @version  1.1
// @include      http://boards.4chan.org/*
// @include      https://boards.4chan.org/*
// @include      http://sys.4chan.org/*
// @include      https://sys.4chan.org/*
// @include      http://www.4chan.org/*
// @include      https://www.4chan.org/*
// @include      http://boards.4channel.org/*
// @include      https://boards.4channel.org/*
// @include      http://sys.4channel.org/*
// @include      https://sys.4channel.org/*
// @include      http://www.4channel.org/*
// @include      https://www.4channel.org/*
// @license MIT
// @require  https://code.jquery.com/jquery-3.5.1.min.js
// @namespace PyonScripts
// @description Extension to 4chanX that adds support for delayed posts. Useful for delaying your posts when you want to keep a slow thread bumped while idle.
// ==/UserScript==

// A separate queue for each page
let pageQueues = {
    '2': [],
    '3': [],
    '4': [],
    '5': [],
    '6': [],
    '7': [],
    '8': [],
    '9': [],
    '10': [],
    '11': []
};

function delay(time) {
  return new Promise(resolve => setTimeout(resolve, time));
}

// The main loop that will be responsible for posting the enqueued posts
setInterval(function() {
    const pageCountElement = $('#page-count');

    // if post-count has the class warning, don't post anything
    if (document.querySelector("#post-count").classList.contains('warning')) {
        return;
    }

    const currentPage = Number(pageCountElement.text());

    for (let i = 3; i <= currentPage; i++) {
        if (pageQueues[i.toString()] && pageQueues[i.toString()].length > 0) {
            const post = pageQueues[i.toString()].shift();

            document.querySelector('#shortcut-qr a').click()
            delay(1000).then(() =>  {
              if(post.file) {
                var detail = {file: post.file, name: post.fileName};
                if (typeof cloneInto === 'function') {
                  detail = cloneInto(detail, document.defaultView);
                }
                var event = new CustomEvent('QRSetFile', {bubbles: true, detail: detail});
                document.dispatchEvent(event);
              }
              document.querySelector('#qr textarea').value = post.message;

            });
            delay(2000).then(() =>  {
              document.querySelector("#file-n-submit input[type='submit']").click();
              // Attempt to remove the preview from the thread
              const fakePost = document.getElementById(`fp${post.mockNumber}`);
              if (fakePost) {
                fakePost.remove();
              }
            });
            console.log('Remaining queue', pageQueues);
            break;
        }
    }
}, 60000);

function removeFuturePost(postNumber) {
  // Iterate over each page queue in the pageQueues object
  for (const queueName in pageQueues) {
    const queue = pageQueues[queueName];
    // Find the index of the object with a matching postNumber in the queue
    const index = queue.findIndex(obj => obj.mockNumber === postNumber);
    // If the object is found, remove it from the queue
    if (index !== -1) {
      queue.splice(index, 1);
      removePostPreview(postNumber);
      break;
    }
  }
  console.log('Remaining queue', pageQueues);
}

function removePostPreview(postNumber) {
  // Attempt to remove the preview from the thread
  const fakePost = document.getElementById(`fp${postNumber}`);
  if (fakePost) {
    fakePost.remove();
  }
}

function initExtension() {
    var qrElement = document.querySelector('#qr.dialog');
    var futurePostEl = document.querySelector('#future-post');

    // Stop init if it can't find the quickly reply dialog, or if this extension
    // has already been initialized
    if (!qrElement || futurePostEl) {
        return;
    }

    const originalButton = qrElement.querySelector('input[type="submit"]');
    if (!originalButton) {
      return;
    }

    // Create and style your fake button
    var fakeButton = document.createElement('input');
    fakeButton.type = 'submit';
    fakeButton.value = "Submit";
    fakeButton.id = 'future-post';

    // Submit handler for the delayed post button
    fakeButton.addEventListener('click', (e) => {
      e.preventDefault();  // Prevents the default action
      e.stopPropagation(); // Prevents the event from propagating up the DOM tree, preventing any parent handlers from being notified of the event

      // Your custom submit behavior here
      const textArea = $('#qr textarea');
      const fileInput = document.querySelector('#qr input[type="file"]'); // replace with your actual file input ID

      const pageNum = $('#fp-page-num').val();

      let fileBlob;
      let fileName;

      document.addEventListener('QRFile', function(e) {
        if(e.detail) {
          fileName = e.detail.newName;
          fileBlob = new Blob([e.detail], { type: e.detail.type });
        }
      }, false);

      var event = new CustomEvent('QRGetFile', {bubbles: true, detail: null});
      document.dispatchEvent(event);

      // Generate a random post number
      const randomNumber = Math.floor(Math.random() * 1000000);
      let post = {
          mockNumber: randomNumber,
          message: textArea.val(),
          file: fileBlob, // this will be null if no file was selected
          fileName: fileName // this will be null if no file was selected
      };
      pageQueues[pageNum].push(post);

      // Try to reset the form, doings lots of extra stuff i prob don't have to
      $('#fp-checkbox').prop('checked', false).trigger('change');
      fileInput.value = '';
      textArea.val('');
      const closeAnchor = document.querySelector('#qr .close');
      closeAnchor.click();
      console.log(pageQueues);

      // Add a placeholder delayed post to the DOM
      const threadDiv = document.querySelector('.thread');

      // Create a new postContainer element
      const postContainer = document.createElement('div');
      postContainer.classList.add('replyContainer');
      postContainer.id = `fp${post.mockNumber}`

      let fileInfo = '';

      if(fileName) {
        const fileExtension = fileName.split('.').pop().toLowerCase();
        let imgThumb = '';
        if (['jpg', 'gif', 'png', 'jpeg'].includes(fileExtension)) {
          // Create a File blob with the fileName and set it as the img src
          const imgSrc = URL.createObjectURL(fileBlob);
          const fileSizeInBytes = fileBlob.size;
          const fileSizeInKB = Math.round(fileSizeInBytes / 1024);
          imgThumb = `<a class="fileThumb" href="javascript:void(0);" target="_blank">
            <img src="${imgSrc}" alt="${fileSizeInKB} KB" style="width: 125px;" loading="lazy">
          </a>`;
        }

        fileInfo = `<div class="file">
        <div class="fileText">
          <span class="file-info">
            <a href="javascript:void(0);" target="_blank">${fileName}</a>
          </span>
          </div>
          ${imgThumb}
        </div>`
      }

      // Set the innerHTML of the postContainer element with the provided HTML, replacing the message content
      postContainer.innerHTML = `
        <div class="post reply" style="border: 1px dotted yellow;">
          <div class="postInfoM mobile">
            <span class="nameBlock"><span class="name">Anonymous</span><br /></span>
          </div>
          <div class="postInfo desktop">
            <input type="checkbox" name="51464347" value="delete" /> <span class="nameBlock"><span class="name">Anonymous</span> </span> <span class="dateTime" title="This post will not be submitted until the page number that is shown" >
            Page ${pageNum}
            </span>
            <span class="postNum desktop"><a href="#fp${post.mockNumber}" title="Link to this post">No.</a><a href="javascript:void(0);" title="You can't reply to a future post">Pending</a></span>
            <a class="menu-button delete-future-post" href="javascript:void(0);"><i class="fa fa-times"></i></a>
          </div>
          ${fileInfo}
          <blockquote class="postMessage" style="min-width: 300px">
            ${post.message.replace(/\n/g, '<br>')}
            <br />
            <br />
            <strong style="color: red;">(Only visible to you)</strong>
          </blockquote>
        </div>
      `;

      // Append the new postContainer element to the thread div
      threadDiv.appendChild(postContainer);

      postContainer.querySelector('.delete-future-post').addEventListener('click', function(e) {
        e.preventDefault();
        const confirmation = confirm('Remove this future post?');
        if (confirmation) {
          removeFuturePost(post.mockNumber)
        }
      });
    });

    // Append checkbox to qrElement
    var checkbox = document.createElement('input');
    checkbox.type = 'checkbox';
    const futurePostHtml = `<div>
            Delay Post?
            <input type="checkbox" id="fp-checkbox" />
            Page:
            <select id="fp-page-num" required disabled>
              <option value="2">2</option>
              <option value="3">3</option>
              <option value="4">4</option>
              <option value="5">5</option>
              <option value="6" selected>6</option>
              <option value="7">7</option>
              <option value="8">8</option>
              <option value="9">9</option>
              <option value="10">10</option>
              <option value="11">11</option>
            </select>
      </div>
    `;
    $(qrElement).append(futurePostHtml);

    $(document).on('change', '#fp-checkbox', function() {
      if($(this).is(":checked")) {
        $('#fp-page-num').prop('disabled', false);
        // Check if fakeButton already exists in the DOM
        if (document.querySelector('#future-post')) {
          // If it does, simply show it
          fakeButton.style.display = "inline";
        } else {
          // Otherwise, add it to the DOM
          $(originalButton).after(fakeButton);
        }
        originalButton.style.display = 'none';
      } else {
        $('#fp-page-num').prop('disabled', true);
        // Replace the fake button with the original one
        fakeButton.style.display = "none";
        originalButton.style.display = "inline";
      }
    });

    startIdleStats();
}

function startIdleStats() {
  let lastUpdateTime = Date.now();
  let changeDurations = []; // keep track of all durations between changes

  // Create a callback function to be executed whenever a mutation is observed
  const callback = function(mutationsList, observer) {
      for(let mutation of mutationsList) {
          if (mutation.type === 'childList') {
              let currentTime = Date.now();
              let duration = currentTime - lastUpdateTime;
              lastUpdateTime = currentTime;
              changeDurations.push(duration);
              dispatchIdleMetricsUpdate(); // dispatch event when metrics change
          }
      }
  };

  // Create an observer instance linked to the callback function
  let observer = new MutationObserver(callback);

  // Start observing the 'page-count' node for configured mutations
  let targetNode = document.getElementById('page-count');
  observer.observe(targetNode, { childList: true, subtree: true });

  // Calculate the weighted average change duration
  function calculateWeightedAverageDuration() {
      let weightedSum = 0;
      let totalWeight = 0;
      for(let i = 0; i < changeDurations.length; i++) {
          let weight = i + 1; // more recent changes have a larger weight
          weightedSum += changeDurations[i] * weight;
          totalWeight += weight;
      }
      let average = weightedSum / totalWeight;
      return average / 60000; // convert from ms to minutes
  }

  // Calculate the approximate time before the thread hits page 11
  function calculateIdleTimeBeforePageEleven() {
      let weightedAvg = calculateWeightedAverageDuration();
      let totalBumpTime = 0;
      for (let page in pageQueues) {
          totalBumpTime += pageQueues[page].length * page * weightedAvg;
      }
      let totalPageChanges = 11;
      let approximateTime = totalPageChanges * weightedAvg + totalBumpTime;
      return approximateTime;
  }

  // Dispatch an event with the updated idle metrics
  function dispatchIdleMetricsUpdate() {
      let event = new CustomEvent('idleMetricsUpdate', {
          detail: {
              idleTimeBeforePageEleven: calculateIdleTimeBeforePageEleven(),
              weightedAvgDuration: calculateWeightedAverageDuration(),
          }
      });
      window.dispatchEvent(event);
  }

  window.addEventListener('idleMetricsUpdate', function(e) {
      let idleTimeBeforePageEleven = e.detail.idleTimeBeforePageEleven;
      let hours = Math.floor(idleTimeBeforePageEleven / 60);
      let minutes = Math.floor(idleTimeBeforePageEleven % 60);
      let weightedAvgDuration = e.detail.weightedAvgDuration.toFixed(2); // rounded to 2 decimal places

      // Display the report in the console with a label for easy filtering
      console.log(
          `%cIdle Metrics Report:\n\n` +
          `Idle time before page 11: ${hours} hour(s) and ${minutes} minute(s)\n` +
          `Weighted average duration: ${weightedAvgDuration} minute(s)`,
          'color: green; font-weight: bold;'
      );
  });
}

// Initialize the extension
document.addEventListener('QRDialogCreation', function(e) {
  initExtension();
}, false);