Adding a Custom CSS menu to Slack
F53
Posted on August 25, 2022
Intro
A lot of people don't like how Discord looks/works, so there are many mods that add customizability to it:
- BetterDiscord
- PowerCord (now defunct)
- GooseMod (my personal favorite)
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])
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
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.
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
const settingsTabList = document.querySelector(".p-prefs_dialog__menu")
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)
I expected to get a result something like this
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)
}
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.
// const preferencesButton = querySelector(selector)
preferencesButton.addEventListener("click", (event) => {
addSettingsTab()
})
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()
})
})
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()
}
})
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
})
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);
Here is the "pseudocode" for that, the left pane is thread 1, the right pane is thread 2
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);
}
})
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)
}
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"
I honestly didnt expect it to be that easy and thats why I made it it's own step.
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
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
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")
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)
Now we have a text editor!
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)
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}
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
Setting content is easy
cssEditor.setAttribute("rows", "33")
cssEditor.setAttribute("cols", "60")
// set content from current CSS
cssEditor.value = getCustomCSS()
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)
})
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();
}
})
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;
}
That brings the editor to a somewhat useable point
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;
}
Current Limitations
Currently, this mod is limited in several ways:
- CSS does not save newlines
- CSS does not persist between restarts
- looking into Javascript file I/O, there is apparently no direct way to save a file to a user's system
- 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
- Every time I have changed the javascript I am injecting throughout writing this blog I have
- 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.
Posted on August 25, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.