Skip to content

Commit

Permalink
feat: enhance review experience
Browse files Browse the repository at this point in the history
  • Loading branch information
double-beep authored Jun 5, 2024
1 parent dfce471 commit 2d4c801
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 59 deletions.
5 changes: 2 additions & 3 deletions src/AdvancedFlagging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,7 @@ function setPopoverOpening(
}
}

const url = new URL(window.location.href);
export let page = new Page(url);
export let page = new Page();

function setupPostPage(): void {
// check if the link + popover should be set up
Expand All @@ -193,7 +192,7 @@ function setupPostPage(): void {
// i) append the icons to iconLocation
// ii) add link & set up reporters on

page = new Page(url);
page = new Page();

if (page.name && page.name !== 'Question') {
page.posts.forEach(post => post.addIcons());
Expand Down
68 changes: 64 additions & 4 deletions src/UserscriptTools/NattyApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { page } from '../AdvancedFlagging';
import Reporter from './Reporter';
import Page from './Page';
import { WebsocketUtils } from './WebsocketUtils';
import { Spinner } from '@userscripters/stacks-helpers';
import Post from './Post';

const dayMillis = 1000 * 60 * 60 * 24;
const nattyFeedbackUrl = 'https://logs.sobotics.org/napi-1.1/api/stored/';
Expand Down Expand Up @@ -94,22 +96,45 @@ export class NattyAPI extends Reporter {
private async report(): Promise<string> {
if (!this.canBeReported()) return '';

// When mods flag a post as NAA/VLQ, then that
// post is deleted immediately. As a result, it
// isn't reported on time to Natty.
if (StackExchange.options.user.isModerator) {
const submit = document.querySelector('form .js-modal-submit');

const popover = document.createElement('div');
popover.classList.add('s-popover');
popover.id = 'advanced-flagging-progress-popover';

const arrow = document.createElement('div');
arrow.classList.add('s-popover--arrow');

if (Page.isLqpReviewPage && submit) {
// attach a popover to the "Delete" button indicating
// that post is being reported to Natty
Stacks.attachPopover(submit, popover, {
placement: 'bottom-start',
autoShow: true
});
}

// Handle cases where the post may not be reported to Natty on time:
// - when a mod flags a post as NAA/VLQ it is deleted immediately.
// - when a reviewer sends the last Recommend deletion/Delete review,
// the post is also deleted immediately
if (StackExchange.options.user.isModerator || Page.isLqpReviewPage) {
this.addItem('Connecting to chat websocket...');
// init websocket
const url = await this.chat.getFinalUrl();
const wsUtils = new WebsocketUtils(url, this.id);

this.addItem('Reporting post to Natty...');
await this.chat.sendMessage(this.reportMessage);

// wait until the report is received
this.addItem('Waiting for Natty to receive the report...');
await wsUtils.waitForReport(event => this.chat.reportReceived(event));
} else {
await this.chat.sendMessage(this.reportMessage);
}

this.addItem('Completing review task...');
return nattyReportedMessage;
}

Expand All @@ -125,4 +150,39 @@ export class NattyAPI extends Reporter {
// get the number of days between the creation of the question and the answer
return (answerDate.valueOf() - questionDate.valueOf()) / dayMillis;
}

private addItem(text: string): void {
if (!Page.isLqpReviewPage) return;

const wrapper = document.createElement('div');
wrapper.classList.add('d-flex', 'gs8');

const action = document.createElement('div');
action.classList.add('flex--item');
action.textContent = text;

StackExchange.helpers.removeSpinner();

const spinner = Spinner.makeSpinner({
size: 'sm',
classes: [ 'flex--item' ]
});

wrapper.append(spinner, action);

const done = document.createElement('div');
done.classList.add('flex--item', 'fc-green-500', 'fw-bold');
done.textContent = 'done!';

const popover = document.querySelector('#advanced-flagging-progress-popover');

// previous process has been finished
const tick = Post.getActionIcons()[0];
tick.style.display = 'block';

popover?.lastElementChild?.prepend(tick);
popover?.lastElementChild?.append(done);
// info about current process
popover?.append(wrapper);
}
}
30 changes: 24 additions & 6 deletions src/UserscriptTools/Page.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Post from './Post';

type Pages = 'Question' | 'NATO' | 'Flags' | 'Search';
type Pages = 'Question' | 'NATO' | 'Flags' | 'Search' | 'Review';

export default class Page {
public static readonly isStackOverflow = /^https:\/\/stackoverflow.com/.test(location.href);
Expand All @@ -13,12 +13,24 @@ export default class Page {
private readonly href: URL;
private readonly selector: string;

constructor(href: URL) {
this.href = href;
constructor(
// whether to include posts AF has parsed
// useful for review
private readonly includeModified = false
) {
this.href = new URL(location.href);
this.name = this.getName();

this.selector = this.getPostSelector();
this.posts = this.getPosts();

const question = document.querySelector<HTMLElement>('.question');
if (Page.isLqpReviewPage && question) {
// populate Post.qDate
const post = new Post(question);

Post.qDate = post.date;
}
}

public getAllPostIds(
Expand Down Expand Up @@ -49,6 +61,7 @@ export default class Page {
else if (isNatoPage) return 'NATO';
else if (isQuestionPage) return 'Question';
else if (isSearch) return 'Search';
else if (Page.isLqpReviewPage) return 'Review';
else return '';
}

Expand All @@ -62,16 +75,21 @@ export default class Page {
return '.question, .answer';
case 'Search':
return '.js-search-results .s-post-summary';
case 'Review':
return '#answer .answer';
default:
return '';
}

return '';
}

private getPosts(): Post[] {
if (this.name === '') return [];

return ([...document.querySelectorAll(this.selector)] as HTMLElement[])
.filter(el => !el.querySelector('.advanced-flagging-link, .advanced-flagging-icon'))
.filter(el => {
return !el.querySelector('.advanced-flagging-link, .advanced-flagging-icon')
|| this.includeModified;
})
.map(el => new Post(el));
}
}
20 changes: 10 additions & 10 deletions src/UserscriptTools/Post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,20 @@ export default class Post {
this.opReputation = this.getOpReputation();
this.opName = this.getOpName();

[this.done, this.failed, this.flagged] = this.getActionIcons();
[this.done, this.failed, this.flagged] = Post.getActionIcons();

this.initReporters();
}

public static getActionIcons(): HTMLElement[] {
return [
['Checkmark', 'fc-green-500'],
['Clear', 'fc-red-500'],
['Flag', 'fc-red-500']
]
.map(([svg, classname]) => Post.getIcon(getSvg(`icon${svg}`), classname));
}

public async flag(
reportType: Flags,
text: string | null,
Expand Down Expand Up @@ -461,15 +470,6 @@ export default class Post {
return Boolean(deleteButton);
}

private getActionIcons(): HTMLElement[] {
return [
['Checkmark', 'fc-green-500'],
['Clear', 'fc-red-500'],
['Flag', 'fc-red-500']
]
.map(([svg, classname]) => Post.getIcon(getSvg(`icon${svg}`), classname));
}

private initReporters(): void {
// for every site & post type
this.reporters.Smokey = new MetaSmokeAPI(this.id, this.type, this.deleted);
Expand Down
88 changes: 52 additions & 36 deletions src/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,13 @@ import { NattyAPI } from './UserscriptTools/NattyApi';
import { CopyPastorAPI } from './UserscriptTools/CopyPastorAPI';
import { Cached, Store } from './UserscriptTools/Store';

import Post from './UserscriptTools/Post';
import Page from './UserscriptTools/Page';

interface ReviewQueueResponse {
postId: number;
isAudit: boolean; // detect audits & avoid sending feedback to bots
}

const allPosts: Post[] = [];

function getPostIdFromReview(): number {
const answer = document.querySelector('[id^="answer-"]');
const id = answer?.id.split('-')[1];

return Number(id);
}

async function runOnNewTask(xhr: XMLHttpRequest): Promise<void> {
const regex = /\/review\/(next-task|task-reviewed\/)/;

Expand All @@ -32,14 +22,13 @@ async function runOnNewTask(xhr: XMLHttpRequest): Promise<void> {
|| !document.querySelector('#answer') // not an answer
) return;

const reviewResponse = JSON.parse(xhr.responseText) as ReviewQueueResponse;
if (reviewResponse.isAudit) return; // audit

const cached = allPosts.find(({ id }) => id === reviewResponse.postId);
const element = document.querySelector<HTMLElement>('#answer .answer');
if (!element) return;
const response = JSON.parse(xhr.responseText) as ReviewQueueResponse;
if (response.isAudit) return; // audit

const post = cached ?? new Post(element);
const page = new Page();
// page.posts should be an element with just one item:
// the answer the user is reviewing
const post = page.posts[0];

// eslint-disable-next-line no-await-in-loop
while (!isDone) await delay(200);
Expand All @@ -52,7 +41,7 @@ async function runOnNewTask(xhr: XMLHttpRequest): Promise<void> {
CopyPastorAPI.storeReportedPosts([ url ])
]);

if (!cached) allPosts.push(post);
post.addIcons();

// attach click event listener to the submit button
// it's OK to do on every task load, as the HTML is re-added
Expand All @@ -66,17 +55,15 @@ async function runOnNewTask(xhr: XMLHttpRequest): Promise<void> {
// must have selected 'Looks OK' and clicked submit
if (!looksGood?.checked) return;

const cached = allPosts.find(({ id }) => id === post.id);

const flagType = Store.flagTypes
// send 'Looks Fine' feedback:
// get the respective flagType, call handleFlag()
// send 'Looks Fine' feedback: get the respective flagType
.find(({ id }) => id === 15);

// in case "looks fine" flagtype is deleted
if (!cached || !flagType) return;
if (!flagType) return;

void cached.sendFeedbacks(flagType);
const page = new Page(true);
void page.posts[0].sendFeedbacks(flagType);
});
}

Expand All @@ -86,24 +73,53 @@ export function setupReview(): void {

addXHRListener(runOnNewTask);

// The "Add a comment for the author?" modal opens
// when the GET request to /posts/modal/delete/<post id> is finished.
// We detect when the modal opens and attach a click event listener
// to the submit button in order to prevent immediate sending of the
// "Recommend Deletion" vote. This is required in case the post is 1 vote
// away from being deleted and it needs to be reported to a bot.
addXHRListener(xhr => {
const regex = /(\d+)\/vote\/10|(\d+)\/recommend-delete/;
const regex = /\/posts\/modal\/delete\/\d+/;

if (
xhr.status !== 200 // request failed
|| !regex.test(xhr.responseURL) // didn't vote to delete
|| !document.querySelector('#answer') // not an answer
|| !document.querySelector('#answer') // answer element not found
) return;

const postId = getPostIdFromReview();
const cached = allPosts.find(({ id }) => id === postId);

if (!cached) return;

const flagType = Store.flagTypes
.find(({ id }) => id === 7); // the "Not an answer" flag type
if (!flagType) return; // something went wrong

void cached.sendFeedbacks(flagType);
// the submit button
const submit = document.querySelector('form .js-modal-submit');
if (!submit) return;

submit.addEventListener('click', async event => {
// don't recomment deletion immediately
event.preventDefault();
event.stopPropagation();

const target = event.target as HTMLButtonElement;

// indicate loading
target.classList.add('is-loading');
target.ariaDisabled = 'true';
target.disabled = true;

try {
// find the "Not an answer" flag type
const flagType = Store.flagTypes.find(({ id }) => id === 7);
if (!flagType) return; // something went wrong

const page = new Page(true);
await page.posts[0].sendFeedbacks(flagType);
} finally {
// remove previously added indicators
target.classList.remove('is-loading');
target.ariaDisabled = 'false';
target.disabled = false;

// proceed with the vote
target.click();
}
}, { once: true });
});
}

0 comments on commit 2d4c801

Please sign in to comment.