// ==UserScript==
// @name Internet Roadtrip - Vote History
// @description Show the result of the last votes in neal.fun/internet-roadtrip
// @namespace me.netux.site/user-scripts/internet-roadtrip/vote-history
// @version 2.1.1
// @author Netux
// @license MIT
// @match https://neal.fun/internet-roadtrip/*
// @icon https://cloudy.netux.site/neal_internet_roadtrip/Vote%20History%20logo.png
// @grant GM_addStyle
// @grant GM.getValue
// @grant GM.setValue
// @run-at document-start
// @require https://cdn.jsdelivr.net/npm/[email protected]
// @noframes
// ==/UserScript==
(async () => {
const CSS_PREFIX = 'vh-';
const cssClass = (... names) => names.map((name) => `${CSS_PREFIX}${name}`).join(' ');
const cssProp = (name) => `--${CSS_PREFIX}${name}`;
const state = {
dom: {},
settings: {
increasedVisibility: false,
maxEntries: 8,
debug: {
honkers: false
}
}
};
for (const [key, defaultValue] of Object.entries(state.settings)) {
state.settings[key] = await GM.getValue(key, defaultValue);
}
function injectStylesheets() {
GM_addStyle(`
.${cssClass('vote-history')} {
position: fixed;
left: 10px;
top: 150px;
margin: 0;
padding: 0;
list-style: none;
color: white;
font-family: "Roboto", sans-serif;
font-size: 0.8rem;
user-select: none;
& .${cssClass('vote-history-entry')} {
margin: 0.5rem 0;
text-shadow: 1px 1px 2px black;
display: flex;
align-items: center;
.${cssClass('vote-history--increased-visibility')} & {
text-shadow: ${[[0, 1], [0, -1], [1, 0], [-1, 0]].map(([x, y]) => `${x}px ${y}px 2px rgba(0 0 0 / 50%)`)};
}
& .${cssClass('vote-history-entry__icon-container')} {
position: relative;
height: 12px;
aspect-ratio: 1;
margin-right: 0.5rem;
vertical-align: middle;
& > img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: inline-block;
}
& .${cssClass('vote-history-entry__icon-shadow')} {
opacity: .5;
scale: 1.5;
filter: blur(5px);
}
& .${cssClass('vote-history-entry__icon')} {
filter: invert(1);
z-index: 1;
}
}
& .${cssClass('vote-history-entry__time')} {
margin-left: 1ch;
font-size: 80%;
color: lightgrey;
}
}
}
`);
state.dom.voteHistoryEntriesFadeStyleEl = document.createElement('style');
document.head.appendChild(state.dom.voteHistoryEntriesFadeStyleEl);
}
function createSettingsTab() {
const tab = IRF.ui.panel.createTabFor(
{ ... GM.info, script: { ... GM.info.script, name: GM.info.script.name.replace('Internet Roadtrip - ', '') } },
{
tabName: 'Vote History',
style: `
.${cssClass('settings-tab-content')} {
& *, *::before, *::after {
box-sizing: border-box;
}
& .${cssClass('field-group')} {
margin-block: 1rem;
gap: 0.25rem;
display: flex;
align-items: center;
justify-content: space-between;
& input:is(:not([type]), [type="text"], [type="number"]) {
--padding-inline: 0.5rem;
width: calc(100% - 2 * var(--padding-inline));
min-height: 1.5rem;
margin: 0;
padding-inline: var(--padding-inline);
color: white;
background: transparent;
border: 1px solid #848e95;
font-size: 100%;
border-radius: 5rem;
}
}
}
`,
className: cssClass('settings-tab-content')
}
);
function makeFieldGroup({ id, label }, renderInput) {
const fieldGroupEl = document.createElement('div');
fieldGroupEl.className = cssClass('field-group');
const labelEl = document.createElement('label');
labelEl.textContent = label;
fieldGroupEl.appendChild(labelEl);
const inputEl = renderInput({ id });
fieldGroupEl.appendChild(inputEl);
return fieldGroupEl;
}
tab.container.append(
makeFieldGroup({ id: `${CSS_PREFIX}max-entries`, label: 'Past Votes Max Entries' }, ({ id }) => {
const inputEl = document.createElement('input');
inputEl.id = id;
inputEl.type = 'number';
inputEl.style.width = '10ch';
inputEl.value = state.settings.maxEntries;
inputEl.addEventListener('change', async () => {
const numberValue = parseInt(inputEl.value);
if (Number.isNaN(numberValue)) {
return;
}
state.settings.maxEntries = numberValue;
await saveSettings();
updateDomFromSettings();
});
return inputEl;
}),
makeFieldGroup({ id: `${CSS_PREFIX}increase-visiblity`, label: 'Increase Visibility' }, ({ id }) => {
const inputEl = document.createElement('input');
inputEl.id = id;
inputEl.type = 'checkbox';
inputEl.className = IRF.ui.panel.styles.toggle;
inputEl.checked = state.settings.increasedVisibility;
inputEl.addEventListener('change', async () => {
state.settings.increasedVisibility = inputEl.checked;
await saveSettings();
updateDomFromSettings();
});
return inputEl;
}),
);
}
async function setupDom() {
await IRF.vdom.container; // FIX(netux): required to avoid crashing other userscripts :s
const containerEl = await IRF.dom.container;
injectStylesheets();
createSettingsTab();
state.dom.voteHistoryContainerEl = document.createElement('ul');
state.dom.voteHistoryContainerEl.className = cssClass('vote-history');
containerEl.appendChild(state.dom.voteHistoryContainerEl);
updateDomFromSettings();
}
async function patch() {
const containerVDOM = await IRF.vdom.container;
containerVDOM.state.changeStop = new Proxy(containerVDOM.methods.changeStop, {
apply(ogChangeStop, thisArg, args) {
const currentChosen = args[1];
if (!thisArg.isChangingStop) {
const payload = { currentChosen, currentHeading: thisArg.currentHeading, currentOptions: thisArg.currentOptions, voteCounts: thisArg.voteCounts };
addVote(payload).catch((error) => {
console.error('Could not add vote to history:', payload, error);
})
}
return ogChangeStop.apply(thisArg, args);
}
});
}
async function saveSettings() {
for (const [key, value] of Object.entries(state.settings)) {
await GM.setValue(key, value);
}
}
function updateDomFromSettings() {
removeExcessVotes();
// TODO(netux): it'd be nice to be able to use CSS variables and counters here,
// but counter() does not work inside calc() :(
// See https://github.com/w3c/csswg-drafts/issues/1026
state.dom.voteHistoryEntriesFadeStyleEl.textContent = `
.${cssClass('vote-history')} {
${new Array(state.settings.maxEntries).fill(null).map((_, i) => `
.${cssClass('vote-history-entry')}:nth-child(${i + 1}) {
opacity: ${1.2 - Math.pow(1 - ((state.settings.maxEntries - i) / state.settings.maxEntries), 2)};
}
`).join('\n')}
}
`;
state.dom.voteHistoryContainerEl.classList.toggle(cssClass('vote-history--increased-visibility'), state.settings.increasedVisibility);
}
function removeExcessVotes() {
while (state.dom.voteHistoryContainerEl.childElementCount > Math.max(0, state.settings.maxEntries)) {
state.dom.voteHistoryContainerEl.lastElementChild.remove();
}
}
async function addVote({ currentChosen: vote, currentHeading: heading, currentOptions: options, voteCounts }) {
const resultsVDOM = await IRF.vdom.results;
const newEntryEl = document.createElement('li');
newEntryEl.className = cssClass('vote-history-entry');
let entryActionText = '?';
let entryIconSrc = null;
let entryIconRotation = 0;
switch (vote) {
case -2: {
entryActionText = 'HONK!';
entryIconSrc = '/internet-roadtrip/honk.svg'
break;
}
case -1: {
entryActionText = 'Seek Radio';
entryIconSrc = '/internet-roadtrip/skip.svg'
break;
}
default: {
entryIconSrc = '/internet-roadtrip/chevron-black.svg';
if (options[vote]) {
entryActionText = options[vote].description;
entryIconRotation = resultsVDOM.methods.getRotation(vote);
} else {
entryActionText = 'U-Turn';
entryIconRotation = 180;
}
break;
}
}
const voteIconEl = document.createElement('div');
voteIconEl.className = cssClass('vote-history-entry__icon-container');
if (entryIconRotation !== 0) {
voteIconEl.style.rotate = `${entryIconRotation}deg`;
}
newEntryEl.appendChild(voteIconEl);
const voteIconShadowImageEl = document.createElement('img');
voteIconShadowImageEl.className = cssClass('vote-history-entry__icon-shadow');
voteIconShadowImageEl.src = entryIconSrc;
voteIconEl.appendChild(voteIconShadowImageEl);
const voteIconImageEl = document.createElement('img');
voteIconImageEl.className = cssClass('vote-history-entry__icon');
voteIconImageEl.src = entryIconSrc;
voteIconEl.appendChild(voteIconImageEl);
const voteCount = voteCounts[vote];
const entryVotesText = voteCount != null
? `${voteCount} vote${voteCount === 1 ? '' : 's'}, ${Math.round(voteCount / Object.values(voteCounts).reduce((acc, votes) => acc + votes, 0) * 100)}%`
: 'no votes';
const entryTextNode = document.createTextNode(`${entryActionText} (${entryVotesText})`);
newEntryEl.appendChild(entryTextNode);
const entryTimeEl = document.createElement('span');
entryTimeEl.className = cssClass('vote-history-entry__time');
entryTimeEl.innerText = new Date().toLocaleTimeString();
newEntryEl.appendChild(entryTimeEl);
if (state.dom.voteHistoryContainerEl.childElementCount > 0) {
state.dom.voteHistoryContainerEl.insertBefore(newEntryEl, state.dom.voteHistoryContainerEl.firstChild);
} else {
state.dom.voteHistoryContainerEl.appendChild(newEntryEl);
}
removeExcessVotes();
}
if (typeof unsafeWindow !== 'undefined') {
unsafeWindow.DEBUG__addVoteToHistory = addVote;
}
await Promise.all([
setupDom(),
patch()
])
.then(async () => {
if (state.settings.debug?.honkers) {
for (let i = 0; i < state.settings.maxEntries; i++) {
const vote = -2;
await addVote({
currentChosen: vote,
currentHeading: 0,
currentOptions: { [vote]: { heading: 180 } },
voteCounts: { [vote]: 100, [9999]: 50 }
});
}
}
});
})();