diff --git a/source/content-scripts/features/exports.ts b/source/content-scripts/features/exports.ts index 118a049..6efb691 100644 --- a/source/content-scripts/features/exports.ts +++ b/source/content-scripts/features/exports.ts @@ -10,6 +10,7 @@ export * from "./miscellaneous/group-list-subscribe-button.js"; export * from "./miscellaneous/hide-own-username.js"; export * from "./miscellaneous/show-topic-author.js"; export * from "./miscellaneous/topic-info-ignore.js"; +export * from "./miscellaneous/topic-log-enhancements.js"; export * from "./miscellaneous/unignore-all-button.js"; export * from "./theme-switcher.js"; export * from "./themed-logo.js"; diff --git a/source/content-scripts/features/miscellaneous/topic-log-enhancements.tsx b/source/content-scripts/features/miscellaneous/topic-log-enhancements.tsx new file mode 100644 index 0000000..82e9dda --- /dev/null +++ b/source/content-scripts/features/miscellaneous/topic-log-enhancements.tsx @@ -0,0 +1,315 @@ +import fastDiff from "fast-diff"; +import {type JSX, render} from "preact"; +import {capitalize, log, querySelector} from "../../../utilities/exports.js"; + +export function runTopicLogEnhancementsFeature(): void { + const count = applyEnhancements(); + if (count > 0) { + log(`Topic Log Enhancements: Applied to ${count} topic log entries.`); + } +} + +function applyEnhancements(): number { + // Get the topic log listing if there is one. + const listing = + document.querySelector( + ".topic-log-listing:not([data-trx-topic-log-enhancements])", + ) ?? undefined; + if (listing === undefined) { + return 0; + } + + // Open the topic log `
` element. + querySelector("details.topic-log").open = true; + + const entries = listing.querySelectorAll(".topic-log-entry"); + const enhancements: JSX.Element[] = []; + + for (const entry of Array.from(entries)) { + const editor = entry.querySelector(".link-user")?.textContent ?? undefined; + + // Grab the text of the topic log entry. + let text = entry.textContent ?? undefined; + if (text === undefined) { + log( + "Undefined topic log entry text encountered, this should be unreachable.", + true, + ); + continue; + } + + // Remove any excess whitespace. + text = text.replace(/\s+/g, " ").trim(); + + // Remove the editor's name too as we already have that. + text = text.slice(text.indexOf(" ") + 1); + + // Grab the timestamp and remove it from the text. + const timestampIndex = text.lastIndexOf("("); + const timestamp = text.slice(timestampIndex); + text = text.slice(0, timestampIndex - 1); + + // Account for the original poster making edits. + const editedByAuthor = text.startsWith("(OP) "); + if (editedByAuthor) { + text = text.slice("(OP) ".length); + } + + // Account for deleted and removed topics or users not having their names + // shown. + if (text.startsWith("user ")) { + text = text.slice("user ".length); + } + + // Create the enhanced entry and remove the original one. + enhancements.push( + , + ); + entry.remove(); + } + + listing.dataset.trxTopicLogEnhancements = "true"; + render(enhancements, listing); + return enhancements.length; +} + +/** Properties for the {@linkcode TopicLogEnhancement} component. */ +type TopicLogEnhancementProps = { + /** Whether the original topic author made the changes. */ + editedByAuthor: boolean; + + /** + * The username of the person making the change, can be undefined if no user + * link was in the entry. + */ + editor: string | undefined; + + /** The text body of the entry. */ + text: string; + + /** The timestamp of when the edit was made. */ + timestamp: string; +}; + +/** The topic log entry enhancement component. */ +function TopicLogEnhancement(props: TopicLogEnhancementProps): JSX.Element { + const {editedByAuthor, editor, text, timestamp} = props; + + const editorLink = editor ? ( + + {editor} + + ) : ( + "Unknown user" + ); + + const originalPosterSpan = editedByAuthor ? ( + <> + (OP) + + ) : undefined; + + const transformedText = transformText(text); + + return ( +
  • + {editorLink} {originalPosterSpan} {transformedText.title} {timestamp}{" "} +
    {transformedText.body} +
  • + ); +} + +/** The result of the {@linkcode transformText} function. */ +type TransformedText = { + /** The main body of the topic log entry. */ + body: JSX.Element | string; + + /** The title of the entry to be placed between the username and timestamp. */ + title: string; +}; + +/** Transform the topic log entry text into its respective enhanced version. */ +function transformText(text: string): TransformedText { + // Check for tag additions and removals. + if (text.startsWith("added tag") || text.startsWith("removed tag")) { + const sections = text.includes("' and removed tag") + ? // If the entry has both added and removed tags in the same line, split + // them in the middle so we can more easily match them. The extra colon + // in the split string is so we don't accidentally split on an " and " + // in a tag. + text.split("' and ") + : [text]; + + // Match the added tags using the first section, as it always comes first. + const {added} = + /^added tags? (?.+)$/g.exec(sections[0])?.groups ?? {}; + + // Match the removed tags using the second section first and if that + // doesn't exist, try using the first section. + const {removed} = + /^removed tags? (?.+)$/g.exec(sections[1] ?? sections[0]) + ?.groups ?? {}; + + // Collect all the diff spans into one array. + const spans: JSX.Element[] = []; + + // Get the topic's group path by removing the ID and slug from the pathname. + // So for example "/~group/id36/title_slug" becomes "/~group". + const groupPath = window.location.pathname.slice( + 0, + window.location.pathname.indexOf("/", 1), + ); + + if (removed !== undefined) { + const tags = removed.replace(/'/g, "").split(", "); + spans.push( + ...tags.map((tag) => ( + <> + + {tag} + +
    + + )), + ); + } + + if (added !== undefined) { + const tags = added.replace(/'/g, "").split(", "); + spans.push( + ...tags.map((tag) => ( + <> + + {tag} + +
    + + )), + ); + } + + return { + body: <>{spans}, + title: "edited the tags", + }; + } + + // Check for link changes. + if (text.startsWith("changed link from")) { + // The Tildes link topic schema only allows HTTP or HTTPS links so we can + // take advantage of that to match them more easily. + const {from, to} = /(?http.+) to (?http.+)$/g.exec(text)!.groups!; + return { + body: , + title: "edited the link", + }; + } + + // Check for title changes. + if (text.startsWith("changed title from")) { + const {from, to} = /"(?.+)" to "(?.+)"$/g.exec(text)!.groups!; + return { + body: , + title: "edited the title", + }; + } + + // Check for both the locking and unlocking of topics. + if (text.includes("locked comments")) { + const locked = text.startsWith("locked"); + return { + body: locked + ? "New comments can not be posted." + : "New comments can be posted again.", + title: `${locked ? "" : "un"}locked the topic`, + }; + } + + // Check for the topic group being changed. + if (text.startsWith("moved from")) { + const {from, to} = /(?~.+) to (?~.+)/g.exec(text)!.groups!; + return { + body: ( + <> + From{" "} + + {from} + {" "} + to{" "} + + {to} + + + ), + title: "moved the topic", + }; + } + + // Check for topic removals and un-removals. + if (text.includes("removed")) { + const removed = text.startsWith("removed"); + return { + body: removed + ? "The topic was removed by a site administrator." + : "The topic has been made available again.", + title: `${removed ? "" : "un-"}removed the topic`, + }; + } + + // Log any unhandled topic log entries and return the text as the body so the + // entry still shows up in a decent enough way. + log(`Unhandled topic log entry: ${text}`, true); + return {body: capitalize(text), title: ""}; +} + +/** Properties for the {@linkcode DiffSpans} component. */ +type DiffSpansProps = { + /** The original text string. */ + from: string; + + /** The resulting text string to compare against the original with. */ + to: string; +}; + +/** + * Calculate the diff between to strings using `fast-diff` and render the + * results as two ``s. + */ +function DiffSpans({from, to}: DiffSpansProps): JSX.Element { + const fromParts: Array = []; + const toParts: typeof fromParts = []; + + for (const [action, change] of fastDiff(from, to)) { + // eslint-disable-next-line default-case + switch (action) { + case fastDiff.EQUAL: { + fromParts.push(change); + toParts.push(change); + break; + } + + case fastDiff.INSERT: { + toParts.push({change}); + break; + } + + case fastDiff.DELETE: { + fromParts.push({change}); + break; + } + } + } + + return ( + <> + {fromParts} +
    + {toParts} + + ); +} diff --git a/source/content-scripts/setup.tsx b/source/content-scripts/setup.tsx index 2465f1b..0343e54 100644 --- a/source/content-scripts/setup.tsx +++ b/source/content-scripts/setup.tsx @@ -28,6 +28,7 @@ import { runThemedLogoFeature, runShowTopicAuthorFeature, runTopicInfoIgnore, + runTopicLogEnhancementsFeature, runUnignoreAllButtonFeature, runUsernameColorsFeature, runThemeSwitcherFeature, @@ -229,6 +230,10 @@ async function initialize() { runTopicInfoIgnore(); } + if (miscEnabled.value.has(MiscellaneousFeature.TopicLogEnhancements)) { + runTopicLogEnhancementsFeature(); + } + if ( miscEnabled.value.has(MiscellaneousFeature.UnignoreAllButton) && isLoggedIn diff --git a/source/options/components/miscellaneous.tsx b/source/options/components/miscellaneous.tsx index e504d81..893e3d8 100644 --- a/source/options/components/miscellaneous.tsx +++ b/source/options/components/miscellaneous.tsx @@ -56,6 +56,15 @@ function FeatureDescription({ ); } + if (feature === MiscellaneousFeature.TopicLogEnhancements) { + return ( +

    + Enhance the topic log entries by adding text diffs, links and layout + adjustments. Also automatically expands the topic log. +

    + ); + } + if (feature === MiscellaneousFeature.UnignoreAllButton) { return (

    diff --git a/source/scss/content-scripts.scss b/source/scss/content-scripts.scss index 86bd60e..c6b59c0 100644 --- a/source/scss/content-scripts.scss +++ b/source/scss/content-scripts.scss @@ -7,6 +7,7 @@ @import "scripts/markdown-toolbar"; @import "scripts/show-topic-author"; @import "scripts/topic-info-ignore"; +@import "scripts/topic-log-enhancements"; @import "scripts/user-labels"; @import "scripts/username-colors"; diff --git a/source/scss/scripts/_topic-log-enhancements.scss b/source/scss/scripts/_topic-log-enhancements.scss new file mode 100644 index 0000000..30afa6d --- /dev/null +++ b/source/scss/scripts/_topic-log-enhancements.scss @@ -0,0 +1,53 @@ +[data-trx-topic-log-enhancements] { + --entry-spacing: 4px; + + display: flex; + flex-direction: column; + gap: var(--entry-spacing); + + .topic-log-entry { + border-left: 1px solid var(--border-color); + margin: 0; + margin-top: var(--entry-spacing); + padding-left: var(--entry-spacing); + } + + .trx-topic-log-diff { + &-from, + &-to { + &::before { + align-items: center; + display: inline-flex; + font-family: monospace; + font-size: large; + font-weight: bold; + justify-content: center; + margin-right: var(--entry-spacing); + } + } + + &-from::before { + color: var(--error-color); + content: "-"; + } + + &-to::before { + color: var(--success-color); + content: "+"; + } + + &-delete, + &-insert { + font-weight: bold; + text-decoration: underline; + } + + &-delete { + color: var(--error-color); + } + + &-insert { + color: var(--success-color); + } + } +} diff --git a/source/storage/enums.ts b/source/storage/enums.ts index 975f628..0228d6b 100644 --- a/source/storage/enums.ts +++ b/source/storage/enums.ts @@ -26,6 +26,7 @@ export enum MiscellaneousFeature { HideOwnUsername = "hide-own-username", ShowTopicAuthor = "show-topic-author", TopicInfoIgnore = "topic-info-ignore", + TopicLogEnhancements = "topic-log-enhancements", UnignoreAllButton = "unignore-all-button", }