1
Fork 0
tildes-reextended/source/content-scripts/features/user-labels.tsx

430 lines
12 KiB
TypeScript

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<Props, State> {
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 = (
<span
class="trx-user-label-add"
onClick={(event: MouseEvent) => {
this.addLabelHandler(event, username);
}}
>
[+]
</span>
);
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<HTMLSpanElement>(`<span
data-trx-label-id="${userLabel.id}"
class="trx-user-label ${bright}"
>
${userLabel.text}
</span>`);
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}) => (
<option value={bodyStyle.getPropertyValue(value).trim()}>{name}</option>
));
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 (
<form class={`trx-user-label-form ${hidden}`} style={position}>
<div class="trx-label-username-priority">
<label class="trx-label-username">
Add New Label
<input
type="text"
class="form-input"
placeholder="Username"
value={username}
required
/>
</label>
<label class="trx-label-priority">
Priority
<input
type="number"
class="form-input"
value={priority}
onChange={this.priorityChange}
required
/>
</label>
</div>
<div>
<label for="trx-label-color-input">Pick A Color</label>
<div class="trx-label-grid">
<input
id="trx-label-color-input"
type="text"
class="form-input"
placeholder="Color"
value={color}
onInput={debounce(this.colorChange, 250)}
pattern={colorPattern}
required
/>
<select
class="form-select"
value={selectedColor}
onChange={this.colorChange}
>
{themeSelectOptions}
</select>
</div>
</div>
<div>
<label for="trx-label-input">Label</label>
<div class="trx-label-grid">
<input
id="trx-label-input"
type="text"
class="form-input"
placeholder="Text"
value={label}
onInput={debounce(this.labelChange, 250)}
/>
<div class={`trx-label-preview ${bright}`} style={previewStyle}>
<p>{label}</p>
</div>
</div>
</div>
<div class="trx-label-actions">
<a class="btn-post-action" onClick={this.save}>
Save
</a>
<a class="btn-post-action" onClick={this.hide}>
Close
</a>
<a class="btn-post-action" onClick={this.remove}>
Remove
</a>
</div>
</form>
);
}
}