Fixing Twitter with a chrome extension

Aidan Breen
9 min readSep 29, 2017

--

I hate tall tweets. Those long columns of text that force you to scroll forever to pass them. I get it! You made your point. Now can we please move on?

Today, we’re going to build Squish. It’s on the Chrome Web Store if you want to install it. The code is also on github if you want to tinker with it.

If you want to build your own extension, check out my previous posts on building chrome extensions here and here!

Squish locates tall tweets and replaces the text with a button. No more scrolling!

What are we building exactly?

This extension is going to search through your twitter feed and look for tweets that contain a lot of newline characters*. Then, it will replace the tweet with a button that hides the text until it’s clicked.

*Newline characters are invisible characters that tell a program to jump down a line. When you press enter (or shift+enter if you don’t want to submit something) a newline character is inserted into the text. We reference a newline character in code by typing “\n” and “\r”. More on this later.

Challenges

First, we need to identify tall tweets in the content of our twitter feed. We can use a Content Script to do that.

Second, we need to insert a button that shows the text again if the user clicks it. However, Content Scripts can’t listen to user events like button clicks, so we’ll have to be smart about how we set that up by using Runtime Messaging and Code Injection.

Third, we want to let the user change the threshold for hiding tall tweets. Maybe to you, a tweet that is <50 lines is acceptable, but I want to hide tweets longer than 10 lines. For this, we will use the Storage API.

Building the extension

Manifest.json

The core of every chrome extension is the manifest file. This tells chrome which files to load and when (as well as all the metadata about our extension). I’ve introduced this before, so lets just talk about the specifics for this app:

//manifest.json 
...
"content_scripts": [
{
"matches": ["http://www.twitter.com/*",
"https://twitter.com/*"],
"js": ["jquery.js", "contentScript.js"],
"run_at": "document_end"
}
],
...

This block tells chrome that we want to run a content script on every page on twitter, and we want it to work over both http and https. It ensures the script runs when we want it to, and only when we want it to. Let’s not go screwing up peoples browsers willy nilly!

We identify two files in our project, jquery.js (for convenience) and contentScript.js (where all the magic happens!) to be run when any URL in “matches” is loaded.

“run_at” tells chrome to wait until the page has loaded before running our content script. There’s no point filtering out tweets that haven’t loaded yet!

//manifest.json 
...
"background": {
"scripts": ["background.js"],
"persistent": false
},
...

As we discuss later, the content script file has a number of restrictions. To get around these restrictions, we need to run a background script that can interact with normal extension APIs. This block tells chrome to run background.js as a backround script, but “persistent:false” means the script will only run when necessary (ie. when it receives a message, in this case). This saves memory and processing power.

//manifest.json
...
"permissions": [
"http://twitter.com/","https://twitter.com/","tabs","activeTab","storage"
],
...

This block allows our code to work on twitter, and also gives us access to the various APIs we will be using.

contentScript.js — Finding tall tweets

OK — let’s get into the meat of things. How do we identify a tweet that is tall? “\n” will help! Any text that has a bunch of “\n” (newlines) will be tall, so let’s look for those.

We included jquery.js earlier, so we can use $() to search for elements. A inspection of Twitter’s page source tells us that the content of a tweet is inside an element with the class tweet-text. Great! We can find each tweet element and the get the text with the following code:

//contentScript.js
//You can also run this in chrome's dev console...
$('.tweet-text').each(function(index){
var t = $(this).html();
});
//Inside the each() callback function, 'this' refers to the specific element.

Note, we’re using html(), not text() because text() may not handle newlines and whitespace properly! That would be bad.

t is now the text of a tweet! Now count the number of newlines:

var len = t.split(/\r\n|\r|\n/).length;

split() turns a string into an array of substrings. In this case, we’re using the regex /\r\n|\r|\n/ to split it each time it sees \r\nor \r or \n. The pipe character (|)means OR. The forward slashes start and end a regex (like double quotes start and end strings).

You might be wondering why we’re not just using /\n/ a our regex. In some cases, a newline is followed by, or preceded by a carriage return character, “\r”. This is another invisible character, and tells the program to move back to the start of the line. The term originates from typewriters, where the operator had to manually push the “carriage” back to the left after each new line. We could probably ignore it, but let’s keep it in to be safe.

So, if len is longer than, say, 10 we know this is a tall tweet! We know that number 10 is going to vary later, so let’s turn it into a variable called threshold that can be loaded from the user’s settings later. We now have:

//contentScript.js
$('.tweet-text').each(function(index){
var t = $(this).html();
var len = t.split(/\r\n|\r|\n/).length;
if(len > threshold){
//This is a long tweet!.
// we better do something!
}
});

So what should be do with the text? Let’s replace it with a button:

$(this).addClass("squished");
$(this).html(`<button class=”squish-button EdgeButton EdgeButton — primary” data-original-content=”${encodeURI(t)}”>Show Long Tweet</button>`);
if(!$(this).hasClass("squished") && len > threshold){

Ok, there’s a few things going on here:

  1. I’ve added the squished class to the tweet container. This allows us to restore the tweet later, and ignore it when the code runs again. This is achieved simply by editing the if statement expression above like so:
    if(!$(this).hasClass("squished") && len > threshold)
  2. I’ve added the squish-button class so we can access this button later, and add a click listener to it.
  3. By snooping around the inspector, I’ve noticed that twitter use the classes EdgeButton and EdgeButton — primary to style their buttons. We can use them too!
  4. data-original-content is a data attribute that we can access later. I’m using this to store the original text of the tweet so we can restore the tweet later. But, I’m using the encodeURI function to encode the text of the tweet…
    Why?
    This is not strictly necessary, but URI encoding the text replaces all special characters (like newlines, carriage returns etc.) with plain-text codes. It’s designed to let you pass information in URLs safely, but I’m using it here to make the output code more readable. If I didn’t do this, the value of data-original-content would also be super tall and take up loads of space. This way it’s a bit more compact and easier to read and inspect.

Infinite scrolling and new tweets

If you run this code you might find that it works for the tweets that loaded at the top of the page, but after you scroll down, newly loaded tweets will not be squished. Twitter uses AJAX to load new tweets as you scroll, which means that when our content script was run, these new tweets weren’t part of the page — and therefore don’t get squished.

We can handle this by binding an event listener to the DOMSubtreeModified event. This will call the attached function every time something changes inside the DOM which should let us re-run our code when new tweets are loaded!

$('#timeline').bind('DOMSubtreeModified.event1',DOMModificationHandler);

But there’s a catch…when we squish a tweet, we’re also modifying the DOM…which will call this function again, squishing the tweet again, and modifying the DOM again and… we get an infinite loop. The solution is to unbind the listener until we finish our changes, and then bind it again. IF we put the code from above into a function called modify(), the following code block binds, unbinds and rebinds for us.

function DOMModificationHandler(){
$(this).unbind('DOMSubtreeModified.event1');
setTimeout(function(){
modify();
$('#timeline').bind('DOMSubtreeModified.event1',
DOMModificationHandler);
},10);
}
$('#timeline').bind('DOMSubtreeModified.event1',
DOMModificationHandler);

Restoring Tall Tweets

Ok, so now we’ve hidden tall tweets! But what if we really do want to have a little look? We need to add a click listener to our button!

Content Scripts are a great way of changing a page, but they have some big limitations:

Content scripts execute in a special environment called an isolated world. They have access to the DOM of the page they are injected into, but not to any JavaScript variables or functions created by the page. It looks to each content script as if there is no other JavaScript executing on the page it is running on. The same is true in reverse: JavaScript running on the page cannot call any functions or access any variables defined by content scripts.

This means no events! Which means we need to add a listener to our button by injecting code directly into the page. This is achieved by using the chrome.tabs.executeScript API. We can inject click listener code from a file like so:

chrome.tabs.executeScript(null, {file: "injectedScript.js"});

But once again, we find a limitation of Content Scripts…they can’t use the chrome.tabs.executeScript API! Ugh…now what?

It turns out Content Scripts can use the messaging API to communicate with a script that can do the injecting for us. That script is the Background Script! We send a message from the Content Script to the Background Script like so:

chrome.runtime.sendMessage({message: "listeners"}, 
function(response) {
});

And we can receive that message in the Background Script like so:

chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
if (request.message == "listeners"){
//add event handler for button click
chrome.tabs.executeScript(null, {file: "injectedScript.js"});
sendResponse({message: "OK"});//optional
}
});

So now when we want to add a click listener:

  1. We send a massage from the Content Script to the Background Script.
  2. Background Script injects code into the webpage.
  3. Injected code sets up our click listener.

The injected click listener

This code is actually super simple. All we want to do is replace the button with the origin text, which is stored in the button’s data-original-content attribute. Simple…but once again…there’s a catch.

Using Jquery, this is a super simple task, but because the page we injected our script into may not have Jquery loaded, we have to use plain old vanilla javascript.

var els = document.getElementsByClassName("squish-button");
for(var z = 0; z < els.length; z++) {
els[z].addEventListener('click', function(){
var c = decodeURI(this.getAttribute("data-original-content"));
this.parentNode.innerHTML = c;
});
}

It’s not difficult, it’s just not as straightforward as using Jquery.

The only tricky bit here is this.parentNode.innerHTML which actually removes this and replaces it with c, the decoded original content.

Note: When we encode the text to store it, we must remember to decode it to display it. Otherwise you end up with a long block of unreadable text!

User settings

Now things are really looking good. We can identify tall tweets, hide them and restore them when necessary…let’s think about letting the user customise the functionality by changing the threshold for squishing.

Our manifest.json file identifies the “default popup” as popup.html. This means that when the user clicks the extension icon in chrome, popup.html will be loaded into the little window.

popup.html is just like any other html file — it can include inline and external script and css files and have interactive forms, links, images — you name it!

I’ve included popup.js for storing and retrieving user settings, and mui.min.css (which I found here) to help give the settings form a little flair.

Here’s what the html form looks like:

//popup.html
<div class="mui-select">
<select id="len">
<option value="10">10 lines</option>
<option value="50">50 lines</option>
<option value="150">150 lines</option>
<option value="200">200 lines</option>
</select>
</div>
<button id="save"class="mui-btn mui-btn--raised">Save</button>

And here’s how we store user settings using the chrome.storage API :

//popup.js
// Saves options to chrome.storage

function save_options() {
var squishThreshold = document.getElementById('len').value;
chrome.storage.sync.set({
threshold: squishThreshold
}, function() {
// Update status to let user know options were saved.
var status = document.getElementById('status');
status.textContent = 'Saved.';
setTimeout(function() {
status.textContent = '';
}, 750);
});
}

We should also retrieve the users settings so the form displays the right value:

//popup.js
//retrieves user settings

function restore_options() {
chrome.storage.sync.get({
threshold: '50'
}, function(items) {
document.getElementById('len').value = items.threshold;
});
}

We want to store settings when the save button is clicked, and to retrieve settings when the page is loaded:

//popup.js
$(document).ready(function(){
restore_options();
});
document.getElementById('save').addEventListener('click',
save_options);

Using the user’s settings

We’ve managed to get the value for users’ settings above, and we can use the exact same approach to get it in our Content Script to update threshold:

//contentScript.jsvar threshold = 50; //if sync.get fails, we use 50 as a default.chrome.storage.sync.get({
threshold: '50'
}, function(items) {
threshold = items.threshold;
});

And that’s it!

Conclusion

Well, that wasn’t so difficult now was it? Ok, the functionality of Squish isn’t going to change the way we use the internet, but it’s a handy little extension you can install and forget about.

If you enjoyed this article, consider sharing it. You’ll enjoy my others here and here.

If you enjoyed the extension (or hated it…), please let me know. I’d love to hear from you.

--

--