diff --git a/extension/data/frontends/index.tsx b/extension/data/frontends/index.tsx new file mode 100644 index 00000000..fa1bb978 --- /dev/null +++ b/extension/data/frontends/index.tsx @@ -0,0 +1,226 @@ +// Defines a system of "slots" which modules can use to render interface +// elements within the page. Slot types 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'; + +/** Basic information about a subreddit. */ +interface PlatformSlotDetailsSubreddit { + /** The subreddit's fullname, beginning with ``. */ + fullname?: string; + /** The name of the subreddit */ + name: string; +} + +/** Basic information about a user. */ +export type PlatformSlotDetailsUser = { + /** If `true`, this is a deleted user. */ + deleted: true; +} | { + /** If `true`, this is a deleted user. */ + deleted: false; + /** The user's fullname, starting with ``. */ + fullname?: string; + /** The user's username. */ + name: string; +}; + +/** Basic information about a submission. */ +interface PlatformSlotDetailsSubmission { + /** The submission's fullname, beginning with ``. */ + fullname: string; +} + +/** Basic information about a comment. */ +interface PlatformSlotDetailsComment { + /** The comment's fullname, beginning with ``. */ + fullname: string; +} + +// Slot names and the type of associated contextual information + +/** Contextual information provided to consumers of each type of slot. */ +export interface PlatformSlotDetails { + /** Details for a submission author slot. */ + submissionAuthor: { + /** The author of this submission */ + user: PlatformSlotDetailsUser; + /** The submission */ + submission?: PlatformSlotDetailsSubmission; + /** The subreddit where this submission was posted */ + subreddit: PlatformSlotDetailsSubreddit; + // /** The type of distinguish on the submission, if any */ + // distinguishType: null | 'moderator' | 'employee' | 'alumnus'; + // /** The sticky slot populated by the submission, if any */ + // stickied: false | 1 | 2; + }; + commentAuthor: { + /** The author of the comment */ + user: PlatformSlotDetailsUser; + /** The comment */ + comment: PlatformSlotDetailsComment; + /** The parent submission the comment was left under */ + submission?: PlatformSlotDetailsSubmission; + /** The subreddit where the comment was posted */ + subreddit: PlatformSlotDetailsSubreddit; + // /** The type of distinguish on the comment, if any */ + // distinguished: boolean; + // /** Whether the comment is stickied */ + // stickied: boolean; + }; + modmailAuthor: { + /** The author of the message */ + user: PlatformSlotDetailsUser; + /** The subreddit that initially received this message's thread */ + subreddit: PlatformSlotDetailsSubreddit; + /** The thread this message is in */ + thread: {fullname: string}; + /** The message */ + message: {fullname: string}; + // /** Whether the author is a moderator */ + // authorIsModerator: boolean; + // /** Whether this message was sent "as the subreddit" (with username hidden) */ + // repliedAsSubreddit: boolean; + }; + userHovercard: { + /** The user */ + user: PlatformSlotDetailsUser; + /** + * The subreddit of the content the hovercard was triggered from. For + * example if the hovercard was triggered from an author name on a + * submission, this would be the subreddit it was submitted to; if the + * hovercard is triggered on a user in a modmail thread, this would be + * the subreddit that received the thread. + */ + subreddit: PlatformSlotDetailsSubreddit; + /** + * The fullname of the submission, comment, modmail thread, etc. the + * hovercard was triggered from + */ + contextFullname?: string; + }; +} +/** + * A slot type. Describes a location on the page where slot contents can be + * rendered (e.g. `submissionAuthor` is a slot type that's rendered next to the + * usernames of submission authors). + */ +export type PlatformSlotType = 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<{ + /** + * Contextual details about the content the slot is attached to. Different + * slot types provide different information in this object. + */ + details: PlatformSlotDetails[SlotType]; + /** The type of slot the component is currently being populated into. */ + slotType: SlotType; +}>; + +// Map of slot locations to consumers of the slot +const slotConsumers: { + [K in keyof PlatformSlotDetails]?: PlatformSlotContent[]; +} = Object.create(null); + +/** + * Provide a consumer for one or more slot types. Whenever any of the `slots` + * appears on the page, the given component/renderer will be used to populate + * the slot (alongside any other consumers of the same slot type). + * @param slots An array of slots where the given component should be rendered + * @param render A React function component/render function that will be + * rendered in those slots. Props are passed which inform the component which + * type of slot it's currently being rendered in, and contextual information + * about the slot's surroundings. + */ +export function renderInSlots (slots: K[], render: PlatformSlotContent) { + if (!Array.isArray(slots)) { + slots = []; + } + for (const slot of slots) { + if (!slotConsumers[slot]) { + slotConsumers[slot] = []; + } + slotConsumers[slot]?.push(render); + } +} + +// Observer code (used by platform-specific observers in this directory) + +/** + * A platform observer is a function responsible creating slot instances and + * attaching them to the page in the appropriate locations for a specific + * platform. It is called once when Toolbox starts and receives a function which + * creates slot instances, which it then inserts into the DOM. + */ +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: ( + slotType: SlotType, + details: PlatformSlotDetails[SlotType], + ) => 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 type +// 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 = (slotType: K, details: PlatformSlotDetails[K]) => + reactRenderer( +
+ {/* TODO: Do we want to do anything more sophisticated here? */} + {slotConsumers[slotType]?.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 78d96932..a5234aec 100644 --- a/extension/data/init.ts +++ b/extension/data/init.ts @@ -28,13 +28,14 @@ import 'codemirror/mode/yaml/yaml.js'; import './tbplugins.js'; -import AppRoot from './AppRoot'; - import * as TBApi from './tbapi'; import * as TBCore from './tbcore.js'; import {delay} from './tbhelpers.js'; import TBListener from './tblistener.js'; import TBModule from './tbmodule.jsx'; + +import AppRoot from './AppRoot'; +import {initializeObserver} from './frontends'; import {getCache, setCache} from './util/cache'; import {documentInteractive} from './util/dom'; import createLogger from './util/logging'; @@ -402,5 +403,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 ffc330b1..3f2404a2 100644 --- a/extension/data/modules/historybutton.js +++ b/extension/data/modules/historybutton.js @@ -1,9 +1,9 @@ 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 TBui from '../tbui.js'; import {icons} from '../util/icons.ts'; @@ -13,6 +13,9 @@ import {getSettingAsync} from '../util/settings.ts'; const log = createLogger('HButton'); +import {renderInSlots} from '../frontends/index.tsx'; +import {JQueryRenderer} from '../util/ui_interop.tsx'; + const self = new Module({ name: 'History Button', id: 'HButton', @@ -39,6 +42,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', @@ -72,33 +76,24 @@ self.attachHistoryButton = function ($target, author, subreddit, buttonText = 'H }); }; -self.runJsAPI = function ({onlyshowInhover}) { +self.runJsAPI = function () { log.debug('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, slotType}) => { + 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, slotType === 'userHovercard' ? 'User History' : undefined); + return createElement(JQueryRenderer, {content: $target}); }); window.addEventListener('TBNewPage', event => { @@ -132,7 +127,7 @@ async function init (options) { log.debug('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 680b9593..526b56df 100644 --- a/extension/data/modules/modbutton.js +++ b/extension/data/modules/modbutton.js @@ -1,14 +1,16 @@ 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 TBui from '../tbui.js'; import createLogger from '../util/logging.ts'; import {purifyObject} from '../util/purify.js'; import {getSettingAsync} from '../util/settings.ts'; +import {JQueryRenderer} from '../util/ui_interop.tsx'; const log = createLogger('ModButton'); @@ -64,7 +66,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. @@ -72,49 +73,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, slotType}) => { + 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: $(` + + ${slotType === 'userHovercard' ? 'Mod Button' : 'M'} + + `), + }); }); }; diff --git a/extension/data/modules/modnotes.jsx b/extension/data/modules/modnotes.jsx index 14efd821..ea46ef78 100644 --- a/extension/data/modules/modnotes.jsx +++ b/extension/data/modules/modnotes.jsx @@ -1,19 +1,18 @@ import {map, page, pipeAsync} from 'iter-ops'; -import $ from 'jquery'; import {useEffect, useRef, useState} from 'react'; import {Provider} from 'react-redux'; +import {renderInSlots} from '../frontends/index.tsx'; import {useFetched, useSetting} from '../hooks.ts'; import store from '../store/index.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 {Module} from '../tbmodule.jsx'; import {drawPosition, textFeedback, TextFeedbackKind} from '../tbui.js'; import createLogger from '../util/logging.ts'; import {setSettingAsync} from '../util/settings.ts'; -import {createBodyShadowPortal, reactRenderer} from '../util/ui_interop.tsx'; +import {createBodyShadowPortal} from '../util/ui_interop.tsx'; import { ActionButton, @@ -546,55 +545,6 @@ function NoteTableRow ({note, onDelete}) { ); } -const ModNotesUserRoot = ({user, subreddit, contextID}) => { - // 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', @@ -629,43 +579,70 @@ 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)); + + // 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 ad4f07d1..410fd3d5 100644 --- a/extension/data/modules/nukecomments.js +++ b/extension/data/modules/nukecomments.js @@ -1,13 +1,16 @@ 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 TBui from '../tbui.js'; import createLogger from '../util/logging.ts'; import {purify, purifyObject} from '../util/purify.js'; +import {JQueryRenderer} from '../util/ui_interop.tsx'; const log = createLogger('CommentNuke'); @@ -283,37 +286,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, slotType}) => { + 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' + `${ + slotType === '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 a117b53c..3c879e41 100644 --- a/extension/data/modules/usernotes.js +++ b/extension/data/modules/usernotes.js @@ -1,9 +1,11 @@ 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 TBui from '../tbui.js'; import {clearCache, getCache, setCache} from '../util/cache.ts'; @@ -11,6 +13,7 @@ import {icons} from '../util/icons.ts'; import createLogger from '../util/logging.ts'; import {purifyObject} from '../util/purify.js'; import {getSettingAsync} from '../util/settings.ts'; +import {JQueryRenderer} from '../util/ui_interop.tsx'; const log = createLogger('UserNotes'); @@ -50,6 +53,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', @@ -63,7 +67,7 @@ const self = new Module({ }); export default self; -function startUsernotes ({maxChars, showDate, onlyshowInhover}) { +function startUsernotes ({maxChars, showDate}) { const subs = []; const $body = $('body'); let firstRun = true; @@ -125,45 +129,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', + ], ({slotType, 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: slotType === 'userHovercard' ? 'Usernotes' : undefined, + }); + foundSubreddit(subreddit); + queueProcessSub(subreddit, $target); + + return createElement(JQueryRenderer, {content: $target}); }); } @@ -521,19 +520,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 73d59a8b..4ec5c18c 100644 --- a/extension/data/tbcore.js +++ b/extension/data/tbcore.js @@ -971,6 +971,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 9ad7fe1d..02014108 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 04741166..cc114493 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -92,7 +91,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "darwin" @@ -106,7 +104,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -120,7 +117,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -134,7 +130,6 @@ "riscv64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -148,7 +143,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -162,7 +156,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "linux" @@ -176,7 +169,6 @@ "arm64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -190,7 +182,6 @@ "x64" ], "dev": true, - "license": "MIT", "optional": true, "os": [ "win32" @@ -307,7 +298,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.1.tgz", "integrity": "sha512-os5kFd/52gZTl/W6xqMfhaKVJHQM8V/U1P8jcSaQJ/C4Qhdrf2jEXdA/HaxfQs9iiUA/0yzYhk5d3oRHTxGDDQ==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.6", "@inquirer/figures": "^1.0.10", @@ -332,7 +322,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.5.tgz", "integrity": "sha512-ZB2Cz8KeMINUvoeDi7IrvghaVkYT2RB0Zb31EaLWOE87u276w4wnApv0SH2qWaJ3r0VSUa3BIuz7qAV2ZvsZlg==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.6", "@inquirer/type": "^3.0.4" @@ -354,7 +343,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.6.tgz", "integrity": "sha512-Bwh/Zk6URrHwZnSSzAZAKH7YgGYi0xICIBDFOqBQoXNNAzBHw/bgXgLmChfp+GyR3PnChcTbiCTZGC6YJNJkMA==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/figures": "^1.0.10", "@inquirer/type": "^3.0.4", @@ -382,7 +370,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.6.tgz", "integrity": "sha512-l0smvr8g/KAVdXx4I92sFxZiaTG4kFc06cFZw+qqwTirwdUHMFLnouXBB9OafWhpO3cfEkEz2CdPoCmor3059A==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.6", "@inquirer/type": "^3.0.4", @@ -405,7 +392,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.8.tgz", "integrity": "sha512-k0ouAC6L+0Yoj/j0ys2bat0fYcyFVtItDB7h+pDFKaDDSFJey/C/YY1rmIOqkmFVZ5rZySeAQuS8zLcKkKRLmg==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.6", "@inquirer/type": "^3.0.4", @@ -428,7 +414,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.10.tgz", "integrity": "sha512-Ey6176gZmeqZuY/W/nZiUyvmb1/qInjcpiZjXWi6nON+nxJpD1bxtSoBxNliGISae32n6OwbY+TSXPZ1CfS4bw==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" } @@ -438,7 +423,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.1.5.tgz", "integrity": "sha512-bB6wR5wBCz5zbIVBPnhp94BHv/G4eKbUEjlpCw676pI2chcvzTx1MuwZSCZ/fgNOdqDlAxkhQ4wagL8BI1D3Zg==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.6", "@inquirer/type": "^3.0.4" @@ -460,7 +444,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.8.tgz", "integrity": "sha512-CTKs+dT1gw8dILVWATn8Ugik1OHLkkfY82J+Musb57KpmF6EKyskv8zmMiEJPzOnLTZLo05X/QdMd8VH9oulXw==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.6", "@inquirer/type": "^3.0.4" @@ -482,7 +465,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.8.tgz", "integrity": "sha512-MgA+Z7o3K1df2lGY649fyOBowHGfrKRz64dx3+b6c1w+h2W7AwBoOkHhhF/vfhbs5S4vsKNCuDzS3s9r5DpK1g==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.6", "@inquirer/type": "^3.0.4", @@ -505,7 +487,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.1.tgz", "integrity": "sha512-r1CiKuDV86BDpvj9DRFR+V+nIjsVBOsa2++dqdPqLYAef8kgHYvmQ8ySdP/ZeAIOWa27YGJZRkENdP3dK0H3gg==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/checkbox": "^4.1.1", "@inquirer/confirm": "^5.1.5", @@ -535,7 +516,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.0.8.tgz", "integrity": "sha512-hl7rvYW7Xl4un8uohQRUgO6uc2hpn7PKqfcGkCOWC0AA4waBxAv6MpGOFCEDrUaBCP+pXPVqp4LmnpWmn1E1+g==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.6", "@inquirer/type": "^3.0.4", @@ -558,7 +538,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.0.8.tgz", "integrity": "sha512-ihSE9D3xQAupNg/aGDZaukqoUSXG2KfstWosVmFCG7jbMQPaj2ivxWtsB+CnYY/T4D6LX1GHKixwJLunNCffww==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.6", "@inquirer/figures": "^1.0.10", @@ -582,7 +561,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.0.8.tgz", "integrity": "sha512-Io2prxFyN2jOCcu4qJbVoilo19caiD3kqkD3WR0q3yDA5HUCo83v4LrRtg55ZwniYACW64z36eV7gyVbOfORjA==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.6", "@inquirer/figures": "^1.0.10", @@ -607,7 +585,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.4.tgz", "integrity": "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" }, @@ -1073,7 +1050,6 @@ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "dev": true, - "license": "MIT", "dependencies": { "type-fest": "^0.21.3" }, @@ -1089,7 +1065,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -1485,15 +1460,13 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, - "license": "ISC", "engines": { "node": ">= 12" } @@ -2010,7 +1983,6 @@ "integrity": "sha512-xtBPZbPKWOOJH5al4h6rgzsGHuXpyK7TFJCQ5/TW8Z4zkcB9dfmPAh14/Yp5YZcB798D0liztXw+Nd7suzdzBQ==", "dev": true, "hasInstallScript": true, - "license": "MIT", "bin": { "dprint": "bin.js" }, @@ -2050,8 +2022,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/entities": { "version": "2.1.0", @@ -2571,7 +2542,6 @@ "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", "dev": true, - "license": "MIT", "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", @@ -2719,10 +2689,9 @@ } }, "node_modules/framer-motion": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.0.1.tgz", - "integrity": "sha512-u6p0Qc4cY/AEQAtrC7qiYlXla39qnWoI4JXY7OCNBDXwJ5yRBD8HU+RhaOqqziw2m/b0BDh32f44W94+wXonMQ==", - "license": "MIT", + "version": "12.0.6", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.0.6.tgz", + "integrity": "sha512-LmrXbXF6Vv5WCNmb+O/zn891VPZrH7XbsZgRLBROw6kFiP+iTK49gxTv2Ur3F0Tbw6+sy9BVtSqnWfMUpH+6nA==", "dependencies": { "motion-dom": "^12.0.0", "motion-utils": "^12.0.0", @@ -3031,7 +3000,6 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, - "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -3154,7 +3122,6 @@ "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.4.1.tgz", "integrity": "sha512-/V7OyFkeUBFO2jAokUq5emSlcVMHVvzg8bwwZnzmCwErPgbeftsthmPUg71AIi5mR0YmiJOLQ+bTiHVWEjOw7A==", "dev": true, - "license": "MIT", "dependencies": { "@inquirer/core": "^10.1.6", "@inquirer/prompts": "^7.3.1", @@ -3335,7 +3302,6 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -3996,7 +3962,6 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.0.0.tgz", "integrity": "sha512-CvYd15OeIR6kHgMdonCc1ihsaUG4MYh/wrkz8gZ3hBX/uamyZCXN9S9qJoYF03GqfTt7thTV/dxnHYX4+55vDg==", - "license": "MIT", "dependencies": { "motion-utils": "^12.0.0" } @@ -4004,8 +3969,7 @@ "node_modules/motion-utils": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.0.0.tgz", - "integrity": "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA==", - "license": "MIT" + "integrity": "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA==" }, "node_modules/ms": { "version": "2.1.2", @@ -4018,7 +3982,6 @@ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", "dev": true, - "license": "ISC", "engines": { "node": "^18.17.0 || >=20.5.0" } @@ -4220,7 +4183,6 @@ "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -5431,8 +5393,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/scheduler": { "version": "0.25.0", @@ -5524,7 +5485,6 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "ISC", "engines": { "node": ">=14" }, @@ -5587,7 +5547,6 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, - "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -5806,7 +5765,6 @@ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", "dev": true, - "license": "MIT", "dependencies": { "os-tmpdir": "~1.0.2" }, @@ -6165,7 +6123,6 @@ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, - "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -6213,7 +6170,6 @@ "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz", "integrity": "sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==", "dev": true, - "license": "MIT", "engines": { "node": ">=18" },