import { ExecutionContext } from "ava";
import { setStyles, selectAll, select } from "./util";
/*
* LipSurf plugin for Reddit.com
*/
///
declare const PluginBase: IPluginBase;
type Maybe = T | null;
const thingAttr = `${PluginBase.util.getNoCollisionUniqueAttr()}-thing`;
const COMMENTS_REGX = /reddit.com\/r\/[^\/]*\/comments\//;
let isOldReddit = false;
let scrollContainer: Maybe = null;
let observer: Maybe = null;
let posts: Maybe> = null;
let index = 0;
let isDOMLoaded = false;
let isHomeBtnClicked = false;
let currentRoute;
/**
* On new reddit we sometimes get an overlay when clicking the comments of a post,
* this ensures that we give priority to the selector on the overlayed post.
*/
function prioritizeCurPost(u: string): string[] {
return ["[data-test-id='post-content'] ", ""].map((s) => `${s}${u}`);
}
const reddit = {
old: {
post: {
thing: ".thing",
title: "a.title",
expandBtn: ".expando-button",
commentarea: ".commentarea",
},
comments: {
select: "a.comments",
expandBtn: ".expando-button",
comment: {
select: ".comment",
expandBtn: "a.expand",
},
},
special: {
collapsed: ".collapsed",
expanded: ".expanded",
notCollapsed: ":not(.collapsed)",
},
vote: {
up: ".arrow.up:not(.upmod)",
down: ".arrow.down:not(.downmod)",
upmod: ".arrow.upmod",
downmod: ".arrow.downmod",
},
},
latest: {
home: "a[aria-label='Home']",
post: {
thing: ".Post",
},
comments: {
select: "a[data-click-id='comments']",
threadline: ".threadline",
comment: {
select: ".Comment",
expandBtn: ".icon-expand",
},
},
vote: {
up: prioritizeCurPost(`.voteButton[aria-label='upvote']`),
down: prioritizeCurPost(`.voteButton[aria-label='downvote']`),
pressed: prioritizeCurPost(`.voteButton[aria-pressed='true']`),
unpressed: prioritizeCurPost(`.voteButton[aria-pressed='false']`),
},
},
};
function thingAtIndex(i: number): string {
if (isOldReddit) {
return `${reddit.old.post.thing}[${thingAttr}="${i}"]`;
} else {
return `${reddit.latest.post.thing}[${thingAttr}="${i}"]`;
}
}
function waitPosts(fn, timeout) {
let timer: Maybe> = null;
setTimeout(() => {
posts = selectAll(reddit.latest.post.thing);
if (posts && posts.length) {
clearTimeout(timer!);
setTimeout(fn);
} else {
timer = setTimeout(() => {
waitPosts(fn, timeout);
}, timeout);
}
});
}
function clickIfExists(...selectors: string[]) {
for (const sel of selectors) {
const el = select(sel);
if (el) {
el.click();
break;
}
}
}
function clickIfDisplayed(el: HTMLElement) {
if (parseFloat(getComputedStyle(el).width)) {
el.click();
}
}
function genPostNumberElement(n: number): HTMLElement {
const span = document.createElement("span");
span.textContent = "" + n;
span.className = "post-number";
return span;
}
function addRedditAPostsAttributes(
posts: NodeListOf,
isOld: boolean
) {
if (isOld) {
return posts.forEach((post) => {
index += 1;
post.setAttribute(thingAttr, `${index}`);
const oldRank = select(".rank", post)!;
const newRank = genPostNumberElement(index);
setStyles({ position: "relative" }, post);
setStyles({ visibility: "hidden" }, oldRank);
setStyles(
{
position: "absolute",
top: "20px",
left: "10px",
fontWeight: 700,
fontSize: "1rem",
color: "#999999",
transform: "translateY(-50%)",
},
newRank
);
post.appendChild(newRank);
});
} else {
posts.forEach(setAttributes);
}
}
function setAttributes(post: HTMLElement) {
if (!post) return;
const postNum = select(".post-number", post)?.textContent;
if (postNum) index = +postNum;
if (post && getComputedStyle(post).display !== "none" && !postNum) {
index += 1;
post.setAttribute(thingAttr, `${index}`);
setStyles(
{
position: "relative",
overflow: "visible",
},
post
);
if (!COMMENTS_REGX.test(window.location.href)) {
const el = genPostNumberElement(index);
setStyles(
{
position: "absolute",
top: "0",
right: "102%",
fontWeight: 700,
opacity: 0.8,
},
el
);
post.appendChild(el);
}
}
}
function observerCallback(mutationList) {
const { old, latest } = reddit;
const postSelector = isOldReddit ? old.post.thing : latest.post.thing;
mutationList.forEach((it) => {
it.addedNodes.forEach((node) => {
const post = select(postSelector, node);
setAttributes(post!);
});
});
}
function createObserver(el: Element) {
observer = new MutationObserver(observerCallback);
observer.observe(el!, { childList: true });
}
function setParentContainer(posts: NodeListOf): Maybe {
return posts?.[0]?.parentNode?.parentNode?.parentNode || null;
}
type VoteType = "up" | "down" | "clear";
function vote(type: VoteType, index?: number) {
const { old, latest } = reddit;
let selectors: string[] = [];
console.log("voting", type);
if (type === "clear") {
const thing = index && thingAtIndex(index);
if (index && isOldReddit) {
selectors = [`${thing} ${old.vote.downmod}, ${thing} ${old.vote.upmod}`];
} else if (!index && isOldReddit) {
selectors = [`${old.vote.downmod},${old.vote.upmod}`];
} else if (index && !isOldReddit) {
selectors = latest.vote.pressed.map((s) => `${thing} ${s}`);
} else {
selectors = latest.vote.pressed;
}
} else {
selectors = isOldReddit ? [old.vote[type]] : latest.vote[type];
if (index) selectors = selectors.map((s) => `${thingAtIndex(index)} ${s}`);
}
clickIfExists(...selectors);
}
function getCollapseBtnSelector() {
const { post, special, comments } = reddit.old;
const { comment } = comments;
const oldCommentBtnSelector = `${comment.select}${special.notCollapsed} ${comment.expandBtn}`;
const newCommentBtnSelector = reddit.latest.comments.threadline;
return {
postExpBtn: isOldReddit ? `${post.expandBtn}${special.expanded}` : "",
comExpBtn: isOldReddit ? oldCommentBtnSelector : newCommentBtnSelector,
};
}
function getExpandableElementsSelectors() {
const { comments, special, post } = reddit.old;
const selectors = {
comExpBtn: "",
postExpBtn: "",
comment: "",
};
if (isOldReddit) {
selectors.comExpBtn = comments.comment.expandBtn;
selectors.postExpBtn = `${post.thing} ${comments.expandBtn}`;
selectors.comment = `${comments.comment.select}${special.collapsed}`;
} else {
selectors.comment = reddit.latest.comments.comment.select;
selectors.comExpBtn = reddit.latest.comments.comment.expandBtn;
}
return selectors;
}
async function expandCurrent() {
// if expando-button is in frame expand that, otherwise expand first (furthest up) visible comment
const { postExpBtn, comExpBtn, comment } = getExpandableElementsSelectors();
const mainItem =
(!!postExpBtn && select(postExpBtn)) || null;
if (mainItem && PluginBase.util.isVisible(mainItem)) mainItem.click();
else {
const itemsSelector = isOldReddit ? comment : comExpBtn;
let el: HTMLElement;
const items = Array.from(selectAll(itemsSelector));
for (el of items.reverse()) {
if (PluginBase.util.isVisible(el)) {
if (isOldReddit) {
return select(comExpBtn, el)!.click();
} else {
return clickIfDisplayed(el.parentNode as HTMLElement);
}
}
}
}
}
async function expandAll() {
const { comment, comExpBtn } = getExpandableElementsSelectors();
const selector = isOldReddit ? `${comment} ${comExpBtn}` : comExpBtn;
for (let el of selectAll(selector)) {
if (isOldReddit) {
el.click();
} else {
clickIfDisplayed(el.parentNode as HTMLElement);
}
}
}
function collapseCurrent() {
const { postExpBtn, comExpBtn } = getCollapseBtnSelector();
const postBtn = (!!postExpBtn && select(postExpBtn!)) || null;
const commentBtns = selectAll(comExpBtn);
postBtn && PluginBase.util.isVisible(postBtn!) && postBtn!.click();
for (const el of commentBtns) {
if (PluginBase.util.isVisible(el)) {
el.click();
break;
}
}
}
function resetDomState() {
index = 0;
isDOMLoaded = false;
scrollContainer = null;
observer?.disconnect();
observer = null;
}
function onLoad() {
currentRoute = window.location.href;
const { old, latest } = reddit;
isOldReddit = !!select(old.post.thing);
if (isDOMLoaded) return;
const postSelector = isOldReddit ? old.post.thing : latest.post.thing;
posts = selectAll(postSelector);
isDOMLoaded = true;
addRedditAPostsAttributes(posts, isOldReddit);
if (!isOldReddit) {
const home = select(reddit.latest.home);
window.addEventListener("click", onClick);
home?.addEventListener("click", homeClickHandler);
scrollContainer = setParentContainer(posts);
scrollContainer && createObserver(scrollContainer! as Element);
}
}
function redefinePosts() {
waitPosts(() => {
if (window.location.hostname.endsWith("reddit.com")) {
isDOMLoaded = false;
index = 0;
onLoad();
toggleContext(COMMENTS_REGX.test(window.location.href));
} else {
PluginBase.util.removeContext("Post List", "Post");
}
}, 200);
}
function onPopState() {
setTimeout(redefinePosts);
}
function onClick() {
if (!isHomeBtnClicked && currentRoute === window.location.href) return;
setTimeout(() => {
if (isHomeBtnClicked) isHomeBtnClicked = false;
redefinePosts();
currentRoute = window.location.href;
});
}
function homeClickHandler() {
isHomeBtnClicked = true;
}
function toggleContext(isPostContext = false) {
console.log(isPostContext, "post context");
if (isPostContext) {
PluginBase.util.prependContext("Post");
PluginBase.util.removeContext("Post List");
} else {
PluginBase.util.prependContext("Post List");
PluginBase.util.removeContext("Post");
}
}
function dispatchEvent(eventName: string) {
const event = new Event(eventName);
window.dispatchEvent(event);
}
export default {
...PluginBase,
...{
niceName: "Reddit",
description: "Commands for Reddit.com",
version: "4.10.0",
apiVersion: 2,
match: /^https?:\/\/.*\.reddit.com/,
authors: "Miko, Anar",
// less common -> common
homophones: {
navigate: "go",
contract: "collapse",
claps: "collapse",
expense: "expand",
explain: "expand",
expanding: "expand",
"expand noun": "expand 9",
"it's been": "expand",
expanse: "expand",
expanded: "expand",
stand: "expand",
xpand: "expand",
xmen: "expand",
spend: "expand",
span: "expand",
spell: "expand",
spent: "expand",
"reddit dot com": "reddit",
"read it": "reddit",
shrink: "collapse",
advert: "upvote",
download: "downvote",
commence: "comments",
},
contexts: {
"Post List": {
commands: [
"View Comments",
"Visit Post",
"Expand",
"Collapse",
"Upvote",
"Downvote",
"Clear Vote",
],
},
Post: {
commands: [
"Upvote Current",
"Downvote Current",
"Clear Vote Current",
"Visit Current",
"Expand Current",
"Collapse Current",
"Expand All Comments",
],
},
},
init: async () => {
if (window.location.hostname.endsWith("reddit.com")) {
console.log("init");
toggleContext(COMMENTS_REGX.test(window.location.href));
window.addEventListener("load", onLoad);
window.addEventListener("popstate", onPopState);
await PluginBase.util.ready();
setTimeout(() => {
if (!isDOMLoaded) dispatchEvent("load");
}, 2000);
}
},
destroy: () => {
resetDomState();
PluginBase.util.removeContext("Post List", "Post");
window.removeEventListener("load", onLoad);
!isOldReddit && window.removeEventListener("popstate", onPopState);
!isOldReddit && window.removeEventListener("click", onClick);
},
commands: [
{
name: "Go to Reddit",
global: true,
match: ["[/go to ]reddit"],
minConfidence: 0.5,
pageFn: () => {
document.location.href = "https://www.reddit.com";
},
},
{
name: "Go to Subreddit",
match: {
fn: ({ normTs, preTs }) => {
const SUBREDDIT_REGX = /\b(?:go to |show )?(?:are|our|r) (.*)/;
let match = preTs.match(SUBREDDIT_REGX);
if (match) {
const endPos = match.index! + match[0].length;
return [match.index, endPos, [match[1].replace(/\s/g, "")]];
}
},
description: "go to/show r [subreddit name] (do not say slash)",
},
isFinal: true,
nice: (transcript, matchOutput: string) => {
return `go to r/${matchOutput}`;
},
pageFn: (transcript, subredditName: string) => {
window.location.href = `https://www.reddit.com/r/${subredditName}`;
},
},
{
name: "View Comments",
description: "View the comments of a reddit post.",
match: ["comments #", "# comments"],
normal: false,
pageFn: (transcript, index: number) => {
const selector = isOldReddit
? ` ${reddit.old.comments.select}`
: ` ${reddit.latest.comments.select}`;
clickIfExists(thingAtIndex(index) + selector);
},
},
{
name: "Visit Post",
description: "Equivalent of clicking a reddit post.",
match: ["visit #", "# visit"],
normal: false,
pageFn: (transcript, index: number) => {
const selector = isOldReddit
? ` ${reddit.old.post.title}`
: reddit.latest.post.thing;
clickIfExists(thingAtIndex(index) + selector);
},
},
{
name: "Expand",
description:
"Expand a preview of a post, or a comment by it's position (rank).",
match: ["expand #", "# expand"], // in comments view
normal: false,
pageFn: (transcript, index: number) => {
const { comments, special } = reddit.old;
const el = select(
`${thingAtIndex(index)} ${comments.expandBtn}${special.collapsed}`
);
el!.click();
PluginBase.util.scrollToAnimated(el!, -25);
},
test: async (t: ExecutionContext, say, client) => {
await client.url(
`${t.context.localPageDomain}/reddit-r-comics.html?fakeUrl=https://www.reddit.com/r/comics`
);
const selector = `#thing_t3_dvpn38 > div > div > div.expando-button.collapsed`;
const item = await client.$(selector);
t.true(await item.isExisting());
await say("expand for");
t.true(
(await item.getAttribute("class")).split(" ").includes("expanded")
);
},
},
{
name: "Collapse",
description:
"Collapse an expanded preview (or comment if viewing comments). Defaults to topmost in the view port.",
match: ["collapse #", "# collapse"],
normal: false,
pageFn: (transcript, index: number) => {
const { comments, special } = reddit.old;
const el = select(
// thingAtIndex(index) + ' .expando-button:not(.collapsed)'
thingAtIndex(index) + ` ${comments.expandBtn}${special.expanded}`
);
el?.click();
},
test: async (t: ExecutionContext, say, client) => {
await client.url(
"https://old.reddit.com/r/IAmA/comments/z1c9z/i_am_barack_obama_president_of_the_united_states/"
);
// await client.driver.wait(client.until.elementIsVisible(client.driver.findElement(client.By.css('.commentarea'))), 1000);
await client.execute(() => {
select(".commentarea")!.scrollIntoView();
});
// make sure it's expanded
//
[–]Bi const commentUnderTest = await client.$( "//div[contains(@class, 'noncollapsed')][contains(@class, 'comment')][@data-author='Biinaryy']" ); // make sure a child element is visible const tierTwoComment = await client.$( "//p[contains(text(), 'HE KNOWS')]" ); // await tierTwoComment.waitForDisplayed() t.true( await tierTwoComment.isDisplayed(), `tier two comment should be visible` ); await say(); // check that the child comment is no longer visible t.true( (await commentUnderTest.getAttribute("class")).includes( "collapsed" ), `comment under test needs collapsed class` ); t.false( await tierTwoComment.isDisplayed(), `tier two comment should not be visible` ); }, }, { name: "Upvote", match: ["upvote #", "# upvote"], description: "Upvote the post # (doesn't work for comments yet)", normal: false, pageFn: (transcript, index: number) => vote("up", index), }, { name: "Downvote", match: ["downvote #", "# downvote"], description: "Downvote the post # (doesn't work for comments yet)", normal: false, pageFn: (transcript, index: number) => vote("down", index), }, { name: "Clear Vote", description: "Unsets the upvote/downvote so it's neither up or down.", match: ["[clear/reset] vote #", "# [clear/reset] vote"], normal: false, pageFn: (transcript, index: number) => vote("clear", index), }, /* Comments Page */ { name: "Upvote Current", match: "upvote", description: "Upvote the current post.", normal: false, pageFn: () => vote("up"), }, { name: "Downvote Current", match: "downvote", description: "Downvote the post # (doesn't work for comments yet)", normal: false, pageFn: () => vote("down"), }, { name: "Clear Vote Current", description: "Unsets the upvote/downvote so it's neither up or down.", match: ["[clear/reset] vote"], normal: false, pageFn: () => vote("clear"), }, { name: "Visit Current", description: "Click the link for the post that we're in.", match: "visit", normal: false, pageFn: () => { // here we have to dispatch popstate event // because switching to the post page does // not cause location popstate event dispatchEvent("popstate"); clickIfExists("#siteTable a.title"); }, }, { name: "Expand Current", description: "Expand the post that we're in.", match: "expand", normal: false, pageFn: expandCurrent, }, { name: "Collapse Current", description: "Collapse the current post that we're in.", match: ["collapse", "close"], normal: false, pageFn: collapseCurrent, }, { name: "Expand All Comments", description: "Expands all the comments.", match: ["expand all[/ comments]"], normal: false, pageFn: expandAll, test: async (t, say, client) => { // Only checks to see that more than 5 comments are collapsed. await client.url( "https://old.reddit.com/r/OldSchoolCool/comments/2uak5a/arnold_schwarzenegger_flexing_for_two_old_ladies/co6nw85/" ); // first let's make sure there's some collapsed items t.truthy( (await client.$$(".thing.comment.collapsed")).length, `should be some collapsed items` ); const previousCollapsed = ( await client.$$(".thing.comment.collapsed") ).length; await say(); // no collapsed comments remain t.true( (await client.$$(".thing.comment.collapsed")).length < previousCollapsed - 5, `at least 5 comments have been expanded` ); }, }, ], }, };