diff --git a/src/features/quote_replies/index.js b/src/features/quote_replies/index.js index 66cbb361b5..e6e410d963 100644 --- a/src/features/quote_replies/index.js +++ b/src/features/quote_replies/index.js @@ -2,16 +2,19 @@ import { keyToCss } from '../../utils/css_map.js'; import { dom } from '../../utils/dom.js'; import { inject } from '../../utils/inject.js'; import { buildStyle, displayInlineFlexUnlessDisabledAttr, notificationSelector } from '../../utils/interface.js'; +import { registerReplyMeatballItem, unregisterReplyMeatballItem } from '../../utils/meatballs.js'; import { showErrorModal } from '../../utils/modals.js'; import { pageModifications } from '../../utils/mutations.js'; import { notify } from '../../utils/notifications.js'; import { getPreferences } from '../../utils/preferences.js'; +import { timelineObject } from '../../utils/react_props.js'; import { buildSvg } from '../../utils/remixicon.js'; import { apiFetch, navigate } from '../../utils/tumblr_helpers.js'; -import { userBlogs } from '../../utils/user.js'; +import { userBlogNames, userBlogs } from '../../utils/user.js'; const storageKey = 'quote_replies.draftLocation'; const buttonClass = 'xkit-quote-replies'; +const meatballButtonId = 'quote-replies'; const dropdownButtonClass = 'xkit-quote-replies-dropdown'; // Remove outdated elements when loading module @@ -93,7 +96,7 @@ const processNotifications = notifications => notifications.forEach(async notifi { click () { this.disabled = true; - quoteReply(tumblelogName, notificationProps) + quoteActivityReply(tumblelogName, notificationProps) .catch(showErrorModal) .finally(() => { this.disabled = false; }); }, @@ -102,7 +105,7 @@ const processNotifications = notifications => notifications.forEach(async notifi )); }); -const processGenericReply = async (notificationProps) => { +const createGenericActivityReplyPost = async (notificationProps) => { const { subtype: type, timestamp, @@ -120,7 +123,7 @@ const processGenericReply = async (notificationProps) => { ? bodyDescriptionContent.text.slice(summaryFormatting.start + 1, summaryFormatting.end - 1) : bodyDescriptionContent.text; - return await processReply({ type, timestamp, targetPostId, targetTumblelogName, targetPostSummary }); + return await createActivityReplyPost({ type, timestamp, targetPostId, targetTumblelogName, targetPostSummary }); } catch (exception) { console.error(exception); console.debug('[XKit] Falling back to generic quote content due to fetch/parse failure'); @@ -153,7 +156,7 @@ const processGenericReply = async (notificationProps) => { return { content, tags }; }; -const processReply = async ({ type, timestamp, targetPostId, targetTumblelogName, targetPostSummary }) => { +const createActivityReplyPost = async ({ type, timestamp, targetPostId, targetTumblelogName, targetPostSummary }) => { const { response } = await apiFetch( `/v2/blog/${targetTumblelogName}/post/${targetPostId}/notes/timeline`, { queryParams: { mode: 'replies', before_timestamp: `${timestamp + 1}000000` } }, @@ -166,38 +169,95 @@ const processReply = async ({ type, timestamp, targetPostId, targetTumblelogName throw new Error('Reply not found.'); } + const { content: replyContent, blog: { name: replyingBlogName, uuid: replyingBlogUuid } } = reply; + const targetPostUrl = `https://${targetTumblelogName}.tumblr.com/post/${targetPostId}`; + + return createReplyPost({ type, replyingBlogName, replyingBlogUuid, targetPostSummary, targetPostUrl, replyContent }); +}; + +const createReplyPost = ({ type, replyingBlogName, replyingBlogUuid, targetPostSummary, targetPostUrl, replyContent }) => { const verbiage = { reply: 'replied to your post', reply_to_comment: 'replied to you in a post', note_mention: 'mentioned you on a post', }[type]; - const text = `@${reply.blog.name} ${verbiage} \u201C${targetPostSummary.replace(/\n/g, ' ')}\u201D:`; + const text = `@${replyingBlogName} ${verbiage} \u201C${targetPostSummary.replace(/\n/g, ' ')}\u201D:`; const formatting = [ - { start: 0, end: reply.blog.name.length + 1, type: 'mention', blog: { uuid: reply.blog.uuid } }, - { start: text.indexOf('\u201C'), end: text.length - 1, type: 'link', url: `https://${targetTumblelogName}.tumblr.com/post/${targetPostId}` }, + { start: 0, end: replyingBlogName.length + 1, type: 'mention', blog: { uuid: replyingBlogUuid } }, + { start: text.indexOf('\u201C'), end: text.length - 1, type: 'link', url: targetPostUrl }, ]; const content = [ { type: 'text', text, formatting }, - Object.assign(reply.content[0], { subtype: 'indented' }), + Object.assign(replyContent[0], { subtype: 'indented' }), { type: 'text', text: '\u200B' }, ]; const tags = [ ...originalPostTag ? [originalPostTag] : [], - ...tagReplyingBlog ? [reply.blog.name] : [], + ...tagReplyingBlog ? [replyingBlogName] : [], ].join(','); return { content, tags }; }; -const quoteReply = async (tumblelogName, notificationProps) => { - const uuid = userBlogs.find(({ name }) => name === tumblelogName).uuid; +const quoteActivityReply = async (tumblelogName, notificationProps) => { + const replyPost = notificationProps.type === 'generic' + ? await createGenericActivityReplyPost(notificationProps) + : await createActivityReplyPost(notificationProps); + + openQuoteReplyDraft(tumblelogName, replyPost); +}; - const { content, tags } = notificationProps.type === 'generic' - ? await processGenericReply(notificationProps) - : await processReply(notificationProps); +const determineNoteReplyType = ({ noteProps, parentNoteProps }) => { + if (userBlogNames.includes(noteProps.note.blogName)) return false; + if (noteProps.communityId) return false; - const { response: { id: responseId, displayText } } = await apiFetch(`/v2/blog/${uuid}/posts`, { method: 'POST', body: { content, state: 'draft', tags } }); + if (parentNoteProps && userBlogNames.includes(parentNoteProps.note.blogName)) { + return { + type: 'reply_to_comment', + targetBlogName: parentNoteProps.note.blogName, + }; + } + if (userBlogNames.includes(noteProps.blog.name)) { + return { + type: 'reply', + targetBlogName: noteProps.blog.name, + }; + } + for (const { formatting = [] } of noteProps.note.content) { + for (const { type, blog } of formatting) { + if (type === 'mention' && userBlogNames.includes(blog.name)) { + return { + type: 'note_mention', + targetBlogName: blog.name, + }; + } + } + } + return false; +}; + +const quoteNoteReply = async ({ currentTarget }) => { + const { + noteProps: { + note: { blogName: replyingBlogName, content: replyContent }, + }, + } = currentTarget.__notePropsData; + + const { type, targetBlogName } = determineNoteReplyType(currentTarget.__notePropsData); + + const { summary: targetPostSummary, postUrl: targetPostUrl } = await timelineObject(currentTarget.closest(keyToCss('meatballMenu'))); + const replyingBlogUuid = await apiFetch(`/v2/blog/${replyingBlogName}/info?fields[blogs]=uuid`) + .then(({ response: { blog: { uuid } } }) => uuid); + + const replyPost = createReplyPost({ type, replyingBlogName, replyingBlogUuid, targetPostSummary, targetPostUrl, replyContent }); + openQuoteReplyDraft(targetBlogName, replyPost); +}; + +const openQuoteReplyDraft = async (tumblelogName, replyPost) => { + const uuid = userBlogs.find(({ name }) => name === tumblelogName).uuid; + + const { response: { id: responseId, displayText } } = await apiFetch(`/v2/blog/${uuid}/posts`, { method: 'POST', body: { state: 'draft', ...replyPost } }); const currentDraftLocation = `/edit/${tumblelogName}/${responseId}`; @@ -220,6 +280,13 @@ export const main = async function () { pageModifications.register(notificationSelector, processNotifications); + registerReplyMeatballItem({ + id: meatballButtonId, + label: 'Quote this reply', + notePropsFilter: notePropsData => Boolean(determineNoteReplyType(notePropsData)), + onclick: event => quoteNoteReply(event).catch(showErrorModal), + }); + const { [storageKey]: draftLocation } = await browser.storage.local.get(storageKey); browser.storage.local.remove(storageKey); @@ -230,6 +297,8 @@ export const main = async function () { export const clean = async function () { pageModifications.unregister(processNotifications); + unregisterReplyMeatballItem(meatballButtonId); + $(`.${buttonClass}`).remove(); }; diff --git a/src/main_world/unbury_note_props.js b/src/main_world/unbury_note_props.js new file mode 100644 index 0000000000..759b973cdc --- /dev/null +++ b/src/main_world/unbury_note_props.js @@ -0,0 +1,18 @@ +export default function unburyNoteProps () { + const noteElement = this; + const reactKey = Object.keys(noteElement).find(key => key.startsWith('__reactFiber')); + let fiber = noteElement[reactKey]; + + const resultsByReplyId = {}; + while (fiber !== null) { + const props = fiber.memoizedProps || {}; + if (typeof props?.note?.replyId === 'string') { + // multiple sets of props correspond to each replyId; + // prefer the last set, as it contains the most information + resultsByReplyId[props.note.replyId] = props; + } + fiber = fiber.return; + } + const [noteProps, parentNoteProps] = Object.values(resultsByReplyId); + return { noteProps, parentNoteProps }; +} diff --git a/src/utils/meatballs.js b/src/utils/meatballs.js index 0c4a6870b0..f8d32ec624 100644 --- a/src/utils/meatballs.js +++ b/src/utils/meatballs.js @@ -2,7 +2,7 @@ import { keyToCss } from './css_map.js'; import { dom } from './dom.js'; import { displayBlockUnlessDisabledAttr, getClosestRenderedElement, postSelector } from './interface.js'; import { pageModifications } from './mutations.js'; -import { blogData, timelineObject } from './react_props.js'; +import { blogData, notePropsObjects, timelineObject } from './react_props.js'; const postHeaderSelector = `${postSelector} :is(article > header, article > div > header)`; const blogHeaderSelector = `[style*="--blog-title-color"] > div > div > header, ${keyToCss('blogCardHeaderBar')}`; @@ -10,6 +10,7 @@ const blogHeaderSelector = `[style*="--blog-title-color"] > div > div > header, const meatballItems = { post: {}, blog: {}, + reply: {}, }; /** @@ -48,6 +49,24 @@ export const unregisterBlogMeatballItem = id => { $(`[data-xkit-blog-meatball-button="${id}"]`).remove(); }; +/** + * Add a custom button to post replies' meatball menus. + * @param {object} options Destructured + * @param {string} options.id Identifier for this button (must be unique) + * @param {string|Function} options.label Button text to display. May be a function accepting the note component props data of the reply element being actioned on. + * @param {Function} options.onclick Button click listener function + * @param {Function} [options.notePropsFilter] Filter function, called with the note component props data of the reply element being actioned on. Must return true for button to be added. + */ +export const registerReplyMeatballItem = function ({ id, label, onclick, notePropsFilter }) { + meatballItems.reply[id] = { label, onclick, filter: notePropsFilter }; + pageModifications.trigger(addMeatballItems); +}; + +export const unregisterReplyMeatballItem = id => { + delete meatballItems.reply[id]; + $(`[data-xkit-reply-meatball-button="${id}"]`).remove(); +}; + const addMeatballItems = meatballMenus => meatballMenus.forEach(async meatballMenu => { const closestHeader = await getClosestRenderedElement(meatballMenu, 'header'); if (closestHeader?.matches(postHeaderSelector)) { @@ -66,6 +85,20 @@ const addMeatballItems = meatballMenus => meatballMenus.forEach(async meatballMe reactData: await blogData(meatballMenu), reactDataKey: '__blogData', }); + return; + } + const inPostActivity = Boolean(await getClosestRenderedElement(meatballMenu, `${keyToCss('postActivity')} *`)); + if (inPostActivity) { + const __notePropsData = await notePropsObjects(meatballMenu); + + if (__notePropsData?.noteProps?.note?.type === 'reply') { + addTypedMeatballItems({ + meatballMenu, + type: 'reply', + reactData: __notePropsData, + reactDataKey: '__notePropsData', + }); + } } }); diff --git a/src/utils/react_props.js b/src/utils/react_props.js index a612790f22..89b38ac175 100644 --- a/src/utils/react_props.js +++ b/src/utils/react_props.js @@ -30,6 +30,22 @@ export const notificationObject = notificationElement => { return notificationElement.notificationObjectPromise; }; +/** + * @typedef NotePropsData + * @property {object} noteProps A note element's buried note component props + * @property {object} [parentNoteProps] A note element's parent reply's buried note component props, if it is a threaded reply + */ + +/** + * @param {Element} noteElement An on-screen post note element + * @returns {Promise} An object containing the element's buried note component props and, if it is a + * threaded reply, its parents' buried note component props values + */ +export const notePropsObjects = noteElement => { + noteElement.notePropsObjectsPromise ??= inject('/main_world/unbury_note_props.js', [], noteElement); + return noteElement.notePropsObjectsPromise; +}; + /** * @param {Element} meatballMenu An on-screen meatball menu element in a blog modal header or blog card * @returns {Promise} The post's buried blog or blogSettings property. Some blog data fields, such as "followed," are not available in blog cards.