diff --git a/src/features/quote_replies/feature.json b/src/features/quote_replies/feature.json index 20cbf46fdb..dc70b91780 100644 --- a/src/features/quote_replies/feature.json +++ b/src/features/quote_replies/feature.json @@ -8,6 +8,15 @@ }, "help": "https://github.com/AprilSylph/XKit-Rewritten/wiki/Features#quote-replies", "preferences": { + "defaultMode": { + "type": "select", + "label": "Default to replying in:", + "options": [ + { "value": "new post", "label": "A new post" }, + { "value": "reblog", "label": "A reblog of the post" } + ], + "default": "new post" + }, "tagReplyingBlog": { "type": "checkbox", "label": "Automatically tag the quoted user", diff --git a/src/features/quote_replies/index.js b/src/features/quote_replies/index.js index 66cbb361b5..6fc4bace1c 100644 --- a/src/features/quote_replies/index.js +++ b/src/features/quote_replies/index.js @@ -2,16 +2,20 @@ 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 { showErrorModal } from '../../utils/modals.js'; +import { registerReplyMeatballItem, unregisterReplyMeatballItem } from '../../utils/meatballs.js'; +import { hideModal, modalCancelButton, showErrorModal, showModal } 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 newPostMeatballButtonId = 'quote-replies-new-post'; +const reblogMeatballButtonId = 'quote-replies-reblog'; const dropdownButtonClass = 'xkit-quote-replies-dropdown'; // Remove outdated elements when loading module @@ -66,6 +70,7 @@ const activitySelector = `:is(${keyToCss('notification')} > ${keyToCss('activity const dropdownSelector = '[role="tabpanel"] *'; +let defaultMode; let originalPostTag; let tagReplyingBlog; let newTab; @@ -93,7 +98,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 +107,7 @@ const processNotifications = notifications => notifications.forEach(async notifi )); }); -const processGenericReply = async (notificationProps) => { +const createGenericActivityReplyPost = async (notificationProps) => { const { subtype: type, timestamp, @@ -120,7 +125,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'); @@ -150,10 +155,11 @@ const processGenericReply = async (notificationProps) => { ...tagReplyingBlog ? [replyingBlog.name] : [], ].join(','); + // never a reblog (always a new post) 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 +172,152 @@ 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}`; + + const tryToReblog = defaultMode === 'reblog'; + return createReplyPost({ type, replyingBlogName, replyingBlogUuid, targetPostSummary, targetPostUrl, replyContent, targetTumblelogName, targetPostId, tryToReblog }); +}; + +const getReblogData = async ({ targetTumblelogName, targetPostId }) => { + try { + const { blog, canReblog, id: parentPostId, blog: { uuid: parentTumblelogUUID }, reblogKey } = await apiFetch(`/v2/blog/${targetTumblelogName}/posts/${targetPostId}`).then(({ response }) => response); + if (canReblog !== false && !blog?.isPasswordProtected) { + return { parentPostId, parentTumblelogUUID, reblogKey }; + } + } catch {} + return new Promise(resolve => + showModal({ + title: 'Cannot reblog', + message: ['The target post cannot be reblogged!'], + buttons: [ + modalCancelButton, + dom('button', { class: 'blue' }, { + click () { + hideModal(); + resolve(false); + }, + }, ['Reply in a new post']), + ], + }), + ); +}; + +const createReplyPost = async ({ type, replyingBlogName, replyingBlogUuid, targetPostSummary, targetPostUrl, replyContent, targetTumblelogName, targetPostId, tryToReblog }) => { + const reblogData = tryToReblog && await getReblogData({ targetTumblelogName, targetPostId }); + if (reblogData) { + const { parentPostId, parentTumblelogUUID, reblogKey } = reblogData; + + const text = `@${replyingBlogName} replied:`; + const formatting = [ + { start: 0, end: replyingBlogName.length + 1, type: 'mention', blog: { uuid: replyingBlogUuid } }, + ]; + + const content = [ + { type: 'text', text, formatting }, + Object.assign(replyContent[0], { subtype: 'indented' }), + { type: 'text', text: '\u200B' }, + ]; + const tags = [ + ...tagReplyingBlog ? [replyingBlogName] : [], + ].join(','); + + return { + content, + tags, + parent_post_id: parentPostId, + parent_tumblelog_uuid: parentTumblelogUUID, + reblog_key: reblogKey, + }; + } + 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); - const { content, tags } = notificationProps.type === 'generic' - ? await processGenericReply(notificationProps) - : await processReply(notificationProps); + openQuoteReplyDraft(tumblelogName, replyPost); +}; - const { response: { id: responseId, displayText } } = await apiFetch(`/v2/blog/${uuid}/posts`, { method: 'POST', body: { content, state: 'draft', tags } }); +const determineNoteReplyType = ({ noteProps, parentNoteProps }) => { + if (userBlogNames.includes(noteProps.note.blogName)) return false; + if (noteProps.communityId) return false; + + 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 }, tryToReblog) => { + const { + noteProps: { + note: { blogName: replyingBlogName, content: replyContent }, + }, + } = currentTarget.__notePropsData; + + const { type, targetBlogName } = determineNoteReplyType(currentTarget.__notePropsData); + + const { + summary: targetPostSummary, + postUrl: targetPostUrl, + blogName: targetTumblelogName, + id: targetPostId, + } = await timelineObject(currentTarget.closest(keyToCss('meatballMenu'))); + const replyingBlogUuid = await apiFetch(`/v2/blog/${replyingBlogName}/info?fields[blogs]=uuid`) + .then(({ response: { blog: { uuid } } }) => uuid); + + const replyPost = await createReplyPost({ type, replyingBlogName, replyingBlogUuid, targetPostSummary, targetPostUrl, replyContent, targetTumblelogName, targetPostId, tryToReblog }); + 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}`; @@ -216,10 +336,24 @@ const quoteReply = async (tumblelogName, notificationProps) => { export const main = async function () { ({ [originalPostTagStorageKey]: originalPostTag } = await browser.storage.local.get(originalPostTagStorageKey)); - ({ tagReplyingBlog, newTab } = await getPreferences('quote_replies')); + ({ defaultMode, tagReplyingBlog, newTab } = await getPreferences('quote_replies')); pageModifications.register(notificationSelector, processNotifications); + registerReplyMeatballItem({ + id: defaultMode === 'reblog' ? reblogMeatballButtonId : newPostMeatballButtonId, + label: `Quote this reply (${defaultMode === 'reblog' ? 'as reblog' : 'as new post'})`, + notePropsFilter: notePropsData => Boolean(determineNoteReplyType(notePropsData)), + onclick: event => quoteNoteReply(event, defaultMode === 'reblog').catch(showErrorModal), + }); + + registerReplyMeatballItem({ + id: defaultMode !== 'reblog' ? reblogMeatballButtonId : newPostMeatballButtonId, + label: `Quote this reply (${defaultMode !== 'reblog' ? 'as reblog' : 'as new post'})`, + notePropsFilter: notePropsData => Boolean(determineNoteReplyType(notePropsData)), + onclick: event => quoteNoteReply(event, defaultMode !== 'reblog').catch(showErrorModal), + }); + const { [storageKey]: draftLocation } = await browser.storage.local.get(storageKey); browser.storage.local.remove(storageKey); @@ -230,6 +364,9 @@ export const main = async function () { export const clean = async function () { pageModifications.unregister(processNotifications); + unregisterReplyMeatballItem(newPostMeatballButtonId); + unregisterReplyMeatballItem(reblogMeatballButtonId); + $(`.${buttonClass}`).remove(); }; @@ -239,6 +376,6 @@ export const onStorageChanged = async function (changes) { } if (Object.keys(changes).some(key => key.startsWith('quote_replies.preferences'))) { - ({ tagReplyingBlog, newTab } = await getPreferences('quote_replies')); + clean().then(main); } }; 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.