1
Fork 0

Compare commits

..

3 Commits

10 changed files with 405 additions and 2 deletions

View File

@ -35,7 +35,7 @@ const sourceDir = toAbsolutePath("../source");
await fsp.mkdir(outDir, {recursive: true}); await fsp.mkdir(outDir, {recursive: true});
// Write the WebExtension manifest file. // Write the WebExtension manifest file.
const manifest = createManifest(browser); const manifest = createManifest(browser, dev);
await fsp.writeFile( await fsp.writeFile(
path.join(outDir, "manifest.json"), path.join(outDir, "manifest.json"),
JSON.stringify(manifest), JSON.stringify(manifest),

View File

@ -10,6 +10,7 @@ export * from "./miscellaneous/group-list-subscribe-button.js";
export * from "./miscellaneous/hide-own-username.js"; export * from "./miscellaneous/hide-own-username.js";
export * from "./miscellaneous/show-topic-author.js"; export * from "./miscellaneous/show-topic-author.js";
export * from "./miscellaneous/topic-info-ignore.js"; export * from "./miscellaneous/topic-info-ignore.js";
export * from "./miscellaneous/topic-log-enhancements.js";
export * from "./miscellaneous/unignore-all-button.js"; export * from "./miscellaneous/unignore-all-button.js";
export * from "./theme-switcher.js"; export * from "./theme-switcher.js";
export * from "./themed-logo.js"; export * from "./themed-logo.js";

View File

@ -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<HTMLOListElement>(
".topic-log-listing:not([data-trx-topic-log-enhancements])",
) ?? undefined;
if (listing === undefined) {
return 0;
}
// Open the topic log `<details>` element.
querySelector<HTMLDetailsElement>("details.topic-log").open = true;
const entries = listing.querySelectorAll<HTMLLIElement>(".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(
<TopicLogEnhancement
editedByAuthor={editedByAuthor}
editor={editor}
text={text}
timestamp={timestamp}
/>,
);
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 ? (
<a class="link-user" href={`/user/${editor}`}>
{editor}
</a>
) : (
"Unknown user"
);
const originalPosterSpan = editedByAuthor ? (
<>
(<abbr title="Original Poster (of the topic)">OP</abbr>)
</>
) : undefined;
const transformedText = transformText(text);
return (
<li class="topic-log-entry">
{editorLink} {originalPosterSpan} {transformedText.title} {timestamp}{" "}
<br /> {transformedText.body}
</li>
);
}
/** 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? (?<added>.+)$/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? (?<removed>.+)$/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) => (
<>
<span class="trx-topic-log-diff-from">
<a href={`${groupPath}?tag=${tag}`}>{tag}</a>
</span>
<br />
</>
)),
);
}
if (added !== undefined) {
const tags = added.replace(/'/g, "").split(", ");
spans.push(
...tags.map((tag) => (
<>
<span class="trx-topic-log-diff-to">
<a href={`${groupPath}?tag=${tag}`}>{tag}</a>
</span>
<br />
</>
)),
);
}
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} = /(?<from>http.+) to (?<to>http.+)$/g.exec(text)!.groups!;
return {
body: <DiffSpans from={from} to={to} />,
title: "edited the link",
};
}
// Check for title changes.
if (text.startsWith("changed title from")) {
const {from, to} = /"(?<from>.+)" to "(?<to>.+)"$/g.exec(text)!.groups!;
return {
body: <DiffSpans from={from} to={to} />,
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} = /(?<from>~.+) to (?<to>~.+)/g.exec(text)!.groups!;
return {
body: (
<>
From{" "}
<a class="link-group" href={`/${from}`}>
{from}
</a>{" "}
to{" "}
<a class="link-group" href={`/${to}`}>
{to}
</a>
</>
),
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 `<span>`s.
*/
function DiffSpans({from, to}: DiffSpansProps): JSX.Element {
const fromParts: Array<JSX.Element | string> = [];
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(<span class="trx-topic-log-diff-insert">{change}</span>);
break;
}
case fastDiff.DELETE: {
fromParts.push(<span class="trx-topic-log-diff-delete">{change}</span>);
break;
}
}
}
return (
<>
<span class="trx-topic-log-diff-from">{fromParts}</span>
<br />
<span class="trx-topic-log-diff-to">{toParts}</span>
</>
);
}

View File

@ -28,6 +28,7 @@ import {
runThemedLogoFeature, runThemedLogoFeature,
runShowTopicAuthorFeature, runShowTopicAuthorFeature,
runTopicInfoIgnore, runTopicInfoIgnore,
runTopicLogEnhancementsFeature,
runUnignoreAllButtonFeature, runUnignoreAllButtonFeature,
runUsernameColorsFeature, runUsernameColorsFeature,
runThemeSwitcherFeature, runThemeSwitcherFeature,
@ -229,6 +230,10 @@ async function initialize() {
runTopicInfoIgnore(); runTopicInfoIgnore();
} }
if (miscEnabled.value.has(MiscellaneousFeature.TopicLogEnhancements)) {
runTopicLogEnhancementsFeature();
}
if ( if (
miscEnabled.value.has(MiscellaneousFeature.UnignoreAllButton) && miscEnabled.value.has(MiscellaneousFeature.UnignoreAllButton) &&
isLoggedIn isLoggedIn

View File

@ -6,9 +6,13 @@ import {type Manifest} from "webextension-polyfill";
* Creates the WebExtension manifest based on the browser target. * Creates the WebExtension manifest based on the browser target.
* *
* @param browser The browser target ("firefox" or "chromium"). * @param browser The browser target ("firefox" or "chromium").
* @param dev Is this for development or production.
* @returns The WebExtension manifest. * @returns The WebExtension manifest.
*/ */
export function createManifest(browser: string): Manifest.WebExtensionManifest { export function createManifest(
browser: string,
dev: boolean,
): Manifest.WebExtensionManifest {
const manifest: Manifest.WebExtensionManifest = { const manifest: Manifest.WebExtensionManifest = {
manifest_version: Number.NaN, manifest_version: Number.NaN,
name: "Tildes ReExtended", name: "Tildes ReExtended",
@ -29,6 +33,13 @@ export function createManifest(browser: string): Manifest.WebExtensionManifest {
], ],
}; };
if (dev) {
// Add the localhost permissions in development so TRX can run on a locally
// hosted Tildes.
manifest.permissions!.push("*://localhost/*");
manifest.content_scripts![0].matches.push("https://*.localhost/*");
}
const icons: Manifest.IconPath = { const icons: Manifest.IconPath = {
128: "tildes-reextended.png", 128: "tildes-reextended.png",
}; };

View File

@ -56,6 +56,15 @@ function FeatureDescription({
); );
} }
if (feature === MiscellaneousFeature.TopicLogEnhancements) {
return (
<p class="description">
Enhance the topic log entries by adding text diffs, links and layout
adjustments. Also automatically expands the topic log.
</p>
);
}
if (feature === MiscellaneousFeature.UnignoreAllButton) { if (feature === MiscellaneousFeature.UnignoreAllButton) {
return ( return (
<p class="description"> <p class="description">

View File

@ -7,6 +7,7 @@
@import "scripts/markdown-toolbar"; @import "scripts/markdown-toolbar";
@import "scripts/show-topic-author"; @import "scripts/show-topic-author";
@import "scripts/topic-info-ignore"; @import "scripts/topic-info-ignore";
@import "scripts/topic-log-enhancements";
@import "scripts/user-labels"; @import "scripts/user-labels";
@import "scripts/username-colors"; @import "scripts/username-colors";

View File

@ -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);
}
}
}

View File

@ -26,6 +26,7 @@ export enum MiscellaneousFeature {
HideOwnUsername = "hide-own-username", HideOwnUsername = "hide-own-username",
ShowTopicAuthor = "show-topic-author", ShowTopicAuthor = "show-topic-author",
TopicInfoIgnore = "topic-info-ignore", TopicInfoIgnore = "topic-info-ignore",
TopicLogEnhancements = "topic-log-enhancements",
UnignoreAllButton = "unignore-all-button", UnignoreAllButton = "unignore-all-button",
} }

View File

@ -25,3 +25,10 @@ export async function hashSha256(input: string): Promise<string> {
.map((byte) => byte.toString(16).padStart(2, "0")) .map((byte) => byte.toString(16).padStart(2, "0"))
.join(""); .join("");
} }
/**
* Capitalize the input string by making the first character in it uppercase.
*/
export function capitalize(input: string): string {
return input[0].toUpperCase() + input.slice(1);
}