Skip to content
Draft
Show file tree
Hide file tree
Changes from 15 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
99 changes: 84 additions & 15 deletions src/features/quote_replies/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import { notify } from '../../utils/notifications.js';
import { getPreferences } from '../../utils/preferences.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';
import { registerReplyMeatballItem, unregisterReplyMeatballItem } from '../../utils/meatballs.js';
import { timelineObject } from '../../utils/react_props.js';

const storageKey = 'quote_replies.draftLocation';
const buttonClass = 'xkit-quote-replies';
const meatballButtonId = 'quote-replies';
const dropdownButtonClass = 'xkit-quote-replies-dropdown';

export const styleElement = buildStyle(`
Expand Down Expand Up @@ -90,7 +93,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 @@ -99,7 +102,7 @@ const processNotifications = notifications => notifications.forEach(async notifi
));
});

const processGenericReply = async (notificationProps) => {
const createGenericActivityReplyPost = async (notificationProps) => {
const {
subtype: type,
timestamp,
Expand All @@ -117,7 +120,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 @@ -150,7 +153,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 @@ -163,36 +166,93 @@ 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 { content, tags } = notificationProps.type === 'generic'
? await processGenericReply(notificationProps)
: await processReply(notificationProps);
? await createGenericActivityReplyPost(notificationProps)
: await createActivityReplyPost(notificationProps);

openQuoteReplyDraft(tumblelogName, content, tags);
};

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;

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 { content, tags } = createReplyPost({ type, replyingBlogName, replyingBlogUuid, targetPostSummary, targetPostUrl, replyContent });
openQuoteReplyDraft(targetBlogName, content, tags);
};

const openQuoteReplyDraft = async (tumblelogName, content, tags) => {
const uuid = userBlogs.find(({ name }) => name === tumblelogName).uuid;

const { response: { id: responseId, displayText } } = await apiFetch(`/v2/blog/${uuid}/posts`, { method: 'POST', body: { content, state: 'draft', tags } });

Expand All @@ -217,6 +277,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 @@ -227,6 +294,8 @@ export const main = async function () {

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

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

Expand Down
13 changes: 13 additions & 0 deletions src/main_world/test_parent_element.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export default function testParentElement (selector) {
const element = this;
const reactKey = Object.keys(element).find(key => key.startsWith('__reactFiber'));
let fiber = element[reactKey];

while (fiber !== null) {
if (fiber.stateNode?.matches?.(selector)) {
return true;
} else {
fiber = fiber.return;
}
}
}
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 };
}
37 changes: 35 additions & 2 deletions src/utils/meatballs.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import { dom } from './dom.js';
import { postSelector } from './interface.js';
import { pageModifications } from './mutations.js';
import { inject } from './inject.js';
import { blogData, timelineObject } from './react_props.js';
import { blogData, notePropsObjects, timelineObject } from './react_props.js';

const postHeaderSelector = `${postSelector} article > header`;
const blogHeaderSelector = `[style*="--blog-title-color"] > div > div > header, ${keyToCss('blogCardHeaderBar')}`;

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

/**
Expand Down Expand Up @@ -49,6 +50,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 inPostHeader = await inject('/main_world/test_header_element.js', [postHeaderSelector], meatballMenu);
if (inPostHeader) {
Expand All @@ -68,6 +87,20 @@ const addMeatballItems = meatballMenus => meatballMenus.forEach(async meatballMe
reactData: await blogData(meatballMenu),
reactDataKey: '__blogData'
});
return;
}
const inPostFooter = await inject('/main_world/test_parent_element.js', ['footer *'], meatballMenu);
if (inPostFooter) {
const __notePropsData = await notePropsObjects(meatballMenu);

if (__notePropsData?.noteProps?.note?.type === 'reply') {
addTypedMeatballItems({
meatballMenu,
type: 'reply',
reactData: __notePropsData,
reactDataKey: '__notePropsData'
});
}
}
});

Expand Down
15 changes: 15 additions & 0 deletions src/utils/react_props.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,21 @@ export const notificationObject = weakMemoize(notificationElement =>
inject('/main_world/unbury_notification.js', [], notificationElement)
);

/**
* @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 = weakMemoize(noteElement =>
inject('/main_world/unbury_note_props.js', [], noteElement)
);

/**
* @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