DEV Community

Cover image for Drive-by UX enhancing with ViolentMonkey (Part I of N)
Ground Hogs
Ground Hogs

Posted on

Drive-by UX enhancing with ViolentMonkey (Part I of N)

Intro

I downloaded ViolentMonkey somewhere in the past week and rediscovered a sense of wonder and joy I didn't think was there any more. It also runs in every major browser, so I can have the same magical mystery scripts follow me around.

Thoroughly customized GMail with it, added a bunch of useful things to Jira, the usual fun.

But then I promptly decided to go and build a thing with it, which I find wonderful! Bask in its glory:

Bask in its glory

Everything after the odd `[-]` thing is my UI extension.

There's a website called Hacker News Network, which you'd be forgiven if you mistook it for an actual hackerspace. It's more of a rough-around-the-edges version of reddit for startup founder types, with ocasional nerd chat around it.

It's not bad, it's not great either. So I went and instantaneously improved it 10000% (estimation based on an n=1 test) by grafting my own micro-ranking on top of it. Awesome innit?

It works a lot like an extension -in fact, it runs in the **same* space* as extensions do, due to ViolentMonkey's magical featureset-, except it is entirely private to me and allows me to vote and tag people, storing the results of doing so in a firebase db.

Let me show you how it works, it's fun.

Internals

The observables

It all hinges on an observable implementation I built specially for this, which while probably too minimal, I didn't find restrictive:

function observable(value) {
  let current = value;
  let listeners = [];
  const instance = function(newValue) {
  if(typeof(newValue) === 'undefined'){
    return current;
  }
  listeners.forEach((listener) => listener(newValue, current));
    current = newValue;
  };
  instance.subscribe = function(fn) {
    listeners.push(fn);
    return function unsubscribe() {
      listeners = listeners.filter((listener) => listener !== fn);
      }
  };
  return instance;
}

/*
* # Usage
* const COUNT = observable(0); 
* const unsubscribe = COUNT.subscribe(function(current){
*   if(current >= 5){
*     unsubscribe();
*   }
*   console.log(current);
* });
* for(let i=0; i < 10; i++){
*   STATE(STATE()+1);
* }
*/
Enter fullscreen mode Exit fullscreen mode

The API

As soon as the script starts its lifetime, I added two subscribers to a PAGE_STATE observable I have that is basically empty at this point. See if you can figure out what they do:

  const PAGE_STATE = observable({});
  PAGE_STATE.subscribe(updateUI);
  PAGE_STATE.subscribe(updateFirebase);
Enter fullscreen mode Exit fullscreen mode

Upon page load, I go ask for firebase.firestore().collection('COLLECTION_NAME').doc('karma').get();, which is a bit of a mouthful but also used just this once, and promptly put it on a PAGE_STATE observable I had declared earlier.

We haven't really done that much yet but can confidently say this thing is already

  1. Reactive
  2. Decoupled
  3. Inextrincably tied to a random Google product

Not only that, but it also implements an unidirectional data flow 🌈🦄! Awesomest ~20 LOC I've written this far.

The magic

Turns out most modern sites these days use a Content-Security-Policy thing to ensure you don't just sideload a bunch of stuff on them (damn you miscreants for ruining my fun) so you can't just plop firebase in there and expect it to work.

Or... can you? Well actually, if you tell ViolentMonkey to run your script in extension space via this magic incantation: // @inject-into content you totally can. This brings any scripts you injected through // @require to a magical mystery location called unsafeWindow, which is ugly, but you can totally pretend it's not there because it's the default global now.

This allows us to magically sync whatever the UI does to Firebase!
My updateFirebase function ended up looking a lot like this:

function updateFirebase(current){
  const {karma, notes} = current;
  const doc = firebase.firestore().collection('COLLECTION_NAME').doc(karma);
  doc.set({ karma, notes }, { merge: true });
}
Enter fullscreen mode Exit fullscreen mode

Had to make a minimal change to my initial page state thingamabob declaration, but with that we can be reasonably sure that we have an autosynced data model. Check this out:

  const INITIAL_STATE = {
    karma: {},
    notes: {}
  }
  const PAGE_STATE = observable(INITIAL_STATE);
Enter fullscreen mode Exit fullscreen mode

And with this, any change the data layer does to either karma or notes is automagically saved!!1.

Sure, we don't do any kind of error checking or validation, but I probably will down the line.

No frameworks in sight, easily-abstractable third party libraries... isn't this awesome?

The gnarly bits

So it turns out that writing an article about extending UI without writing a single bit of UI is generally frowned upon.

UI however also turns out to be one of the most well-trodden paths in the history of frontend development, so I might be forgiven for having taken a number of creative licenses I could never have applied to my React-based day job.

What do I mean?

Drawing the actual UI

By this point we're reasonably sure there is an updateUI function that will run whenever the data changes. Right. But... how should it work?

A good first starting point (which, to be frank, I haven't yet abandoned since the whole thing is just so pretty) is as follows:

// HELPERS
function sameish(a, b){
  // there sure is room for improvement here
  return JSON.stringify(a) !== JSON.stringify(b);
}

function $(sel, all = false) {
  return document['querySelector' + (all ? 'All' : '')](sel);
}

function str2HTML(str){
  const wrap = document.createElement('wrap');
  wrap.innerHTML = str;
  return wrap.firstElementChild;
}

// ACTUAL UI CODE
function trashUI(){
  [...$('.karma-trinket', true)].forEach(n => n.remove());
}

function drawUI({karma}){
  const handleUpdoot = (user) => {
    const state = PAGE_STATE();
    PAGE_STATE({
      ...state,
      karma: {
        ...state.karma,
        [user]: (state.karma[user]||0) + 1
      }
    });
  };
  const handleDowndoot = () => {
    const state = PAGE_STATE();
    PAGE_STATE({
      ...state,
      karma: {
        ...state.karma,
        [user]: (state.karma[user]||0) - 1
      }
    });  
  };
  const makeTrinket = (user, points) => {
    const node = str2HTML(`
      <div class="karma-trinket" style="display:inline-flex">
        <div>Own: (${points})</div>
          <div>
            <button aria-label="Upvote">â–²</button>
            <button aria-label="Downvote">â–¼</button>
          </div>
        </div>
    `);
    node.querySelector('[aria-label="Upvote"]')
      .addEventListener('click', () => handleUpdoot(user));
    node.querySelector('[aria-label="Downvote"]')
      .addEventListener('click', () => handleDowndoot(user));
    };

    [...$('.hnuser', true)].forEach(
      function addTrinket(user){
        const points = karma[user.innerText] || 0;
        const parent = user.parentNode;
        parent.insertBefore(
          makeTrinket(user, points), 
          user.nextSibling
        );
      }
    );
}
// Observable subscriber
function updateUI(current, old){
  if(!sameish(current, old)){
    trashUI();
    drawUI(current);
  }
}
Enter fullscreen mode Exit fullscreen mode

Whew! We may have sprouted a bunch of helpers, but they're all kind of self explanatory and we now have SOME SORT of UI! My own version of this is more baroque and bikesheddy, but this is a great approximation to my first draft.

Isn't it just the best?

If you collect all the bits and pieces, you should have something along these lines:

Alt Text

Beautiful huh?

And you should be able to increment or decrement the counter for any given user by clicking on the upvote/downvote arrows.

Outro

By this point you should have either lost your sanity, your interest or a combination of both, so I'll leave the rest (and there's a lot left!) for future articles in the series; for starters, I want to be able to define the HTML and CSS in a more, shall we say... expressive way? The sheer modernness of it all! Coming up next 🦄

Please leave suggestions, insults or words of encouragement below.

Top comments (0)