Adding a Custom CSS menu to Slack

f53

F53

Posted on August 25, 2022

Adding a Custom CSS menu to Slack

Intro

A lot of people don't like how Discord looks/works, so there are many mods that add customizability to it:

Slack is basically a worse Discord but targeted at professionals instead of gamers.

Given that Slack is worse, you would expect there to be more mods for it. Despite this expectation, the results of my search for a Slack mod were underwhelming. There are absolutely no clients that do it for you, and all of the tutorials on how to inject css/js don't work on modern versions of Slack.

The only way I have found so far that works now is this general purpose electron injector on github. But this is a far cry from the ease of use of Discord client mods like GooseMod.

Injecting JavaScript through Electron Inject

Instead of reinventing the wheel, I decided to make my project depend on the well maintained electron-inject github repo.

Here's the script I threw together

from electron_inject import inject

# for windows this will be something like "C:/ProgramData/F53/slack/app-4.27.154/slack.exe"
slack_location = "/usr/lib/slack/slack"
inject_location = "/home/f53/Projects/SlackMod/inject.js"

inject(slack_location, devtools=True, timeout=600, scripts=[inject_location])
Enter fullscreen mode Exit fullscreen mode

Developing with this is a pain super easy:

  • Make changes to your javascript file
  • tab to your slack and alt f4 it
    • make sure you have slack configured so it doesn't go run in the background
  • alt tab to your console, press up and enter to re-run the python script

For me the injector making F12 open devtools didn't work. Fortunately, slack has a built in /slackdevtools command

This was supposed to be a temporary solution for me while I made this mod. With the deadline on this blog coming up, I will make my own automatic injector sometime later.

Adding a Custom CSS section to the Preferences menu

Goal:

Eventually, I want the custom tab to be similar to Topaz's snippets section, where there is essentially a file picker editing/enabling/disabling individual css files
Topaz Snippets

But currently, I have no idea how to save a file, so the goal for today is something more like Goosemod's Custom CSS, which has one editor.
Goosemod Custom CSS Screen

Initial Plan

To add this new menu I first got the selector for tab list that I would be adding to. Looking at the HTML I determined it's p-prefs_dialog__menu class was a good option as it's short and not used elsewhere

image of the tab list selected

const settingsTabList = document.querySelector(".p-prefs_dialog__menu")
Enter fullscreen mode Exit fullscreen mode

From here, I wrote out the code for a basic test

const settingsTabList = document.querySelector(".p-prefs_dialog__menu")
// make a button
customTab = document.createElement("button")
// Set it's label
customTab.innerHTML = `<span>Custom Tab!</span>`
// add the button to the list we selected
settingsTabList.appendChild(customTab)
Enter fullscreen mode Exit fullscreen mode

I expected to get a result something like this

image of some options and then a poorly formatted Custom Tab!

Problem was literally nothing happened. No matter how many logs I added I couldn't even find when my code was running.

That code still should work in theory if everything is loaded, so lets put it in a function for later.

function addSettingsTab() {
    const settingsTabList = document.querySelector(".p-prefs_dialog__menu")
    customTab = document.createElement("button")
    customTab.innerHTML = `<span>Custom Tab!</span>`

    settingsTabList.appendChild(customTab)
}
Enter fullscreen mode Exit fullscreen mode

Working with DOM made entirely with JS

Turns out 100% of the HTML slack has is added by javascript. Because of this, getting any DOM related JS to actually work was a massive pain. This took hours to figure out and even longer to figure out how to explain.

To make sure we add the tab when the preferences screen is open. We could do so after the preferences button was clicked.

a button labeled Preferences with a css selector above it

// const preferencesButton = querySelector(selector)
preferencesButton.addEventListener("click", (event) => {
    addSettingsTab()
})
Enter fullscreen mode Exit fullscreen mode

But wait, this preferences button is also in a popout menu, we cant select it either!

To make sure the menu that contains this is open, we can make sure the user popout was hovered


// const userPopout = querySelector(selector)
userPopout.addEventListener("hover", (event) => {
    // const preferencesButton = querySelector(selector)
    preferencesButton.addEventListener("click", (event) => {
        addSettingsTab()
    })
})
Enter fullscreen mode Exit fullscreen mode

But wait, in some screens the user popout isn't shown!

You may be detecting a pattern here, we cant have any direct selectors to elements unless we know we are in a context that they exist.

There are probably hundreds of solutions to this, but here is the one I came up with.

// cant safely select any HTML, so select root
document.addEventListener("click", (event) => {
    // check if clicked element is the button that opens the preferences window
    let element = event.target 
    if (element.classList[0]=="c-menu_item__label" && element.innerHTML == "Preferences") {
        addSettingsTab()
    }
})
Enter fullscreen mode Exit fullscreen mode

That almost works, but there is one last technicality. Our injected code is runs before Slack's code.

Here is the effective order of things in pseudocode

injected code:
    document.click {
        clicked == preferencesButton {
            preferencesScreen.append(button)
        }
    })

slack's code:
    preferencesButton.click {
        make preferencesScreen
    })
Enter fullscreen mode Exit fullscreen mode

We try adding a button to the screen before its made!

We can fix this by adding an asynchronous wait command to our code run after the screen is made, fixing the issue.

// setTimeout(function to run, how long from now to run it in milliseconds)
setTimeout(function () { 
    if (document.querySelector(".p-prefs_dialog__menu") != null) {
        addSettingsTab()
    }
}, 500);
Enter fullscreen mode Exit fullscreen mode

Here is the "pseudocode" for that, the left pane is thread 1, the right pane is thread 2

oh god yeah no I am not explaining this

In summary for all of this, check if screen will be opened, wait so screen can open, edit screen.

Heres all that code in full

// cant safely select any HTML, so select root
document.addEventListener("click", (event) => {
    // check if clicked element is the button that opens our screen
    let element = event.target 
    if (element.classList[0]=="c-menu_item__label" && element.innerHTML == "Preferences") {
        // Our injected code is runs before Slack's code.
        // So the screen hasn't been made yet
        // Wait a short bit asynchronously so it can be made
        // Then add it.
        setTimeout(function () {
            if (document.querySelector(".p-prefs_dialog__menu") != null) {
                addSettingsTab()
            }
        }, 500);
    }
})
Enter fullscreen mode Exit fullscreen mode

Adding text editor into the tab

Heres the plan:

function addSettingsTab() {
    const settingsTabList = document.querySelector(".p-prefs_dialog__menu")
    customTab = document.createElement("button")
    customTab.innerHTML = `<span>Custom CSS</span>`
    // add class that make look good

    // onClick
    //     deselect old tab
    //     select new tab
    //     clear pane to the right
    //     add some kind of multiline text form to the pane

    settingsTabList.appendChild(customTab)
}
Enter fullscreen mode Exit fullscreen mode

Add class that make look good:

  • copy all classes from one of the other buttons
  • set the custom tab to have those
// add class that make look good
customTab.classList = "c-button-unstyled c-tabs__tab js-tab c-tabs__tab--full_width"
Enter fullscreen mode Exit fullscreen mode

I honestly didnt expect it to be that easy and thats why I made it it's own step.

the normal slack preferences tabs but with custom css at the bottom looking hot

Tabs being visually selected is dependent on if they have one class c-tabs__tab--active. So the process of deselecting/selecting should be something like this:

const activeClass = "c-tabs__tab--active"
// get old tab
let activeTab = settingsTabList.querySelector("."+activeClass)
// visually deselect old tab by removing class
activeTab.classList = // classList but without activeClass
// visually select new tab by adding class
customTab.classList = customTab.classList.toString() + " " + activeClass
Enter fullscreen mode Exit fullscreen mode

Removing the selected class at first seems pretty troubling because by default activeTab.classList is an array of some custom object, but calling activeTab.classList.toString() lets us just use .replace(stringToRemove, "")

That gives us the following:

// visually deselect old tab by removing class
activeTab.classList = activeTab.classList.toString().replace(activeClass+" ", "")
// visually select new tab by adding class
customTab.classList = customTab.classList.toString() + " " + activeClass
Enter fullscreen mode Exit fullscreen mode

That looks pretty good!
prior image but custom css is now selected

Making a text editor is super simple

// make the element
let cssEditor = document.createElement("textarea")
// size it 
cssEditor.setAttribute("rows", "33")
cssEditor.setAttribute("cols", "60")
Enter fullscreen mode Exit fullscreen mode

The rest of our current plan can be done in 1 line. Basically we just throw out whatever the old Preferences tab was showing and put in our textarea

// clear pane to the right
// add some kind of multiline text form to the pane
document.querySelector(".p-prefs_dialog__panel").replaceChildren(cssEditor)
Enter fullscreen mode Exit fullscreen mode

Now we have a text editor!

the slack preferences page, but with custom css selected and a empty text window

Actually using input as CSS

This stackoverflow answer is great for adding arbitrary CSS not specific to a node. The following is loosely based on it for our purposes:

// make a new style element for our custom CSS
let styleSheet = document.createElement("style")
// set default contents of Custom CSS
styleSheet.innerText = "/*Write Custom CSS here!*/"
// give it an id to make it easier to query
// the document for this stylesheet later
styleSheet.id = "SlackMod-Custom-CSS"
// add to head
document.head.appendChild(styleSheet)
Enter fullscreen mode Exit fullscreen mode

We cant just do styleSheet.innerText = new value because styleSheet is a static reference. Instead we query the document for the ID we gave it and then set the css from there:

document.querySelector("#SlackMod-Custom-CSS").innerText = newCSS;

To make this cleaner, I made 2 methods

// method to quickly change css
const updateCustomCSS = newCSS => { document.querySelector("#SlackMod-Custom-CSS").innerText = newCSS; }
// method to quickly get inner css
const getCustomCSS = () => { return document.querySelector("#SlackMod-Custom-CSS").innerText}
Enter fullscreen mode Exit fullscreen mode

Now we just need to make our cssEditor run these accordingly

// a big proper editor
let cssEditor = document.createElement("textarea")
cssEditor.setAttribute("rows", "33")
cssEditor.setAttribute("cols", "60")
// set content from current CSS
// on new chars added
    // update current CSS
Enter fullscreen mode Exit fullscreen mode

Setting content is easy

cssEditor.setAttribute("rows", "33")
cssEditor.setAttribute("cols", "60")
// set content from current CSS
cssEditor.value = getCustomCSS()
Enter fullscreen mode Exit fullscreen mode

Finding which event listener to detects when new characters are added to this editor was more difficult, but by reading through the list of all event listeners I found what I needed, the horribly named "input" event.

From it's description, its exactly what we need:

The input event fires when the value of an <input>, <select>, or <textarea> element has been changed.

But it's name is input, not inputChanged or something descriptive, just input.

Once we do know the horribly bad name for the event listener we need, making the css update in real time is easy:

// on new chars added
cssEditor.addEventListener("input", ()=>{
    // update current CSS
    updateCustomCSS(cssEditor.value)
})
Enter fullscreen mode Exit fullscreen mode

With that, now we are moving!
a screenshot of the preferences panel up, set to custom css, with some custom css making the background of the tabs red, and the background of the editor a darker gray

Now we can write css and see it update as we type!

Making CSS editor behave and look right

There are a few things preventing this custom CSS panel from feeling usable though.

  • tab kicks you out of the box instead of indenting you
  • the font isn't monospace
  • upon exiting and re-entering the screen all your newlines are gone
    • no idea why this happens or how to fix it

Fixing tabs kicking you out is easy, simply prevent default behavior of the tab key when you are in the css editor.

// make pressing tab add indent
cssEditor.addEventListener("keydown", (event) => {
    if (event.code == "Tab") {
        event.preventDefault();
    }
})
Enter fullscreen mode Exit fullscreen mode

I have no idea how to make it actually indent you though.

To fix the monospace font issue, I just changed the default value of our CSS field to be this instead of just /*Write Custom CSS here!*/

/*Write Custom CSS here!*/
.p-prefs_dialog__panel textarea {
   font-family: Monaco,Menlo,Consolas,Courier New,monospace!important;
}
Enter fullscreen mode Exit fullscreen mode

That brings the editor to a somewhat useable point

prior screenshot but with monospace font

I added a bit more css to make the editor nicer to use.

/*Write Custom CSS here!*/
/* Improve Legibility of Custom CSS Area */
.p-prefs_dialog__panel textarea {
    font-family: Monaco, Menlo, Consolas, CourierNew, monospace!important;
    font-size: 12px;
    /* Make editor fill Preferences panel */
    width: 100%; 
    height: calc(100% - 0.5rem);
    /* disable text wrapping */
    white-space: nowrap;
    /* make background of editor darker */
    background-color: #1c1c1c;
}
/* Increase width of Preferences to allow for more code width */
body > div.c-sk-modal_portal > div > div {
    max-width: 100%!important;
}
Enter fullscreen mode Exit fullscreen mode

prior screenshot but editor panel is significantly wider, with a slightly smaller font and darker background

Current Limitations

Currently, this mod is limited in several ways:

  • CSS does not save newlines
  • CSS does not persist between restarts
  • Injecting is manual and hardcoded to the user's system
    • Every time I have changed the javascript I am injecting throughout writing this blog I have
      • alt+f4'd slack
      • tabbed to my terminal
      • pressed up and enter to rerun python slack_launch.py
      • waited ~30 seconds
  • Selecting custom css in preferences then selecting a different category leads to an issue
  • No syntax highlighting

Normally I would delay release of the blog until all of these issues were fixed, but I have a deadline for releasing this one.

If you want to help solving these issues all the code discussed can be found at github.com/CodeF53/SlackMod. Otherwise, stay tuned for a followup where I fix these issues.

💖 💪 🙅 🚩
f53
F53

Posted on August 25, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Slack Mod: Improving the Editor
javascript Slack Mod: Improving the Editor

August 28, 2022

Adding a Custom CSS menu to Slack
javascript Adding a Custom CSS menu to Slack

August 25, 2022