Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
a7cecbc
implement meatball button
marcustyphoon Feb 16, 2025
ae0d550
meatballs: fix onclick prop capitalization
marcustyphoon Jan 6, 2025
1511fa6
implement functionality
marcustyphoon Jan 6, 2025
56e824a
remove debug label
marcustyphoon Jan 6, 2025
551bf9e
misc cleanup
marcustyphoon Jan 27, 2025
6085540
temporarily revert quote replies.js changes
marcustyphoon Apr 28, 2025
8df8fde
Merge branch 'master' into quote-replies-post-notes
marcustyphoon Apr 28, 2025
198f41c
reimplement quote replies.js changes
marcustyphoon Apr 28, 2025
1bdaa7c
Merge remote-tracking branch 'upstream/master' into quote-replies-pos…
marcustyphoon Jun 30, 2025
393b6cd
Merge remote-tracking branch 'upstream/master' into quote-replies-pos…
marcustyphoon Aug 7, 2025
9c5a899
partial refactor to api call
marcustyphoon Aug 7, 2025
887a4d9
Revert "partial refactor to api call"
marcustyphoon Aug 8, 2025
2c16d8c
refactor: name parent note props explictly
marcustyphoon Aug 8, 2025
618d045
refactor: DRY
marcustyphoon Aug 8, 2025
f7990fe
refactor: Renames
marcustyphoon Aug 8, 2025
2dd72fe
refactor: combine content and tags
marcustyphoon Aug 9, 2025
511599f
Merge branch 'master' into quote-replies-post-notes
marcustyphoon Oct 25, 2025
d075256
update for new footer
marcustyphoon Oct 25, 2025
1d511a6
Merge remote-tracking branch 'upstream/master' into quote-replies-pos…
marcustyphoon Nov 3, 2025
48327ea
update
marcustyphoon Nov 3, 2025
505fbb4
Merge branch 'master' into quote-replies-post-notes
marcustyphoon Feb 16, 2026
6b624dc
use getClosestRenderedElement
marcustyphoon Feb 16, 2026
eaa6a0c
Merge branch 'master' into quote-replies-post-notes
marcustyphoon Feb 16, 2026
5d48cad
fix commas
marcustyphoon Feb 16, 2026
9ec059e
Revert "fix commas"
marcustyphoon Feb 16, 2026
488c8be
Reapply "fix commas"
marcustyphoon Feb 16, 2026
59ac40a
Merge remote-tracking branch 'upstream/master' into quote-replies-pos…
marcustyphoon Feb 16, 2026
be24799
organize imports
marcustyphoon Feb 16, 2026
dbf2d5d
remove weakMemoize
marcustyphoon Feb 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 85 additions & 16 deletions src/features/quote_replies/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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; });
},
Expand All @@ -102,7 +105,7 @@ const processNotifications = notifications => notifications.forEach(async notifi
));
});

const processGenericReply = async (notificationProps) => {
const createGenericActivityReplyPost = async (notificationProps) => {
const {
subtype: type,
timestamp,
Expand All @@ -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');
Expand Down Expand Up @@ -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` } },
Expand All @@ -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;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting note from testing: this line excludes quote replying yourself, which probably makes sense in general... but a) maybe it doesn't (something something group blogs), and b) that's inconsistent with the behavior on activity items, which totally does let you quote reply yourself!

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}`;

Expand All @@ -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);

Expand All @@ -230,6 +297,8 @@ export const main = async function () {

export const clean = async function () {
pageModifications.unregister(processNotifications);
unregisterReplyMeatballItem(meatballButtonId);

$(`.${buttonClass}`).remove();
};

Expand Down
18 changes: 18 additions & 0 deletions src/main_world/unbury_note_props.js
Original file line number Diff line number Diff line change
@@ -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 };
}
35 changes: 34 additions & 1 deletion src/utils/meatballs.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@ 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')}`;

const meatballItems = {
post: {},
blog: {},
reply: {},
};

/**
Expand Down Expand Up @@ -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)) {
Expand All @@ -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',
});
}
}
});

Expand Down
16 changes: 16 additions & 0 deletions src/utils/react_props.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotePropsData>} 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<object>} The post's buried blog or blogSettings property. Some blog data fields, such as "followed," are not available in blog cards.
Expand Down