1
Fork 0
tildes-reextended/source/ts/utilities.ts

331 lines
8.9 KiB
TypeScript

import {browser, Manifest} from 'webextension-polyfill-ts';
export interface UserLabel {
color: string;
id: number;
text: string;
priority: number;
username: string;
}
export interface Settings {
data: {
hideVotes: {
comments: boolean;
topics: boolean;
ownComments: boolean;
ownTopics: boolean;
[index: string]: boolean;
};
knownGroups: string[];
latestActiveFeatureTab: string;
userLabels: UserLabel[];
version?: string;
};
features: {
autocomplete: boolean;
backToTop: boolean;
debug: boolean;
hideVotes: boolean;
jumpToNewComment: boolean;
markdownToolbar: boolean;
userLabels: boolean;
[index: string]: boolean;
};
}
export const defaultSettings: Settings = {
data: {
hideVotes: {
comments: true,
topics: true,
ownComments: true,
ownTopics: true
},
// If groups are added or removed from Tildes this does not necessarily need
// to be updated. There is a helper function available to update it whenever
// the user goes to "/groups", where all the groups are easily available. So
// scripts that use this should call that function when they are run.
knownGroups: [
'~anime',
'~arts',
'~books',
'~comp',
'~creative',
'~design',
'~enviro',
'~finance',
'~food',
'~games',
'~games.game_design',
'~games.tabletop',
'~health',
'~health.coronavirus',
'~hobbies',
'~humanities',
'~lgbt',
'~life',
'~misc',
'~movies',
'~music',
'~news',
'~science',
'~space',
'~sports',
'~talk',
'~tech',
'~test',
'~tildes',
'~tildes.official',
'~tv'
],
latestActiveFeatureTab: 'debug',
userLabels: []
},
features: {
autocomplete: true,
backToTop: true,
debug: false,
hideVotes: false,
jumpToNewComment: true,
markdownToolbar: true,
userLabels: true
}
};
// Keep a local variable here for the debug logging, this way we don't have to
// call `getSettings()` each time we potentially want to log something. It gets
// set each time `getSettings()` is called so it's always accurate.
let debug = true;
export async function getSettings(): Promise<Settings> {
const syncSettings: any = await browser.storage.sync.get(defaultSettings);
const settings: Settings = {
data: {...defaultSettings.data, ...syncSettings.data},
features: {...defaultSettings.features, ...syncSettings.features}
};
debug = settings.features.debug;
// If we're in development, force debug output.
if (getManifest().nodeEnv === 'development') {
debug = true;
}
return settings;
}
export async function setSettings(
newSettings: Partial<Settings>
): Promise<void> {
return browser.storage.sync.set(newSettings);
}
export function log(message: any, override = false): void {
let overrideStyle = '';
let prefix = '[TRX]';
if (override) {
prefix = '%c' + prefix;
overrideStyle = 'background-color: #dc322f; margin-right: 9px;';
}
if (debug || override) {
if (overrideStyle.length > 0) {
console.debug(prefix, overrideStyle, message);
} else {
console.debug(prefix, message);
}
}
}
// Helper function to convert kebab-case strings to camelCase ones.
// Primarily for converting element IDs to Object keys.
export function kebabToCamel(input: string): string {
let output = '';
for (const part of input.split('-')) {
output += part[0].toUpperCase();
output += part.slice(1);
}
return output[0].toLowerCase() + output.slice(1);
}
// The opposite of `kebabToCamel()`.
export function camelToKebab(input: string): string {
const uppercaseMatches: RegExpMatchArray | null = input.match(/[A-Z]/g);
// If there are no uppercase letters in the input, just return it.
if (uppercaseMatches === null) {
return input;
}
// Find all the indexes of where uppercase letters are.
const uppercaseIndexes: number[] = [];
for (const match of uppercaseMatches) {
const latestIndex: number =
typeof uppercaseIndexes[uppercaseIndexes.length - 1] === 'undefined'
? 0
: uppercaseIndexes[uppercaseIndexes.length - 1];
uppercaseIndexes.push(input.indexOf(match, latestIndex + 1));
}
// Convert each section up to the next uppercase letter to lowercase with
// a dash between each section.
let output = '';
let previousIndex = 0;
for (const index of uppercaseIndexes) {
output += input.slice(previousIndex, index).toLowerCase();
output += '-';
previousIndex = index;
}
output += input.slice(previousIndex).toLowerCase();
return output;
}
// This utility function should only be used in cases where we know for certain
// that the wanted element is going to exist.
export function querySelector<T extends Element>(selector: string): T {
return document.querySelector<T>(selector)!;
}
export function querySelectorAll<T extends Element>(selector: string): T[] {
const elements: T[] = [];
for (const element of document.querySelectorAll<T>(selector)) {
elements.push(element);
}
return elements;
}
export function createElementFromString<T extends Element>(input: string): T {
const template: HTMLTemplateElement = document.createElement('template');
template.innerHTML = input.trim();
return template.content.firstElementChild! as T;
}
export function isInTopicListing(): boolean {
return document.querySelector('.topic-listing') !== null;
}
export function getManifest(): {nodeEnv?: string} & Manifest.ManifestBase {
const manifest: Manifest.ManifestBase = browser.runtime.getManifest();
return {...manifest};
}
export function getCSSCustomPropertyValue(property: string): string {
return getComputedStyle(document.body).getPropertyValue(property);
}
// Adapted from https://stackoverflow.com/a/12043228/12251171.
export function isColorBright(color: string): boolean {
if (color.startsWith('#')) {
color = color.slice(1);
}
if (color.length === 4) {
color = color.slice(0, 3);
}
if (color.length === 8) {
color = color.slice(0, 6);
}
if (color.length === 3) {
color = color
.split('')
.map((value) => value.repeat(2))
.join('');
}
const red: number = Number.parseInt(color.slice(0, 2), 16);
const green: number = Number.parseInt(color.slice(2, 4), 16);
const blue: number = Number.parseInt(color.slice(4, 6), 16);
const brightness: number = 0.2126 * red + 0.7152 * green + 0.0722 * blue;
return brightness > 128;
}
export function appendStyleAttribute(element: Element, styles: string): void {
const existingStyles: string | null = element.getAttribute('style');
if (existingStyles === null) {
element.setAttribute('style', styles);
} else {
element.setAttribute('style', `${existingStyles} ${styles}`);
}
}
export function isValidHexColor(color: string): boolean {
// Overly verbose validation for 3/4/6/8-character hex colors.
if (
/^#[a-f\d]{6}$/i.exec(color) === null &&
/^#[a-f\d]{3}$/i.exec(color) === null &&
/^#[a-f\d]{8}$/i.exec(color) === null &&
/^#[a-f\d]{4}$/i.exec(color) === null
) {
return false;
}
return true;
}
export function flashMessage(message: string, error = false): void {
if (document.querySelector('.trx-flash-message') !== null) {
log(
`A flash message already exists, skipping requested one with message:\n${message}`
);
return;
}
const messageElement: HTMLDivElement = createElementFromString(
`<div class="trx-flash-message">${message}</div>`
);
if (error) {
messageElement.classList.add('trx-flash-error');
}
let isRemoved = false;
messageElement.addEventListener('click', (): void => {
messageElement.remove();
isRemoved = true;
});
document.body.append(messageElement);
setTimeout(() => {
messageElement.classList.add('trx-opaque');
}, 50);
setTimeout(() => {
if (!isRemoved) {
messageElement.classList.remove('trx-opaque');
setTimeout(() => {
messageElement.remove();
}, 500);
}
}, 5000);
}
// Validation copied from Tildes source code:
// https://gitlab.com/tildes/tildes/blob/master/tildes/tildes/schemas/user.py
export function isValidTildesUsername(username: string): boolean {
return (
username.length >= 3 &&
username.length <= 20 &&
/^[a-z\d]([a-z\d]|[_-](?![_-]))*[a-z\d]$/i.exec(username) !== null
);
}
// This function will update the saved known groups when we're in the Tildes
// group listing. Any script that uses the known groups should call this before
// running.
export async function extractAndSaveGroups(
settings: Settings
): Promise<Settings> {
if (window.location.pathname !== '/groups') {
return Promise.reject(new Error('Not in /groups.'));
}
const groups: string[] = [...document.querySelectorAll('.link-group')].map(
(value) => value.textContent!
);
settings.data.knownGroups = groups;
await setSettings(settings);
log('Updated saved groups.', true);
return settings;
}