A couple of days ago a user in chat asked us to spend more time in the queues. Kick starting my desire to get notifications for new items in the queues.
Reviewing the queues has been something I've wanted to do for a long time now. But I've never built a habit to check daily, and so I've wanted to use Simon Forsberg's Desktop Notifications for flags and queue items user script for a while now. The only problem is that it doesn't work for me, and given the design changes SE has recently made, it's only more broken.
Originally I planned to fix Simon's user script, however my pre-ES 2015 JavaScript skills are not great. And so I could barely understand Simon's code never mind fix the issues I was having.
To use the script:
- Install it like you would any other user script.
- Navigate to Code Review's review page.
- If you haven't enabled notifications it should prompt you to allow them.
If it does not prompt you then you can enable them by pressing the options button to the left hand side of the URL. - Reload the page.
I feel my code doesn't really follow YAGNI as I have implemented a lot of classes. And currently Notifications
is fairly redundant. I think this is acceptable as later on when I've not read the user script for a while all the helpful names and short docstrings should point me in the right direction. Knowing what Review.title()
means is, to me, far easier than understanding what foo.children[1].children[0].children[0].innerText
is.
The code is also as close to pure JS as I could reasonably get. This means anyone with any compliant browser and any userscript plugin should be able to run the code. Meaning there's no accidental Greasemonkey/Tampermonkey/Violentmonkey incompatibilities.
I think I've followed JavaScript's conventions, however I've not written any in a short while.
Any and all reviews are welcome.
// ==UserScript==
// @name Review Notifications
// @namespace Violentmonkey Scripts
// @match *://*.stackexchange.com/review
// @grant none
// @version 1.0
// @author Peilonrayz
// @description Get notifications for new review items.
// ==/UserScript==
// Enum of push states
const Status = Object.freeze({
NOT_IMPLEMENTED: 1,
DENIED: 2,
GRANTED: 3,
DEFAULT: 4,
UNKNOWN: 5,
});
function pushStatus(status) {
// Convert from a string into the enum's value
if (status === undefined) {
return Status.NOT_IMPLEMENTED;
}
if (status === "denied") {
return Status.DENIED;
}
if (status === "granted") {
return Status.GRANTED;
}
if (status === "default") {
return Status.DEFAULT;
}
return Status.UNKNOWN;
}
class PushNotification {
// A small wrapper to Notification to expose the observer interface
static async enable() {
// Enable notifications if possible
//
// This returns the current state regardless if notifications are enabled.
// This returns if enabled and errors if it is not possible.
// This allows simple and clean usage with await or `then`.
let status = pushStatus(Notification.permission);
// Prompt user to allow us to push notifications
if (status === Status.DEFAULT) {
status = pushStatus(await Notification.requestPermission());
}
if (status === Status.GRANTED) {
return status;
}
throw status;
}
notify(notification) {
// Push a notification to the system
new Notification(notification.title, notification);
}
}
class Notifications {
// Holds a collections of observers that we can push notifications to.
//
// This is here as I may want to implement additional observers.
// For example alerting if push notifications are unavailable is an option.
// This allows easy additions without having to change more than just the
// creation of objects.
constructor() {
this.clients = [];
}
add(client) {
// Add an observer to the subject
this.clients.push(client);
}
notify(notification) {
// Notify all observers
for (const observer of this.clients) {
observer.notify(notification);
}
}
}
class Review {
// Interface to the underlying review information
constructor(object) {
this.object = object;
}
static* findAll() {
// Get all reviews on the current page.
for (let review of $("#content")[0].children[1].children[0].children) {
if (!review.className.contains("grid")) {
continue;
}
yield new Review(review);
}
}
amount() {
// Get the current amount of reviews.
return +this.object.children[0].children[0].innerText;
}
title() {
// Get the queue's title
return this.object.children[1].children[0].children[0].innerText;
}
}
function split_once(value, sep) {
// Helper function to only split by a seperator once.
//
// This is not the same as `"abc def ghi".split(" ", 2)` as that would
// result in ["abc", "def"] rather than ["abc", "def ghi"]
let index = value.indexOf(sep);
if (index === -1) {
return [value, ""];
}
return [value.slice(0, index), value.slice(index + sep.length)]
}
class Cookies {
// A map like interface to the cookies.
constructor() {
this.cookies = new Map();
this.update();
}
update() {
// Update the internal map from the cookies on the page.
//
// This is useful when other code on the page changes the cookies but
// not through this object.
this.cookies = new Map(document.cookie.split(';').map(c => split_once(c.trim(), "=")));
}
get(key) {
// Get the value of the cookie by its name
return this.cookies.get(key);
}
set(key, value) {
// Set a cookie to the provided value
this.cookies.set(key, value);
document.cookie = key + "=" + value;
}
}
function findReviews(notifications) {
// Find and notify the user about new reviews.
//
// 1. This is provided a fully initialized Notifications object.
// 2. Initialize a Cookies object to allow comparisions with the previous
// page load. This is important as otherwise the code would
// continuously notify users of all active reviews.
// 3. For each review on the page:
// 1. Verify if there are new reviews - comparing with the cookie.
// 2. Notify the user if there is a new review.
// 3. Update the cookie to the new value.
// 4. Reload the page.
let cookies = new Cookies();
for (let review of Review.findAll()) {
let prev = cookies.get(review.title());
let prevAmount = prev === undefined ? 0 : +prev;
console.log(review.title(), prevAmount, "->", review.amount(), prevAmount < review.amount());
if (prevAmount < review.amount()) {
notifications.notify({
"title": review.amount() + " reviews in " + review.title(),
"icon": "https://cdn.sstatic.net/Sites/codereview/img/apple-touch-icon.png?v=0a72875519a4",
})
}
cookies.set(review.title(), review.amount());
}
setTimeout(function(){ window.location.reload(); }, 60 * 1000);
}
function main() {
// Build notifications and find reviews.
const notifications = new Notifications();
PushNotification.enable().then(
() => {
notifications.add(new PushNotification());
findReviews(notifications);
},
(status) => console.log("Can't notify status code", status),
);
}
main()