// ==UserScript==
// @name Fimfiction Comment Tweaks
// @description Tweaks for Fimfiction comments
// @author Pluie
// @version 0.2.2
// @license MIT
// @homepageURL https://github.com/PluieElectrique/fimfic-comment-tweaks
// @supportURL https://github.com/PluieElectrique/fimfic-comment-tweaks/issues
// @match *://www.fimfiction.net/*
// @grant none
// @run-at document-idle
// @noframes
// @namespace https://greasyfork.org/users/307405
// ==/UserScript==
// Note about mobile: To be consistent with Fimfiction, this script detects mobile by using
// `is_mobile`, a global declared in an inline script in <head>. It seems detection of mobile
// browsers is done server side (probably through user agent).
"use strict";
let commentController;
let comment_list;
const QUOTE_LINK_HOVER_DELAY = 85;
const ctCSS = `
.ct--collapse-button { padding: 3px; }
.ct--collapsed-comment .author > .avatar { display: none; }
.ct--collapsed-comment .comment_callbacks > a { opacity: 0.7; }
.ct--collapsed-comment .comment_callbacks > div { display: none; }
.ct--collapsed-comment .comment_data { display: none; }
.ct--collapsed-comment .comment_information:after { height: 0; }
.ct--deleted-link { text-decoration: line-through; }
.ct--expanded-link { opacity: 0.7; }
.ct--forward-hidden { display: none; }
.ct--parent-link-highlight { text-decoration: underline; }
@media all and (min-width: 701px) {
.comment .data, .comment_information > .buttons { padding-right: 0.3rem; }
.inline-quote .meta > .name { display: inline; }
}
.embed-container .placeholder.hidden { display: none; }
`;
// A wrapper object that will be assigned onto the real comment controller
const commentControllerShell = {
// Map from comment ID to { author: string; index?: number; deleted?: boolean }
commentMetadata: {},
/* Methods that shadow existing methods */
getComment(id) {
let comment = document.getElementById("comment_" + id);
let promise;
if (comment === null) {
promise = CommentListController.prototype.getComment.call(this, id);
} else {
promise = Promise.resolve(comment);
}
// We always rewrite the comment in case there's new metadata that we didn't have before.
return promise.then(comment => {
let link = comment.querySelector(`a[href='#comment/${id}']`);
// An equivalent way of checking if a comment is deleted
if (link !== null) {
let meta = this.commentMetadata[id];
if (meta === undefined) {
// Remove "#" to avoid confusing comment IDs with comment indexes
link.textContent = link.textContent.replace("#", "");
} else {
// Rewrite comment index
link.textContent = formatCommentIndex(meta.index);
}
this.rewriteQuoteLinks(comment);
}
return comment;
});
},
setupQuotes() {
CommentListController.prototype.setupQuotes.call(this);
this.storeComments();
this.rewriteQuoteLinks(this.comment_list);
setupCollapseButtons();
},
goToPage(num) {
this.storeComments();
return CommentListController.prototype.goToPage.call(this, num).then(_ => {
if (is_mobile) {
let numComments = document.querySelector(".num-comments").textContent;
// There's a space before "Comments" for consistency with the Fimfiction HTML
document.querySelector(
".comments-header > .fa-comments"
).nextSibling.nodeValue = ` Comments ( ${numComments} )`;
}
});
},
beginShowQuote(quoteLink) {
// Just in case a mouseover event is triggered before the last mouseover's mouseout has
this.endShowQuote();
let cancel = false;
this.getComment(quoteLink.dataset.comment_id).then(comment => {
if (cancel) {
return;
}
let parent = fQuery.closestParent(quoteLink, ".comment");
let clone = cloneComment(comment);
markParentLink(parent, clone);
this.quote_container.appendChild(clone);
let parentRect = this.comment_list.getBoundingClientRect();
let style = this.quote_container.style;
style.top = quoteLink.getBoundingClientRect().bottom + fQuery.scrollTop() + 8 + "px";
style.left = parentRect.left - 6 + "px";
style.width = parentRect.width + 12 + "px";
App.DispatchEvent(this.quote_container, "loadVisibleImages");
});
return () => {
cancel = true;
};
},
endShowQuote() {
clearTimeout(this.hoverTimeout);
if (this.quote_container.firstChild !== null) {
removeElement(this.quote_container.firstChild);
}
},
expandQuote(quoteLink) {
let parent = fQuery.closestParent(quoteLink, ".comment");
// Don't expand parent links or links within collapsed comments
let linkStatus = getQuoteLinkStatus(quoteLink);
if (linkStatus.parentCollapsed || linkStatus.isParentLink) {
return;
}
this.endShowQuote();
// This probably causes a bug when two links to the same comment are expanded, and the
// bottom is collapsed. The top comment will disappear instead of the bottom. I don't think
// it's important enough to fix though.
let linkedId = quoteLink.dataset.comment_id;
let expandedComment = quoteLink.parentNode.querySelector(
`.comment[data-comment_id='${linkedId}']`
);
if (expandedComment === null) {
this.getComment(linkedId).then(comment => {
let clone = cloneComment(comment);
markParentLink(parent, clone);
clone.classList.add("inline-quote");
forwardHide(quoteLink, 1);
quoteLink.classList.add("ct--expanded-link");
if (!is_mobile && !isCommentDeleted(comment)) {
// Add middot after username in .meta to separate it from the index. On mobile,
// the username is `display: block;`, so we don't need a separator.
fQuery.insertAfter(clone.querySelector(".meta > .name"), createMiddot());
}
if (quoteLink.classList.contains("comment_callback")) {
// Search backwards through .comment_callbacks for the last quote link, and
// place this comment after it. This keeps quote links together at the top and
// orders expanded comments from most to least recently expanded.
let lastLink = quoteLink.parentElement.lastElementChild;
while (lastLink.tagName !== "A") {
lastLink = lastLink.previousElementSibling;
}
fQuery.insertAfter(lastLink, clone);
} else {
fQuery.insertAfter(quoteLink, clone);
}
});
} else {
// Update forward hiding counts for all expanded links
for (let expandedLink of expandedComment.querySelectorAll(".ct--expanded-link")) {
forwardHide(expandedLink, -1);
}
removeElement(expandedComment);
forwardHide(quoteLink, -1);
quoteLink.classList.remove("ct--expanded-link");
}
},
previous() {
if (this.current_page > 1) {
location.hash = `#page/${this.current_page - 1}`;
}
},
next() {
if (this.current_page < this.num_pages) {
location.hash = `#page/${this.current_page + 1}`;
}
},
/* Extra methods */
storeComments() {
for (let comment of this.comment_list.children) {
if (isCommentDeleted(comment)) {
this.commentMetadata[comment.dataset.comment_id] = {
author: comment.dataset.author,
deleted: true,
};
} else {
let link = comment.querySelector("a[href^='#comment/']");
this.commentMetadata[comment.dataset.comment_id] = {
author: comment.dataset.author,
index: Number(link.textContent.slice(1).replace(/,/g, "")),
};
}
}
},
rewriteQuoteLinks(elem) {
for (let quoteLink of elem.querySelectorAll(".comment_quote_link:not(.comment_callback)")) {
let id = quoteLink.dataset.comment_id;
let meta = this.commentMetadata[id];
if (meta !== undefined) {
if (meta.deleted) {
quoteLink.textContent = meta.author;
quoteLink.classList.add("ct--deleted-link");
} else if (this.comment_list.querySelector("#comment_" + id) === null) {
// Rewrite cross-page comments
quoteLink.textContent = `${meta.author} (${formatCommentIndex(meta.index)})`;
} else if (is_mobile) {
// On mobile, the prototype setupQuotes does nothing. So we have to rewrite all
// quote links
quoteLink.textContent = meta.author;
}
}
}
},
};
// Despite the @run-at option, the userscript is sometimes run before the Fimfiction JS, which
// causes errors. So, we wait for the page to be fully loaded.
if (document.readyState === "complete") {
init();
} else {
window.addEventListener("load", init);
}
function init() {
let storyComments = document.getElementById("story_comments");
if (storyComments === null) {
return;
}
let style = document.createElement("style");
style.textContent = ctCSS;
document.head.appendChild(style);
commentController = App.GetControllerFromElement(storyComments);
comment_list = commentController.comment_list;
Object.assign(commentController, commentControllerShell);
commentController.storeComments();
if (is_mobile) {
commentController.rewriteQuoteLinks(comment_list);
}
setupCollapseButtons();
setupEventListeners();
// quote_container is used by beginShowQuote to store the hovered quote (when there is one). In
// the original code, it's checked for on each call. Here, we create it at init.
if (commentController.quote_container === null) {
let container = document.createElement("div");
container.className = "quote_container";
document.body.appendChild(container);
commentController.quote_container = container;
}
}
function setupEventListeners() {
fQuery.addScopedEventListener(comment_list, ".ct--collapse-button", "click", evt =>
toggleCollapseCommentTree(fQuery.closestParent(evt.target, ".comment"))
);
let cancelCallback = null;
fQuery.addScopedEventListener(comment_list, ".comment_quote_link", "mouseover", evt => {
evt.stopPropagation();
// Mouseover events can sometimes be triggered on mobile, but there's no point. They
// just block the page.
if (is_mobile) {
return;
}
// Don't show popup quote for expanded links, links within collapsed comments, or links
// to the parent comment
let linkStatus = getQuoteLinkStatus(evt.target);
if (!linkStatus.isExpanded && !linkStatus.parentCollapsed && !linkStatus.isParentLink) {
commentController.hoverTimeout = setTimeout(() => {
cancelCallback = commentController.beginShowQuote(evt.target);
}, QUOTE_LINK_HOVER_DELAY);
}
});
fQuery.addScopedEventListener(comment_list, ".comment_quote_link", "mouseout", () => {
if (cancelCallback !== null) {
cancelCallback();
cancelCallback = null;
}
});
for (let binder of App.globalBinders) {
// These event listeners are "global binders." That is, they are added to each element that
// matches the selector. But because this binding is only done at load (and in a few other
// cases), and cloneNode does not copy event listeners, the listeners will not fire for
// expanded comments. We add a scoped listener (with ".inline-quote" prepended so that
// listeners don't fire twice) to fix this.
if (binder.class === "user_image_link" || binder.class === "youtube_container") {
fQuery.addScopedEventListener(
comment_list,
".inline-quote " + binder.selector,
binder.event,
binder.binder
);
} else if (binder.class === "embed-container") {
// Remove this listener. We replace it with a modified version below that hides
// .placeholder instead of removing it. This way, we can restore the .placeholder when
// cloning comments.
for (let elem of document.querySelectorAll(binder.selector)) {
elem.removeEventListener(binder.event, binder.binder);
}
// Disable the binder so we only have to remove listeners once
binder.selector = binder.class = "";
}
}
// Embed containers can occur outside of comments (e.g. in blog posts). So, we must scope this
// listener to the whole page.
fQuery.addScopedEventListener(document.body, ".embed-container", "click", evt => {
let elem = fQuery.closestParent(evt.target, ".embed-container");
if (elem.classList.contains("expanded")) {
return;
}
let cookieConsent = App.GetDependency("cookieConsent");
cookieConsent.requestConsent(["embed"]).then(
() => {
elem.classList.add("expanded");
elem.querySelector(".video").innerHTML = `<iframe src="${
elem.dataset.src
}" frameborder="0" allowfullscreen></iframe>`;
elem.querySelector(".placeholder").classList.add("hidden");
},
() => {
ShowErrorWindow("Cannot view embedded content without consenting to embed cookies");
}
);
});
}
function isCommentDeleted(comment) {
return (
comment.firstElementChild.classList.contains("message") &&
comment.lastElementChild.classList.contains("hidden")
);
}
function forwardHide(quoteLink, change) {
// Callbacks expand newer comments into older ones. So, in ASC order (oldest to newest), we
// forward hide when expanding callbacks. Non-callbacks expand older comments. So, in DESC order
// (newest to oldest), we forward hide when expanding non-callbacks.
let isCallback = quoteLink.classList.contains("comment_callback");
let isASC = commentController.order === "ASC";
if (isCallback !== isASC) {
return;
}
let comment = comment_list.querySelector("#comment_" + quoteLink.dataset.comment_id);
// Don't hide foreign comments
if (comment === null) {
return;
}
let newCount = Number(comment.dataset.expandCount || 0) + change;
if (newCount < 0) {
throw new Error("Expand count cannot be less than 0");
} else if (newCount === 0) {
comment.classList.remove("ct--forward-hidden");
} else if (newCount === 1) {
comment.classList.add("ct--forward-hidden");
}
comment.dataset.expandCount = newCount;
}
function setupCollapseButtons() {
for (let metaName of comment_list.querySelectorAll(".meta > .name")) {
fQuery.insertAfter(metaName, createMiddot());
let collapseButton = document.createElement("a");
collapseButton.classList.add("ct--collapse-button");
let minus = document.createElement("i");
minus.classList.add("fa", "fa-minus-square-o");
collapseButton.appendChild(minus);
fQuery.insertAfter(metaName, collapseButton);
}
}
function toggleCollapseCommentTree(comment) {
collapseCommentTree(comment, !comment.classList.contains("ct--collapsed-comment"));
}
function collapseCommentTree(comment, collapse) {
comment.classList.toggle("ct--collapsed-comment", collapse);
let collapseIcon = comment.querySelector(".ct--collapse-button > i");
collapseIcon.classList.toggle("fa-plus-square-o", collapse);
collapseIcon.classList.toggle("fa-minus-square-o", !collapse);
// We always collapse comments which appear later in the comment list. Exactly which quote links
// we search through depends on the sorting order.
let comment_id = comment.dataset.comment_id;
if (commentController.order === "ASC") {
// We are careful to not select any quote links in expanded comments
let quoteLinks = comment.querySelectorAll(`#comment_callbacks_${comment_id} > a`);
for (let quoteLink of quoteLinks) {
let nextComment = comment_list.querySelector(
"#comment_" + quoteLink.dataset.comment_id
);
collapseCommentTree(nextComment, collapse);
}
} else {
// There's no easy way to select the quote links in the .data of this comment and ignore
// links in expanded comments. It would require a :not(descendant of inline quote) selector,
// which isn't possible. So, we select callbacks which point to the current comment, and
// then get the comments which have those callbacks. This seems inefficient, but it only
// uses DOM lookups, and doesn't require extracting and storing data from the DOM, which I
// think would increase complexity.
let quoteLinks = comment_list.querySelectorAll(
`span[id^='comment_callbacks_'] > a[data-comment_id='${comment_id}']`
);
for (let quoteLink of quoteLinks) {
collapseCommentTree(fQuery.closestParent(quoteLink, ".comment"), collapse);
}
}
}
// Clone a comment and reset it
function cloneComment(comment) {
let clone = comment.cloneNode(true);
clone.removeAttribute("id");
// Needed for comment collapsing
let callbacks = clone.querySelector(".comment_callbacks");
if (callbacks !== null) {
callbacks.removeAttribute("id");
}
// Get rid of the blue highlight caused by clicking on the comment's index or posting date
clone.classList.remove("comment_selected");
// Remove quotes
for (let inlineQuote of clone.querySelectorAll(".inline-quote")) {
removeElement(inlineQuote);
}
// Remove ct classes. We don't remove parent-link-highlight because it's only applied to links
// in expanded comments. It should be fine to leave deleted-link.
clone.classList.remove("ct--forward-hidden");
clone.classList.remove("ct--collapsed-comment");
for (let expandedLink of clone.querySelectorAll(".ct--expanded-link")) {
expandedLink.classList.remove("ct--expanded-link");
}
// Remove middot and collapse button
let collapseButton = clone.querySelector(".ct--collapse-button");
if (collapseButton !== null) {
removeElement(collapseButton.nextElementSibling);
removeElement(collapseButton);
}
// Reset .embed-container
for (let embedContainer of clone.querySelectorAll(".embed-container")) {
embedContainer.classList.remove("expanded");
let frame = embedContainer.querySelector(".video").firstChild;
if (frame !== null) {
removeElement(frame);
}
embedContainer.querySelector(".placeholder").classList.remove("hidden");
}
return clone;
}
// Disable links to the parent comment to help prevent infinite nesting. Also highlight the link if
// there are other links in its section.
function markParentLink(parentComment, childComment) {
let parentId = parentComment.dataset.comment_id;
let linkToParent = childComment.querySelector(
`.comment_quote_link[data-comment_id='${parentId}']`
);
if (linkToParent !== null) {
// If there are other links in this quote link's section (comment data or callbacks), mark
// this link for visibility
let otherLink = fQuery
.closestParent(linkToParent, ".comment_data, .comment_callbacks")
.querySelector(`.comment_quote_link:not([data-comment_id='${parentId}'])`);
if (otherLink !== null) {
linkToParent.classList.add("ct--parent-link-highlight");
}
// This prevents the link from being expanded
linkToParent.dataset.parentLink = true;
}
}
function getQuoteLinkStatus(quoteLink) {
return {
isExpanded: quoteLink.classList.contains("ct--expanded-link"),
isParentLink: quoteLink.dataset.parentLink,
parentCollapsed: fQuery
.closestParent(quoteLink, ".comment")
.classList.contains("ct--collapsed-comment"),
};
}
// https://stackoverflow.com/a/2901298
function formatCommentIndex(index) {
return ("#" + index).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function createMiddot() {
let middot = document.createElement("b");
middot.textContent = "\u00b7";
return middot;
}
function removeElement(elem) {
elem.parentNode.removeChild(elem);
}