import debounce from "debounce"; import {Component, render} from "preact"; import {type UserLabelsData, saveUserLabels} from "../../storage/common.js"; import { createElementFromString, isColorBright, isValidHexColor, log, querySelectorAll, themeColors, } from "../../utilities/exports.js"; type Props = { anonymizeUsernamesEnabled: boolean; userLabels: UserLabelsData; }; type State = { color: string; selectedColor: string; hidden: boolean; id: number | undefined; priority: number; target: HTMLElement | undefined; text: string; username: string; }; const colorPattern: string = [ "^(?:#(?:", // (?:) are non-capturing groups. "[a-f\\d]{8}|", // The order of 8 -> 6 -> 4 -> 3 character hex colors matters. "[a-f\\d]{6}|", "[a-f\\d]{4}|", "[a-f\\d]{3})", "|transparent)$", // "Transparent" is also allowed in the input. ].join(""); export class UserLabelsFeature extends Component { constructor(props: Props) { super(props); const selectedColor = window .getComputedStyle(document.body) .getPropertyValue(themeColors[1].value) .trim(); this.state = { color: selectedColor, hidden: true, id: undefined, text: "", priority: 0, selectedColor, target: undefined, username: "", }; const count = this.addLabelsToUsernames(querySelectorAll(".link-user")); log(`User Labels: Initialized for ${count} user links.`); } hide = () => { this.setState({hidden: true}); }; addLabelsToUsernames = (elements: HTMLElement[], onlyID?: number): number => { const {userLabels} = this.props; const inTopicListing = document.querySelector(".topic-listing") !== null; // Sort the labels by priority or alphabetically, so 2 labels with the same // priority will be sorted alphabetically. const sortedLabels = userLabels.sort((a, b): number => { if (inTopicListing) { // If we're in the topic listing sort with highest priority first. if (a.priority !== b.priority) { return b.priority - a.priority; } } else if (a.priority !== b.priority) { // If we're not in the topic listing, sort with lowest priority first. // We will add elements backwards, so the first label will be // behind all the other labels. return a.priority - b.priority; } return b.text.localeCompare(a.text); }); for (const element of elements) { let username: string = element .textContent!.replace(/@/g, "") .toLowerCase(); if (this.props.anonymizeUsernamesEnabled) { username = element.dataset.trxUsername ?? username; } const userLabels = sortedLabels.filter( (value) => value.username.toLowerCase() === username && (onlyID === undefined ? true : value.id === onlyID), ); const addLabel = ( { this.addLabelHandler(event, username); }} > [+] ); if (!inTopicListing && onlyID === undefined) { const addLabelPlaceholder = document.createElement("span"); element.after(addLabelPlaceholder); render(addLabel, element.parentElement!, addLabelPlaceholder); } if (userLabels.length === 0 && onlyID === undefined) { if ( inTopicListing && (element.nextElementSibling === null || !element.nextElementSibling.className.includes("trx-user-label")) ) { const addLabelPlaceholder = document.createElement("span"); element.after(addLabelPlaceholder); render(addLabel, element.parentElement!, addLabelPlaceholder); } continue; } for (const userLabel of userLabels) { const bright = isColorBright(userLabel.color.trim()) ? "trx-bright" : ""; const label = createElementFromString(` ${userLabel.text} `); label.addEventListener("click", (event: MouseEvent) => { this.editLabelHandler(event, userLabel.id); }); element.after(label); label.setAttribute("style", `background-color: ${userLabel.color};`); // If we're in the topic listing, stop after adding 1 label. if (inTopicListing) { break; } } } return elements.length; }; addLabelHandler = (event: MouseEvent, username: string) => { event.preventDefault(); const target = event.target as HTMLElement; if (this.state.target === target && !this.state.hidden) { this.hide(); } else { const selectedColor = window .getComputedStyle(document.body) .getPropertyValue(themeColors[1].value) .trim(); this.setState({ hidden: false, target, username, color: selectedColor, id: undefined, text: "", priority: 0, selectedColor, }); } }; editLabelHandler = (event: MouseEvent, id: number) => { event.preventDefault(); const target = event.target as HTMLElement; if (this.state.target === target && !this.state.hidden) { this.hide(); } else { const label = this.props.userLabels.find((value) => value.id === id); if (label === undefined) { log( "User Labels: Tried to edit label with ID that could not be found.", true, ); return; } this.setState({ hidden: false, target, ...label, }); } }; colorChange = (event: Event) => { let color: string = (event.target as HTMLInputElement).value.toLowerCase(); if (!color.startsWith("#") && !color.startsWith("t") && color.length > 0) { color = `#${color}`; } if (color !== "transparent" && !isValidHexColor(color)) { log('User Labels: Color must be a valid hex color or "transparent".'); } // If the color was changed through the preset values, also change the // selected color state. if ((event.target as HTMLElement).tagName === "SELECT") { this.setState({color, selectedColor: color}); } else { this.setState({color}); } }; labelChange = (event: Event) => { this.setState({text: (event.target as HTMLInputElement).value}); }; priorityChange = (event: Event) => { this.setState({ priority: Number((event.target as HTMLInputElement).value), }); }; save = async (event: MouseEvent) => { event.preventDefault(); const {color, id, text, priority, username} = this.state; if (color === "" || username === "") { log("Cannot save user label without all values present."); return; } const {userLabels} = this.props; // If no ID is present then save a new label otherwise edit the existing one. if (id === undefined) { let newId = 1; if (userLabels.length > 0) { newId = userLabels.sort((a, b) => b.id - a.id)[0].id + 1; } userLabels.push({ color, id: newId, priority, text, username, }); this.addLabelsToUsernames(querySelectorAll(".link-user"), newId); } else { const index = userLabels.findIndex((value) => value.id === id); userLabels.splice(index, 1); userLabels.push({ id, color, priority, text, username, }); const elements = querySelectorAll(`[data-trx-label-id="${id}"]`); const bright = isColorBright(color); for (const element of elements) { element.textContent = text; element.setAttribute("style", `background-color: ${color};`); if (bright) { element.classList.add("trx-bright"); } else { element.classList.remove("trx-bright"); } } } await saveUserLabels(userLabels); this.props.userLabels = userLabels; this.hide(); }; remove = async (event: MouseEvent) => { event.preventDefault(); const {id} = this.state; if (id === undefined) { log("User Labels: Tried remove label when ID was undefined."); return; } const {userLabels} = this.props; const index = userLabels.findIndex((value) => value.id === id); if (index === undefined) { log( `User Labels: Tried to remove label with ID ${id} that could not be found.`, true, ); return; } for (const value of querySelectorAll(`[data-trx-label-id="${id}"]`)) { value.remove(); } userLabels.splice(index, 1); await saveUserLabels(userLabels); this.props.userLabels = userLabels; this.hide(); }; render() { const bodyStyle = window.getComputedStyle(document.body); const themeSelectOptions = themeColors.map(({name, value}) => ( )); const bright = isColorBright(this.state.color) ? "trx-bright" : ""; const hidden = this.state.hidden ? "trx-hidden" : ""; const {color, text: label, priority, selectedColor, username} = this.state; let top = 0; let left = 0; const target = this.state.target; if (target !== undefined) { const bounds = target.getBoundingClientRect(); top = bounds.y + bounds.height + 4 + window.scrollY; left = bounds.x + window.scrollX; } const position = `left: ${left}px; top: ${top}px;`; const previewStyle = `background-color: ${color}`; return (

{label}

); } }