diff --git a/extension/data/frontends/index.tsx b/extension/data/frontends/index.tsx new file mode 100644 index 00000000..1a328424 --- /dev/null +++ b/extension/data/frontends/index.tsx @@ -0,0 +1,160 @@ +// Defines a system of "slots" which modules can use to render interface +// elements within the page. Slot locations are standardized for consumers (e.g. +// a module says it wants to display a button next to comment author usernames) +// and their actual position in the DOM is controlled by platform-specific +// observers responding to changes in the page and dynamically creating React +// roots which this code then populates with the appropriate contents. + +// TODO: this file probably needs to be explained a lot better im in +// functionality hyperfocus mode not documentation hyperfocus mode + +import {type ComponentType} from 'react'; + +import {currentPlatform, RedditPlatform} from '../util/platform'; +import {reactRenderer} from '../util/ui_interop'; + +import modmailObserver from './modmail'; +import oldRedditObserver from './oldreddit'; +import shredditObserver from './shreddit'; + +// NOMERGE: document all of these +interface PlatformSlotDetailsSubreddit { + fullname?: string; + name: string; +} + +export type PlatformSlotDetailsUser = { + deleted: true; +} | { + deleted: false; + fullname?: string; + name: string; +}; + +interface PlatformSlotDetailsSubmission { + fullname: string; +} + +interface PlatformSlotDetailsComment { + fullname: string; +} + +// Slot names and the type of associated contextual information +// NOMERGE: document +export interface PlatformSlotDetails { + submissionAuthor: { + user: PlatformSlotDetailsUser; + submission?: PlatformSlotDetailsSubmission; + subreddit: PlatformSlotDetailsSubreddit; + // distinguishType: null | 'moderator' | 'employee' | 'alumnus'; + // stickied: boolean; + }; + commentAuthor: { + user: PlatformSlotDetailsUser; + comment: PlatformSlotDetailsComment; + submission?: PlatformSlotDetailsSubmission; + subreddit: PlatformSlotDetailsSubreddit; + // distinguished: boolean; + // stickied: boolean; + }; + modmailAuthor: { + user: PlatformSlotDetailsUser; + subreddit: PlatformSlotDetailsSubreddit; + thread: {fullname: string}; + message: {fullname: string}; + // authorIsModerator: boolean; + // repliedAsSubreddit: boolean; + }; + userHovercard: { + user: PlatformSlotDetailsUser; + subreddit: PlatformSlotDetailsSubreddit; + contextFullname?: string; + }; +} +export type PlatformSlotLocation = keyof PlatformSlotDetails; + +// Consumer code (used by toolbox modules) + +// A consumer of a particular slot location which gets appropriate context and +// returns React content to be rendered in the slot +export type PlatformSlotContent = ComponentType<{ + details: PlatformSlotDetails[Location]; + location: Location; +}>; + +// Map of slot locations to consumers of the slot +const slotConsumers: { + [K in keyof PlatformSlotDetails]?: PlatformSlotContent[]; +} = Object.create(null); + +// NOMERGE: document +export function renderInSlots (locations: K[], render: PlatformSlotContent) { + if (!Array.isArray(locations)) { + locations = []; + } + for (const location of locations) { + if (!slotConsumers[location]) { + slotConsumers[location] = []; + } + slotConsumers[location]?.push(render); + } +} + +// Observer code (used by platform-specific observers in this directory) + +// NOMERGE: document +export type PlatformObserver = ( + /** + * Creates a React root for a slot which will be populated with the + * appropriate contents. Observers are responsible for calling this function + * and inserting the resulting element into the DOM wherever the slot should + * be rendered. + */ + createRenderer: ( + location: Location, + details: PlatformSlotDetails[Location], + ) => HTMLElement, +) => void; + +// the actual `createRenderer` function observers get - returns a new react root +// which will contain all the contents different modules have registered for the +// given slot location +// NOTE: Exported because tbui builders need to manually emit their own slots. +// Should we just import this from the platform-specific bits instead of +// passing this function in to them? +export const createRenderer = (location: K, details: PlatformSlotDetails[K]) => + reactRenderer( +
+ {/* TODO: Do we want to do anything more sophisticated here? */} + {slotConsumers[location]?.map((Component, i) => ( + + ))} +
, + ); + +// Initialize the appropriate observer for the platform we've loaded into +let observers = { + [RedditPlatform.OLD]: oldRedditObserver, + [RedditPlatform.SHREDDIT]: shredditObserver, + [RedditPlatform.MODMAIL]: modmailObserver, +}; + +/** + * Start the platform observer, which will cause slots to be identified and + * populated. To be called as part of the init process after all slot consumers + * have been registered via {@linkcode renderInSlots}. + */ +export function initializeObserver () { + if (currentPlatform == null) { + return; + } + observers[currentPlatform](createRenderer); +} diff --git a/extension/data/frontends/modmail.ts b/extension/data/frontends/modmail.ts new file mode 100644 index 00000000..47f07555 --- /dev/null +++ b/extension/data/frontends/modmail.ts @@ -0,0 +1,96 @@ +import {getThingInfo} from '../tbcore'; +import {PlatformObserver} from '.'; + +const MESSAGE_SEEN_CLASS = 'tb-observer-modmail-message-seen'; + +const SIDEBAR_SEEN_CLASS = 'tb-observer-modmail-sidebar-seen'; + +export default (createRenderer => { + function newModmailConversationAuthors () { + const $body = $('body'); + const subreddit = $body.find('.ThreadTitle__community').text(); + $body.find(`.Thread__message:not(.${MESSAGE_SEEN_CLASS})`).each(function () { + const $this = $(this); + this.classList.add(MESSAGE_SEEN_CLASS); + + // Get information + const authorHref = $this.find('.Message__header .Message__author').attr('href'); + const idDetails = $this.find('.m-link').attr('href')!.match(/\/mail\/.*?\/(.*?)\/(.*?)$/i)!; + + this.querySelector('.Message__divider')?.after(createRenderer('modmailAuthor', { + user: authorHref === undefined + ? {deleted: true} + : {deleted: false, name: authorHref.replace(/.*\/user\/([^/]+).*/, '$1')}, + subreddit: { + name: subreddit, + }, + thread: { + fullname: idDetails[1], + }, + message: { + fullname: idDetails[2], + }, + })); + }); + } + + /** + * Makes sure to fire a jsAPI `TBuserHovercard` event for new modmail sidebar instances. + * @function + */ + function newModmailSidebar () { + const $body = $('body'); + if ($body.find('.ThreadViewer').length) { + const $modmailSidebar = $body.find( + `:is(.ThreadViewer__infobar, .ThreadViewerHeader__infobar, .InfoBar__idCard):not(.${SIDEBAR_SEEN_CLASS})`, + ); + const jsApiPlaceHolder = ` +
+
Toolbox functions:
+ +
+ `; + $modmailSidebar.each(function () { + getThingInfo(this, true).then(info => { + this.classList.add(SIDEBAR_SEEN_CLASS); + + const $jsApiThingPlaceholder = $(jsApiPlaceHolder).appendTo(this); + const jsApiThingPlaceholder = $jsApiThingPlaceholder[0]; + + jsApiThingPlaceholder.appendChild(createRenderer('userHovercard', { + user: (info.user && info.user !== '[deleted]') + ? {deleted: false, name: info.user} + : {deleted: true}, + subreddit: { + name: info.subreddit, + }, + })); + }); + }); + } + } + + const $body = $('body'); + + $body.on('click', '.icon-user', () => { + setTimeout(() => { + newModmailSidebar(); + }, 500); + }); + + $body.on('click', '.Thread__grouped', () => { + setTimeout(() => { + newModmailConversationAuthors(); + }, 500); + }); + + window.addEventListener('TBNewPage', event => { + // TODO: augh + if ((event as any).detail.pageType === 'modmailConversation') { + setTimeout(() => { + newModmailSidebar(); + newModmailConversationAuthors(); + }, 500); + } + }); +}) satisfies PlatformObserver; diff --git a/extension/data/frontends/oldreddit.ts b/extension/data/frontends/oldreddit.ts new file mode 100644 index 00000000..38d30209 --- /dev/null +++ b/extension/data/frontends/oldreddit.ts @@ -0,0 +1,113 @@ +import $ from 'jquery'; + +import {getThingInfo} from '../tbcore.js'; +import TBLog from '../tblog'; +import {PlatformObserver} from '.'; + +const log = TBLog('observer:old'); + +// Class added to items when they are added to the intersection observer, to +// prevent them from being observed multiple times +const THING_OBSERVED_CLASS = 'tb-observer-oldreddit-thing-observed'; + +// Class added to items when they come into the viewport and have their slots +// added, to prevent having slots duplicated in case another intersection +// observer event causes it to be processed again +const THING_PROCESSED_CLASS = 'tb-observer-oldreddit-thing-processed'; + +export default (createRenderer => { + /** + * {@linkcode IntersectionObserver} that handles adding renderers to things + * when they are about to scroll into view. + */ + const viewportObserver = new IntersectionObserver((entries, observer) => { + entries.forEach(async ({target, isIntersecting}) => { + // The observer fires for everything on page load. This makes sure + // that we really only act on those items that are visible. + if (!isIntersecting) { + return; + } + + // Don't continue observing the element once it's become visible. + observer.unobserve(target); + + // If the element's parent is updated, sometimes it gets emitted + // again anyway. Check for stuff we've seen before by checking for + // an added class. + if (target.classList.contains(THING_PROCESSED_CLASS)) { + log.debug('target observed a second time?? so it *does* happen sometimes', target); + return; + } + target.classList.add(THING_PROCESSED_CLASS); + + // Get information about the item so we can fill in context data + const info = await getThingInfo($(target)); + + if (info.kind === 'submission') { + const entryEl = target.querySelector('.entry'); + const authorEl = entryEl?.querySelector('.tagline :is(.author, time + span)'); + + // TODO: We don't have non-author slots yet, but + // entryEl?.appendChild(createRenderer(...)) + + authorEl?.after(createRenderer('submissionAuthor', { + user: (info.author && info.author !== '[deleted]') + ? {deleted: false, name: info.author} + : {deleted: true}, + subreddit: { + name: info.subreddit, + }, + submission: { + fullname: info.id as string, + }, + })); + } + + if (info.kind === 'comment') { + const entryEl = target.querySelector(':scope > .entry'); + const authorEl = entryEl?.querySelector(':scope > .tagline :is(.author, em)'); + + // TODO: We don't have non-author slots yet, but + // entryEl?.appendChild(createRenderer(...)); + + authorEl?.after(createRenderer('commentAuthor', { + user: (info.author && info.author !== '[deleted]') + ? { + deleted: false, + name: info.author, + } + : {deleted: true}, + submission: { + fullname: info.postID, + }, + comment: { + fullname: info.id as string, + }, + subreddit: { + name: info.subreddit, + }, + })); + } + }); + }, {rootMargin: '200px'}); + + // Finds unprocessed items in the DOM and starts waiting for them to get + // close to the viewport edge + function observeNewThings () { + $(`div.content .thing:not(.${THING_OBSERVED_CLASS}) .entry`).closest('.thing').each(function () { + this.classList.add(THING_OBSERVED_CLASS); + viewportObserver.observe(this); + }); + } + + observeNewThings(); + + // TODO: In the future we'd like to remove the TBNewThings event + // entirely and consolidate RES infinite scroll logic in this + // file, since it's only relevant on old Reddit. But not all our + // UI uses the slots/observer API yet, so it doesn't make sense to + // pull it in here yet. + window.addEventListener('TBNewThings', () => { + observeNewThings(); + }); +}) satisfies PlatformObserver; diff --git a/extension/data/frontends/shreddit.ts b/extension/data/frontends/shreddit.ts new file mode 100644 index 00000000..68cd7337 --- /dev/null +++ b/extension/data/frontends/shreddit.ts @@ -0,0 +1,8 @@ +import TBLog from '../tblog'; +import {PlatformObserver} from '.'; + +const log = TBLog('observer:shreddit'); + +export default (() => { + log.warn('Modmail observer not yet implemented'); +}) satisfies PlatformObserver; diff --git a/extension/data/init.ts b/extension/data/init.ts index 34486a82..a6e90909 100644 --- a/extension/data/init.ts +++ b/extension/data/init.ts @@ -37,6 +37,7 @@ import TBModule from './tbmodule.jsx'; import * as TBStorage from './tbstorage.js'; import AppRoot from './AppRoot'; +import {initializeObserver} from './frontends'; import {documentInteractive} from './util/dom'; import {isUserLoggedInQuick} from './util/platform'; import {reactRenderer} from './util/ui_interop'; @@ -400,5 +401,6 @@ async function doSettingsUpdates () { // Once all modules are initialized and have had a chance to register event // listeners, start emitting jsAPI events and page URL change events TBListener.start(); + initializeObserver(); TBCore.watchForURLChanges(); })(); diff --git a/extension/data/modules/historybutton.js b/extension/data/modules/historybutton.js index 38b47eea..3f7a6b81 100644 --- a/extension/data/modules/historybutton.js +++ b/extension/data/modules/historybutton.js @@ -1,13 +1,16 @@ import $ from 'jquery'; +import {createElement} from 'react'; import * as TBApi from '../tbapi.ts'; import * as TBCore from '../tbcore.js'; import * as TBHelpers from '../tbhelpers.js'; -import TBListener from '../tblistener.js'; import {Module} from '../tbmodule.jsx'; import * as TBStorage from '../tbstorage.js'; import * as TBui from '../tbui.js'; +import {renderInSlots} from '../frontends/index.tsx'; +import {JQueryRenderer} from '../util/ui_interop.tsx'; + const self = new Module({ name: 'History Button', id: 'HButton', @@ -34,6 +37,7 @@ const self = new Module({ advanced: true, description: 'Number of comments to retrieve per user history', }, + // XXX: delete this setting as it's now unused { id: 'onlyshowInhover', type: 'boolean', @@ -67,33 +71,24 @@ self.attachHistoryButton = function ($target, author, subreddit, buttonText = 'H }); }; -self.runJsAPI = function ({onlyshowInhover}) { +self.runJsAPI = function () { self.log('run'); - TBListener.on('author', e => { - const $target = $(e.target); - // Skip adding the button next to the username if: - // - the onlyShowInHover preference is set, - // - we're not on old reddit (since the preference doesn't work there), and - // - we didn't make the thing the author is on (since the hovercard doesn't show up on constructed things). - if (onlyshowInhover && !TBCore.isOldReddit && !$target.closest('.tb-thing').length) { - return; - } - const author = e.detail.data.author; - const subreddit = e.detail.data.subreddit && e.detail.data.subreddit.name; + renderInSlots([ + 'submissionAuthor', + 'commentAuthor', + 'userHovercard', + ], ({details, location}) => { + const subreddit = details.subreddit.name; + const user = !details.user.deleted && details.user.name; - if (author === '[deleted]') { - return; + if (details.user.deleted) { + return null; } - self.attachHistoryButton($target, author, subreddit); - }); - - TBListener.on('userHovercard', e => { - const $target = $(e.target); - const author = e.detail.data.user.username; - const subreddit = e.detail.data.subreddit && e.detail.data.subreddit.name; - self.attachHistoryButton($target, author, subreddit, 'User History'); + const $target = $(''); + self.attachHistoryButton($target, user, subreddit, location === 'userHovercard' ? 'User History' : undefined); + return createElement(JQueryRenderer, {content: $target}); }); window.addEventListener('TBNewPage', event => { @@ -127,7 +122,7 @@ async function init (options) { self.log('mscheck passed'); - self.runJsAPI(options); + self.runJsAPI(); $body.on('click', '.user-history-button, #tb-user-history', function (event) { const $this = $(this); diff --git a/extension/data/modules/modbutton.js b/extension/data/modules/modbutton.js index afcb28c8..db5e3797 100644 --- a/extension/data/modules/modbutton.js +++ b/extension/data/modules/modbutton.js @@ -1,12 +1,14 @@ import $ from 'jquery'; +import {createElement} from 'react'; +import {renderInSlots} from '../frontends/index.tsx'; import * as TBApi from '../tbapi.ts'; import * as TBCore from '../tbcore.js'; import * as TBHelpers from '../tbhelpers.js'; -import TBListener from '../tblistener.js'; import {Module} from '../tbmodule.jsx'; import * as TBStorage from '../tbstorage.js'; import * as TBui from '../tbui.js'; +import {JQueryRenderer} from '../util/ui_interop.tsx'; const MAX_BAN_REASON_LENGTH = 300; const MAX_BAN_MESSAGE_LENGTH = 5000; @@ -60,7 +62,6 @@ const self = new Module({ export default self; const $body = $('body'); -const titleText = 'Perform various mod actions on this user'; self.runRedesign = async function () { // Not a mod, don't bother. @@ -68,49 +69,37 @@ self.runRedesign = async function () { if (mySubs.length < 1) { return; } - const onlyshowInhover = await self.get('onlyshowInhover'); - TBListener.on('author', e => { - const $target = $(e.target); - - // As the modbutton is already accessible in the sidebar and not needed for mods we don't show it in modmail threads. - if (e.detail.type === 'TBmodmailCommentAuthor') { - return; - } - if ($target.closest('.tb-thing').length || !onlyshowInhover || TBCore.isOldReddit || TBCore.isNewModmail) { - const subreddit = e.detail.data.subreddit.name; - const author = e.detail.data.author; - - if (author === '[deleted]') { - return; - } - - let parentID; - if (e.detail.data.comment) { - parentID = e.detail.data.comment.id; - } else if (e.detail.data.post) { - parentID = e.detail.data.post.id; - } else { - parentID = 'unknown'; - } - requestAnimationFrame(() => { - $target.append( - `M`, - ); - }); + renderInSlots([ + 'submissionAuthor', + 'commentAuthor', + 'userHovercard', + ], ({details, location}) => { + const contextFullname = details.contextFullname || details.comment?.fullname || details.submission?.fullname + || 'unknown'; + const subreddit = details.subreddit.name; + const user = !details.user.deleted && details.user.name; + + // End of state/hooks - render + if (details.user.deleted) { + return null; } - }); - // event based handling of author elements. - TBListener.on('userHovercard', e => { - const $target = $(e.target); - const subreddit = e.detail.data.subreddit.name; - const author = e.detail.data.user.username; - const parentID = e.detail.data.contextId; - - $target.append( - `Mod Button`, - ); + // TODO: convert the whole popup thing to be React-oriented + return createElement(JQueryRenderer, { + content: $(` + + ${location === 'userHovercard' ? 'Mod Button' : 'M'} + + `), + }); }); }; diff --git a/extension/data/modules/modnotes.jsx b/extension/data/modules/modnotes.jsx index 921fb1eb..40fb4176 100644 --- a/extension/data/modules/modnotes.jsx +++ b/extension/data/modules/modnotes.jsx @@ -1,17 +1,16 @@ import {map, page, pipeAsync} from 'iter-ops'; -import $ from 'jquery'; import {useEffect, useRef, useState} from 'react'; +import {renderInSlots} from '../frontends/index.tsx'; import {useFetched, useSetting} from '../hooks.ts'; import * as TBApi from '../tbapi.ts'; -import {isModSub, isNewModmail, link} from '../tbcore.js'; +import {isModSub, link} from '../tbcore.js'; import {escapeHTML} from '../tbhelpers.js'; -import TBListener from '../tblistener.js'; import TBLog from '../tblog.ts'; import {Module} from '../tbmodule.jsx'; import {setSettingAsync} from '../tbstorage.js'; import {drawPosition, textFeedback, TextFeedbackKind} from '../tbui.js'; -import {createBodyShadowPortal, reactRenderer} from '../util/ui_interop.tsx'; +import {createBodyShadowPortal} from '../util/ui_interop.tsx'; import { ActionButton, @@ -541,61 +540,6 @@ function NoteTableRow ({note, onDelete}) { ); } -const ModNotesUserRoot = ({user, subreddit, contextID}) => { - // Get settings - const defaultTabName = useSetting('ModNotes', 'defaultTabName', 'all_activity'); - const defaultNoteLabel = useSetting('ModNotes', 'defaultNoteLabel', 'none'); - - // Fetch the latest note for the user - const note = useFetched(getLatestModNote(subreddit, user)); - - const [popupShown, setPopupShown] = useState(false); - const [popupClickEvent, setPopupClickEvent] = useState(null); - - /** @type {{top: number; left: number} | undefined} */ - let initialPosition = undefined; - if (popupClickEvent) { - const positions = drawPosition(popupClickEvent); - initialPosition = { - top: positions.topPosition, - left: positions.leftPosition, - }; - } - - function showPopup (event) { - setPopupShown(true); - setPopupClickEvent(event); - } - - function hidePopup () { - setPopupShown(false); - setPopupClickEvent(null); - } - - return ( - <> - - {popupShown && createBodyShadowPortal( - , - )} - - ); -}; - export default new Module({ name: 'Mod Notes', id: 'ModNotes', @@ -630,41 +574,76 @@ export default new Module({ setSettingAsync(this.id, 'cachedParentFullnames', undefined); // Handle authors showing up on the page - TBListener.on('author', async e => { - const subreddit = e.detail.data.subreddit.name; - const author = e.detail.data.author; - const contextID = isNewModmail ? undefined : e.detail.data.comment?.id || e.detail.data.post?.id; - - // Deleted users can't have notes - if (author === '[deleted]') { - return; + renderInSlots([ + 'commentAuthor', + 'submissionAuthor', + 'modmailAuthor', + 'userHovercard', + ], ({details}) => { + const subreddit = details.subreddit.name; + const user = !details.user.deleted && details.user.name; + const contextID = details.contextFullname || details.comment?.fullname || details.submission?.fullname || null; + + const isMod = useFetched(isModSub(details.subreddit.name)); + + // Get settings + const defaultTabName = useSetting('ModNotes', 'defaultTabName', 'all_activity'); + const defaultNoteLabel = useSetting('ModNotes', 'defaultNoteLabel', 'none'); + + // Fetch the latest note for the user + const note = useFetched(getLatestModNote(subreddit, user)); + + const [popupShown, setPopupShown] = useState(false); + const [popupClickEvent, setPopupClickEvent] = useState(null); + + // Need to know where we are and who we're looking at, and can't fetch + // notes in a sub you're not a mod of + // TODO: What specific permissions are required to fetch notes? + if (!subreddit || !user || !isMod) { + return null; } - // Can't fetch notes in a sub you're not a mod of - // TODO: What specific permissions are required to fetch notes? - const isMod = await isModSub(subreddit); - if (!isMod) { - return; + /** @type {{top: number; left: number} | undefined} */ + let initialPosition = undefined; + if (popupClickEvent) { + const positions = drawPosition(popupClickEvent); + initialPosition = { + top: positions.topPosition, + left: positions.leftPosition, + }; } - // Return early if we don't have the things we need - if (!e.detail.data.subreddit.name || !e.detail.data.author) { - return; + function showPopup (event) { + setPopupShown(true); + setPopupClickEvent(event); } - // Display badge for notes if not already present - const $target = $(e.target); - if ($target.find('.tb-modnote-badge-react-root').length) { - return; + function hidePopup () { + setPopupShown(false); + setPopupClickEvent(null); } - const badgeRoot = reactRenderer( - , + + return ( + <> + + {popupShown && createBodyShadowPortal( + , + )} + ); - badgeRoot.classList.add('tb-modnote-badge-react-root'); - $target.append(badgeRoot); }); }); diff --git a/extension/data/modules/nukecomments.js b/extension/data/modules/nukecomments.js index b9a91465..662a191a 100644 --- a/extension/data/modules/nukecomments.js +++ b/extension/data/modules/nukecomments.js @@ -1,12 +1,15 @@ import $ from 'jquery'; +import {createElement} from 'react'; +import {renderInSlots} from '../frontends/index.tsx'; +import {useFetched} from '../hooks.ts'; import * as TBApi from '../tbapi.ts'; import * as TBCore from '../tbcore.js'; import * as TBHelpers from '../tbhelpers.js'; -import TBListener from '../tblistener.js'; import {Module} from '../tbmodule.jsx'; import * as TBStorage from '../tbstorage.js'; import * as TBui from '../tbui.js'; +import {JQueryRenderer} from '../util/ui_interop.tsx'; export default new Module({ name: 'Comment Nuke', @@ -282,37 +285,41 @@ export default new Module({ } // Add nuke buttons where needed - TBListener.on('comment', async e => { - const pageType = TBCore.pageDetails.pageType; - const $target = $(e.target); - const subreddit = e.detail.data.subreddit.name; - const commentID = e.detail.data.id.substring(3); - const postID = e.detail.data.post.id.substring(3); + // XXX 3: this also needs to be able to appear in hovercards apparently?? what + // the fuck is going on with all the special casing in this goddamn module + renderInSlots(['commentAuthor'], ({details, location}) => { + const subreddit = details.subreddit.name; + const commentID = details.comment.fullname.substring(3); + const submissionID = details.submission?.fullname.substring(3); - const isMod = await TBCore.isModSub(subreddit); - // We have to mod the subreddit to show the button - if (!isMod) { - return; - } - // We also have to be on a comments page or looking at a context popup - if ( - pageType !== 'subredditCommentsPage' && pageType !== 'subredditCommentPermalink' - && !$target.closest('.context-button-popup').length - ) { - return; + const isMod = useFetched(TBCore.isModSub(subreddit)); + if (!commentID || !submissionID || !isMod) { + return null; } + // XXX: implement the old check that makes this only show up in comments + // trees and context pages and context popups. i don't know how this + // will be done but we do not want this button showing up on single + // comments in flat listings because thats just not reasonable and we + // dont want to encourage people to act on a whole tree in a vacuum + const NukeButtonHTML = - `${ - e.detail.type === 'TBcommentOldReddit' && !showNextToUser ? 'Nuke' : 'R' + `${ + location === 'userHovercard' ? 'Nuke' : 'R' }`; - if (showNextToUser && TBCore.isOldReddit) { - const $userContainter = $target.closest('.entry, .tb-comment-entry').find( - '.tb-jsapi-author-container .tb-frontend-container', - ); - $userContainter.append(NukeButtonHTML); - } else { - $target.append(NukeButtonHTML); - } + + // XXX 2: implement showNextToUser setting. for now we always show next + // to the author name because we don't have any other slots implemented + // on comments but when this changes we should revisit this. old logic: + // if (showNextToUser && TBCore.isOldReddit) { + // const $userContainter = $target.closest('.entry, .tb-comment-entry').find( + // '.tb-jsapi-author-container .tb-frontend-container', + // ); + // $userContainter.append(NukeButtonHTML); + // } else { + // $target.append(NukeButtonHTML); + // } + + return createElement(JQueryRenderer, {content: $(NukeButtonHTML)}); }); }); diff --git a/extension/data/modules/usernotes.js b/extension/data/modules/usernotes.js index 6a6c9f86..44f01541 100644 --- a/extension/data/modules/usernotes.js +++ b/extension/data/modules/usernotes.js @@ -1,12 +1,15 @@ import $ from 'jquery'; +import {createElement} from 'react'; +import {renderInSlots} from '../frontends/index.tsx'; +import {useFetched} from '../hooks.ts'; import * as TBApi from '../tbapi.ts'; import * as TBCore from '../tbcore.js'; import * as TBHelpers from '../tbhelpers.js'; -import TBListener from '../tblistener.js'; import {Module} from '../tbmodule.jsx'; import * as TBStorage from '../tbstorage.js'; import * as TBui from '../tbui.js'; +import {JQueryRenderer} from '../util/ui_interop.tsx'; // FIXME: It no longer makes sense to bake logger functions into modules // themselves, since functions the module defines may not have the module @@ -44,6 +47,7 @@ const self = new Module({ advanced: true, description: 'Max characters to display in current note tag (excluding date)', }, + // NOMERGE - remove this setting it's unused now { id: 'onlyshowInhover', type: 'boolean', @@ -57,7 +61,7 @@ const self = new Module({ }); export default self; -function startUsernotes ({maxChars, showDate, onlyshowInhover}) { +function startUsernotes ({maxChars, showDate}) { const subs = []; const $body = $('body'); const self = this; @@ -120,45 +124,40 @@ function startUsernotes ({maxChars, showDate, onlyshowInhover}) { function addTBListener () { // event based handling of author elements. - TBListener.on('author', async e => { - const $target = $(e.target); - if ($target.closest('.tb-thing').length || !onlyshowInhover || TBCore.isOldReddit || TBCore.isNewModmail) { - const subreddit = e.detail.data.subreddit.name; - const author = e.detail.data.author; - if (author === '[deleted]') { - return; - } - - $target.addClass('ut-thing'); - $target.attr('data-subreddit', subreddit); - $target.attr('data-author', author); - - const isMod = await TBCore.isModSub(subreddit); - if (isMod) { - attachNoteTag($target, subreddit, author); - foundSubreddit(subreddit); - queueProcessSub(subreddit, $target); - } + renderInSlots([ + 'userHovercard', + 'submissionAuthor', + 'commentAuthor', + 'modmailAuthor', + ], ({location, details}) => { + const subreddit = details.subreddit.name; + const author = details.user.name; + + const isMod = useFetched(TBCore.isModSub(subreddit)); + + if (details.user.deleted || !isMod) { + return null; } - }); - // event based handling of author elements. - TBListener.on('userHovercard', async e => { - const $target = $(e.target); - const subreddit = e.detail.data.subreddit.name; - const author = e.detail.data.user.username; + // spoof the structure the rest of this code is expecting + // TODO: get rid of all this crap when rewriting to use React + const $target = $(''); $target.addClass('ut-thing'); + $target.css('display', 'contents'); $target.attr('data-subreddit', subreddit); $target.attr('data-author', author); + $target.attr( + 'data-context-fullname', + details.contextFullname || details.comment?.fullname || details.submission?.fullname, + ); - const isMod = await TBCore.isModSub(subreddit); - if (isMod) { - attachNoteTag($target, subreddit, author, { - customText: 'Usernotes', - }); - foundSubreddit(subreddit); - queueProcessSub(subreddit, $target); - } + attachNoteTag($target, subreddit, author, { + customText: location === 'userHovercard' ? 'Usernotes' : undefined, + }); + foundSubreddit(subreddit); + queueProcessSub(subreddit, $target); + + return createElement(JQueryRenderer, {content: $target}); }); } @@ -516,19 +515,7 @@ function startUsernotes ({maxChars, showDate, onlyshowInhover}) { link = thingInfo.permalink_newmodmail; createUserPopup(subreddit, user, link, disableLink, e); } else { - let thingID; - let thingDetails; - - if ($thing.data('tb-type') === 'TBcommentAuthor' || $thing.data('tb-type') === 'commentAuthor') { - thingDetails = $thing.data('tb-details'); - thingID = thingDetails.data.comment.id; - } else if ($thing.data('tb-type') === 'userHovercard') { - thingDetails = $thing.data('tb-details'); - thingID = thingDetails.data.contextId; - } else { - thingDetails = $thing.data('tb-details'); - thingID = thingDetails.data.post.id; - } + const thingID = $thing.attr('data-context-fullname'); if (!thingID) { // we don't have the ID on /about/banned, so no thing data for us diff --git a/extension/data/tbcore.js b/extension/data/tbcore.js index 3eae04e4..8bbfe574 100644 --- a/extension/data/tbcore.js +++ b/extension/data/tbcore.js @@ -1010,6 +1010,7 @@ export async function getThingInfo (sender, modCheck) { ham = false; user = $threadBase.find('.Message__author').first().text() || $body.find('.InfoBar__username').first().text() + || $body.find('.ModIdCard__UserNameLink').first().text() || $body.find('.ModIdCard__UserNameContainer').first().text(); } else { const $entry = $($sender.closest('.entry')[0] || $sender.find('.entry')[0] || $sender); diff --git a/extension/data/tbui.js b/extension/data/tbui.js index d5352c86..640feec5 100644 --- a/extension/data/tbui.js +++ b/extension/data/tbui.js @@ -3,6 +3,7 @@ import {createRoot} from 'react-dom/client'; import tinycolor from 'tinycolor2'; import browser from 'webextension-polyfill'; +import {createRenderer as createSlotRenderer} from './frontends/index.tsx'; import * as TBApi from './tbapi.ts'; import * as TBCore from './tbcore.js'; import * as TBHelpers from './tbhelpers.js'; @@ -1305,6 +1306,15 @@ export function makeSubmissionEntry (submission, submissionOptions) { $buildsubmission.css('border-left', `solid 3px ${subColor}`); } + // Add frontend slots so modules can put buttons on these too + $buildsubmission.find('.tb-submission-author').after(createSlotRenderer('submissionAuthor', { + user: submissionAuthor === '[deleted]' + ? {deleted: true} + : {deleted: false, name: submissionAuthor}, + subreddit: {name: submissionSubreddit}, + submission: {fullname: submissionName}, + })); + return $buildsubmission; } @@ -1718,6 +1728,15 @@ export function makeSingleComment (comment, commentOptions = {}) { $buildComment.css('border-left', `solid 3px ${subColor}`); } + // Add frontend slots so modules can put buttons on these too + $buildComment.find('.tb-comment-author').after(createSlotRenderer('commentAuthor', { + user: commentAuthor === '[deleted]' + ? {deleted: true} + : {deleted: false, name: commentAuthor}, + subreddit: {name: commentSubreddit}, + comment: {fullname: commentName}, + })); + return $buildComment; } diff --git a/extension/data/util/dom.ts b/extension/data/util/dom.ts index 758e4119..d4c26024 100644 --- a/extension/data/util/dom.ts +++ b/extension/data/util/dom.ts @@ -16,28 +16,50 @@ export const documentInteractive = new Promise(resolve => { } }); +/** + * Creates a long-lived {@linkcode MutationObserver} which observes mutations to + * some node's subtree and calls a callback for each individual mutation record + * that is observed. + * @param target The element to observe. + * @param options Mutation observer options. This convenience function defaults + * the `subtree` option to `true`; all others are passed through as-is. + * @param callback A function called for each observed + * {@linkcode MutationRecord}. + * @returns The created {@linkcode MutationObserver}. + */ +export function observeSubtree ( + target: Node, + options: MutationObserverInit = {}, + callback: (record: MutationRecord) => void, +) { + let observer = new MutationObserver(records => records.forEach(record => callback(record))); + observer.observe(target, {subtree: true, ...options}); + return observer; +} + // Keep a list of all the handlers we haven't run yet let pendingElementHandlers: [el: HTMLElement, handler: () => void][] = []; -/** Registers a function to run when the given element appears in the DOM. */ +/** + * Registers a function to run when the given element appears in the DOM. + */ export function onDOMAttach (el: HTMLElement, handler: () => void) { pendingElementHandlers.push([el, handler]); } // watch for elements being added to the DOM -new MutationObserver(() => { +observeSubtree(document, {childList: true}, record => { // go through the array and see if each element is present yet pendingElementHandlers = pendingElementHandlers.filter(([el, handler]) => { - if (document.contains(el)) { - // element is on the page, call its handler and remove from array - handler(); - return false; + for (const addedNode of record.addedNodes ?? []) { + if (addedNode === el || addedNode.contains(el)) { + // element is on page, call its handler and remove from array + handler(); + return false; + } } // element is not on page yet, keep it in the array return true; }); -}).observe(document, { - childList: true, - subtree: true, }); diff --git a/extension/data/util/ui_interop.tsx b/extension/data/util/ui_interop.tsx index c0ca2128..c1cc6742 100644 --- a/extension/data/util/ui_interop.tsx +++ b/extension/data/util/ui_interop.tsx @@ -61,7 +61,7 @@ export function JQueryRenderer ({content}: {content: JQuery}) { }; } }, [content]); - return
; + return
; } // this isn't really an "interop" thing but whatever it lives here for now diff --git a/package-lock.json b/package-lock.json index adc66002..768aae52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2520,12 +2520,12 @@ } }, "node_modules/framer-motion": { - "version": "11.14.1", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.14.1.tgz", - "integrity": "sha512-6B7jC54zgnefmUSa2l4gkc/2CrqclHL9AUbDxxRfbFyWKLd+4guUYtEabzoYMU8G5ICZ6CdJdydOLy74Ekd7ag==", + "version": "11.14.4", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.14.4.tgz", + "integrity": "sha512-NQuzr9JbeJDMQmy0FFLhLzk9h1kAjVC1tGE/HY4ubF02B95EBm2lpA21LE3Od/OpXqXgp0zl5Hdqu25hliBRsA==", "dependencies": { - "motion-dom": "^11.14.1", - "motion-utils": "^11.14.1", + "motion-dom": "^11.14.3", + "motion-utils": "^11.14.3", "tslib": "^2.4.0" }, "peerDependencies": { @@ -3785,14 +3785,14 @@ } }, "node_modules/motion-dom": { - "version": "11.14.1", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.1.tgz", - "integrity": "sha512-Y68tHWR0d2HxHDskNxpeY3pzUdz7L/m5A8TV7VSE6Sq4XUNJdZV8zXco1aeAQ44o48u0i8UKjt8TGIqkZSQ8ew==" + "version": "11.14.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz", + "integrity": "sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA==" }, "node_modules/motion-utils": { - "version": "11.14.1", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.14.1.tgz", - "integrity": "sha512-R6SsehArpkEBUHydkcwQ/8ij8k2PyKWAJ7Y8PN3ztnFwq5RBU3zIamYH6esTp09OgsbwB57mBEZ9DORaN1WTxQ==" + "version": "11.14.3", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.14.3.tgz", + "integrity": "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ==" }, "node_modules/ms": { "version": "2.1.2",