Mastodon Notes

Adds 'notes' buttons to profile links to configured Mastodon sites, which can be used to add your own notes (to be displayed as 'title' attributes) to users' profile links.

// ==UserScript==
// @name         Mastodon Notes
// @namespace    FiXato's Mastodon Notes Extension
// @version      0.5.1
// @description  Adds 'notes' buttons to profile links to configured Mastodon sites, which can be used to add your own notes (to be displayed as 'title' attributes) to users' profile links.
// @author       FiXato
// @match        https://hackers.town/*
// @match        https://mastodon.social/*
// @match        https://octodon.social/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';
    console.log('Mastodon Notes loaded');

    var users = {};
    var first_call;
    const max_time_between_calls = (1000 * 3); // 3 seconds delay to reduce lag from the function being called too often while new content loads.
    var restore_users_note_timer;
    var running = false;

try {

    if (typeof(Storage) === "undefined") {
        console.error("Local Storage not supported");
        return;
    }

    if (get_notes_auto_status()) {
        restore_users_notes();
    }

    // Options for the observer (which mutations to observe)
    const config = { attributes: false, childList: true, subtree: true };

    // Callback function to execute when mutations are observed
    const callback = function(mutationsList, observer) {
        // Use traditional 'for loops' for IE 11
        for(let mutation of mutationsList) {
            if (mutation.type === 'childList' && mutation.addedNodes && mutation.addedNodes.length > 0) {
                //console.log("Mastodon Notes:", mutation);
                if (get_notes_auto_status()) {
                    if (mutation.target && mutation.target.getAttribute('role') == 'feed' && mutation.target.classList.contains('item-list')) {
                        //FIXME: This should be called on only a subset of items, so it doesn't get repeatedly called on object that are already processed.
                        restore_users_notes();
                    }
                    if (mutation.target && mutation.target.classList.contains('activity-stream')) {
                        restore_users_notes();
                    }
                }
            }
        }
    };

    const targetNode = document.querySelector('body');

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

    // Start observing the target node for configured mutations
    observer.observe(targetNode, config);
}
catch(err) { console.error('Mastodon Notes: error', err); }

    // Function by Mark Amery from https://stackoverflow.com/a/35385518
    function htmlToElement(html) {
        let template = document.createElement('template');
        html = html.trim(); // Never return a text node of whitespace as the result
        template.innerHTML = html;
        return template.content.firstChild;
    }

    const notes_control_panel = htmlToElement(`
      <div id="notes_control_panel">
        <h1>Mastodon Notes:</h1><br/>
        <div>
          <label>Status:</label>
          <label class="switch" for="notes_status_toggle">
            <input type="checkbox" id="notes_status_toggle" value="Toggle" checked>
            <span class="slider round"></span>
          </label>
        </div>
        <div>
          <label>Auto-Apply:</label>
          <label class="switch" for="notes_auto_toggle">
            <input type="checkbox" id="notes_auto_toggle" value="Toggle" checked>
            <span class="slider round"></span>
          </label>
        </div>
        <div><button id="reapply_notes">Reapply</button></div>
      </div>
    `);

    const head=document.querySelector('head');
    var styles = `<style>button.notes { opacity: 0; visibility: hidden; transition: visibility 1s, opacity 1s linear; position: absolute; margin-top: -1em; margin-left: -2em;}
    a:hover + button.notes, button.notes:hover { visibility: visible; opacity: 1; z-index: 998}
    #notes_template {width: 100%; height: 100%; position: fixed; top: 0; left: 0; z-index: 999; background: rgba(30,30,30, 0.8); color: #fff; }
    .notes_panel {display: flex; align-items: center; justify-content: center; }
    #notes_content {margin: auto 0; position: relative; background: rgb(30,30,30); border: 1px solid rgb(200,200,200); padding: 2em; margin: 2em;}
    #notes_content form textarea, #notes_content form input {width: 100%;}
    #notes_content form label, #notes_content form input {margin: 0.5em;}
    #notes_control_panel { background-color: #282c37; color: #9baec8; margin-bottom: 10px; padding: 15px 30px 15px 15px; }
    #notes_control_panel h1 { font-size: 1.2em; }
#notes_control_panel > div { display: inline-block; margin-right: 0.5em; margin-bottom: 0.5em;}
    /* The switch - the box around the slider */
    .switch {
        position: relative;
        display: inline-block;
        width: 3em;
        height: 1.5em;
    }

    /* Hide default HTML checkbox */
    .switch input {
        opacity: 0;
        width: 0;
        height: 0;
    }

    /* The slider */
    .slider {
        position: absolute;
        cursor: pointer;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background-color: #ccc;
            -webkit-transition: .4s;
        transition: .4s;
    }

    .slider:before {
        position: absolute;
        content: "";
        height: 1em;
        width: 1em;
        left: 0.25em;
        bottom: 0.25em;
        background-color: white;
        -webkit-transition: .4s;
        transition: .4s;
    }

    input:checked + .slider {
        background-color: #2196F3;
    }

    input:focus + .slider {
        box-shadow: 0 0 1px #2196F3;
    }

    input:checked + .slider:before {
        -webkit-transform: translateX(1.5em);
        -ms-transform: translateX(1.5em);
        transform: translateX(1.5em);
    }

    /* Rounded sliders */
    .slider.round {
        border-radius: 1em;
    }

    .slider.round:before {
        border-radius: 50%;
    }
</style>`
    head.appendChild(htmlToElement(styles));
    add_control_panel();

    function get_notes_status() {
        let notes_status = localStorage.getItem('notes_enabled');
        if (notes_status === null) {notes_status = "true";}
        // work around string type of localStorage.
        notes_status = (notes_status == "true" ? true : false);
        return notes_status;
    }

    function get_notes_auto_status() {
        let notes_auto_status = localStorage.getItem('notes_auto_apply');
        if (notes_auto_status === null) {notes_auto_status = "true";}
        // work around string type of localStorage.
        notes_auto_status = (notes_auto_status == "true" ? true : false);
        return notes_auto_status;
    }

    function toggle_notes() {
        let notes_status = !get_notes_status();
        localStorage.setItem('notes_enabled', notes_status);

        let notes_status_toggle = document.querySelector('#notes_status_toggle');
        if (notes_status_toggle === null) {
            console.error('Mastodon Notes: could not find notes status toggle');
            return false;
        }
        notes_status_toggle.checked = notes_status;
        if (notes_status) {restore_users_notes();}
    }

    function toggle_auto_apply() {
        let notes_auto_status = !get_notes_auto_status();
        localStorage.setItem('notes_auto_apply', notes_auto_status);

        let notes_status_toggle = document.querySelector('#notes_auto_toggle');
        if (notes_status_toggle === null) {
            console.error('Mastodon Notes: could not find notes auto-apply status toggle');
            return false;
        }
        notes_status_toggle.checked = notes_auto_status;
        if (notes_auto_status) {restore_users_notes();}
    }

    function add_control_panel() {
      let beforeElement = document.querySelector('.drawer .search, .column-1 .public-account-bio');
      if (beforeElement === null) {
          console.log('Mastodon Notes: Drawer with search not yet present. Will retry');
          setTimeout(() => { add_control_panel(); }, 500);
          return false;
      }
      let notes_control_panel_el = document.querySelector('body #notes_control_panel');
      if(notes_control_panel_el === null) {
          console.log('Mastodon Notes: Adding control panel');
          beforeElement.parentElement.insertBefore(notes_control_panel, beforeElement);
          let notes_status = get_notes_status();
          let notes_status_toggle = document.querySelector('#notes_status_toggle');
          if (notes_status_toggle !== null) {
              notes_status_toggle.checked = notes_status;
              notes_status_toggle.addEventListener('click', toggle_notes);
          }

          let notes_auto_status = get_notes_auto_status();
          let notes_auto_toggle = document.querySelector('#notes_auto_toggle');
          if (notes_auto_toggle !== null) {
              notes_auto_toggle.checked = notes_auto_status;
              notes_auto_toggle.addEventListener('click', toggle_auto_apply);
          }
          let reapply_button = document.querySelector('#reapply_notes');
          if (reapply_button !== null) { reapply_button.addEventListener('click', restore_users_notes); }
      }
    }

    function open_notes(event) {
        console.log('opening notes interface');
        let notes_template = '<div id="notes_template" class="notes_panel"><div id="notes_content"><form><label for="notes_profile_url">Notes for:</label><input type="text" id="notes_profile_url" /><br /><label for="notes"><textarea id="notes" cols="70" rows="15"></textarea></label><input id="save_notes" type="submit" value="Save!"><input id="close_notes" type="reset" value="Reset & Close"></form></div></div>'
        let profile_url = this.dataset.profileUrl;
        let new_element = htmlToElement(notes_template);
        let body = document.querySelector('body');
        body.insertBefore(new_element, body.firstElementChild);
        document.querySelector('#notes_profile_url').value = profile_url;
        document.querySelector('textarea#notes').value = users[profile_url];
        document.querySelector('#notes_template form').addEventListener('submit', save_notes);
        document.querySelector('#notes_template form #save_notes').addEventListener('click', save_notes);
        document.querySelector('#notes_template form #close_notes').addEventListener('click', close_notes);
    }
    function close_notes(event) {
        console.log('closing notes');
        document.querySelector('#notes_template').remove();
        set_open_notes_event_handlers();
    }
    function save_notes(event) {
        event.preventDefault();
        let profile_url = document.querySelector('#notes_profile_url').value;
        let notes = document.querySelector('#notes').value;
        console.log('saving notes for ' + profile_url);
        if (store_user_notes(profile_url, notes) == true) {
            close_notes()
            restore_users_notes();
        }
    }

    function store_user_notes(profile_url, notes) {
        users[profile_url] = notes;
        localStorage.setItem('notes_for_' + profile_url, notes);
        return true;
    }

    // not sure why I have to call this multiple times, but otherwise I seem to lose the handler after editing the first note.
    function set_open_notes_event_handlers() {
        let note_buttons = document.querySelectorAll('button.notes');
        note_buttons.forEach((element) => {
            element.addEventListener('click', open_notes);
        });

    }

    function append_notes_to_title(profile_url, element) {
        if (users[profile_url] !== null) {
            if (element.title !== undefined && element.dataset.originalTitle === undefined) {
                element.setAttribute('data-original-title', element.title);
            }
            element.title = (element.dataset.originalTitle + (element.dataset.originalTitle && element.dataset.originalTitle.length > 0 ? "\n" : "") + "Notes: " + users[profile_url]);
        }
    }
    function add_buttons_to_link_elements(profile_link_elements) {
        //let idx = 1;
        for(let element of profile_link_elements) {
            //console.log('Mastodon Notes: element[' + idx + '/' + profile_link_elements.length + ']:', element);
            //idx += 1;
            let profile_url = element.href;
            // Don't add notes buttons again if it is already present.
            if (!element.nextElementSibling || !element.nextElementSibling.classList.contains('notes')) {
                let new_element = htmlToElement('<button class="notes" data-profile-url="' + profile_url + '">Notes</button>');
                if (!element.nextElementSibling) {
                    element.parentNode.appendChild(new_element);
                } else {
                    element.parentNode.insertBefore(new_element, element.nextElementSibling);
                }
            }
            let notes = localStorage.getItem('notes_for_' + profile_url);
            if (notes !== "undefined") {
                users[profile_url] = notes;
                try {
                    append_notes_to_title(profile_url, element);
                }
                catch(err) {
                    console.error('Mastodon Notes: error', err);
                }
            }
        }
    }

    function restore_users_notes() {
        if (!get_notes_status()) {
            console.warn('Mastodon Notes: Cannot restore users notes as Mastodon Notes is currently disabled. Please toggle it on first.');
            return false;
        }
        if (running) { return false; }
        if (first_call === undefined) { first_call = Date.now();}
        let delta_first_call = (Date.now() - first_call);

        if (restore_users_note_timer !== undefined) { clearTimeout(restore_users_note_timer); }

        if (delta_first_call < max_time_between_calls) {
            let time_remaining = (max_time_between_calls - delta_first_call);
            //console.log('Mastodon Notes: restore_users_notes() delayed for ' + time_remaining + 'ms');

            restore_users_note_timer = setTimeout(() => { restore_users_notes(); }, time_remaining);
            return false;
        } else {
            first_call = Date.now();
            restore_users_note_timer = undefined;
        }
        console.log("Mastodon Notes: restoring users' notes");
        running = true;
        let profile_link_elements = document.querySelectorAll('a.mention[href], a.status__display-name[href], a.avatar[href], a.detailed-status__display-name[href]');
        add_buttons_to_link_elements(profile_link_elements);

        set_open_notes_event_handlers();
        running = false;
    }

})();