Skip to content
Open
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
43 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
55c324c
implement buttons instead of meatball items
marcustyphoon Feb 16, 2026
c5b8169
refactor: rename vars
marcustyphoon Feb 16, 2026
645cd41
refactor: rename more things
marcustyphoon Feb 16, 2026
1215c4e
refactor: rename more things
marcustyphoon Feb 16, 2026
0f0b903
remove duplicate css
marcustyphoon Feb 16, 2026
20ea8c7
simplify react prop fetching (traverse dom instead of react)
marcustyphoon Mar 2, 2026
f243255
Merge remote-tracking branch 'upstream/master' into quote-replies-pos…
marcustyphoon Mar 2, 2026
85abac2
tweak button classes
marcustyphoon Mar 2, 2026
7d4c693
tweak comment
marcustyphoon Mar 2, 2026
4affd40
simplify react prop fetching more; use timelineObject instead
marcustyphoon Mar 2, 2026
1c34fa7
comment note reply types
marcustyphoon Mar 2, 2026
7159005
rearrange functions
marcustyphoon Mar 2, 2026
3fde53d
Revert "refactor(Quote Replies): Rename/organize internal functions (…
marcustyphoon Mar 4, 2026
989e3cc
Merge branch 'master' into quote-replies-post-notes-button
marcustyphoon Mar 4, 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
139 changes: 114 additions & 25 deletions src/features/quote_replies/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,30 @@ 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 { notePropsObject, 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 dropdownButtonClass = 'xkit-quote-replies-dropdown';
const notificationButtonClass = 'xkit-quote-replies-notification';
const notificationDropdownButtonClass = 'xkit-quote-replies-notification-dropdown';

// Remove outdated elements when loading module
$(`.${buttonClass}`).remove();

export const styleElement = buildStyle(`
button.xkit-quote-replies {
align-items: center;
cursor: pointer;
}

button.xkit-quote-replies-notification {
position: relative;
align-self: center;
transform: translateY(-2px);

align-items: center;
margin: 0 6px;

cursor: pointer;
}

button.xkit-quote-replies svg {
Expand All @@ -42,18 +45,18 @@ button.xkit-quote-replies:disabled svg {
transition-property: none;
}

button.xkit-quote-replies-dropdown {
button.xkit-quote-replies-notification-dropdown {
align-self: flex-start;
margin: 10px 0 0;
}

@media (hover: hover) {
button.xkit-quote-replies svg {
button.xkit-quote-replies-notification svg {
opacity: 0;
transform: scale(0);
}

${notificationSelector}:is(:hover, :focus-within) button.xkit-quote-replies svg {
${notificationSelector}:is(:hover, :focus-within) button.xkit-quote-replies-notification svg {
opacity: 1;
transform: scale(1);
}
Expand Down Expand Up @@ -86,14 +89,44 @@ const processNotifications = notifications => notifications.forEach(async notifi
activityElement.after(dom(
'button',
{
class: `${buttonClass} ${notification.matches(dropdownSelector) ? dropdownButtonClass : ''}`,
class: `${buttonClass} ${notificationButtonClass} ${notification.matches(dropdownSelector) ? notificationDropdownButtonClass : ''}`,
[displayInlineFlexUnlessDisabledAttr]: '',
title: 'Quote this reply',
},
{
click () {
this.disabled = true;
quoteNotificationReply(tumblelogName, notificationProps)
.catch(showErrorModal)
.finally(() => { this.disabled = false; });
},
},
[buildSvg('ri-chat-quote-line')],
));
});

const processNoteReplyButtons = noteReplyButtons => noteReplyButtons.forEach(async noteReplyButton => {
const noteProps = await notePropsObject(noteReplyButton);

const parentNoteElement = noteReplyButton.closest(keyToCss('threadedRepliesWrapper'))?.previousElementSibling;
const parentNoteProps = parentNoteElement ? await notePropsObject(parentNoteElement) : undefined;

const noteReplyType = determineNoteReplyType({ noteProps, parentNoteProps });
if (!noteReplyType) return;

const timelineObjectData = await timelineObject(noteReplyButton);

noteReplyButton.parentElement.append(dom(
'button',
{
class: buttonClass,
[displayInlineFlexUnlessDisabledAttr]: '',
title: 'Quote this reply',
},
{
click () {
this.disabled = true;
quoteReply(tumblelogName, notificationProps)
quoteNoteReply({ noteProps, noteReplyType, timelineObjectData })
.catch(showErrorModal)
.finally(() => { this.disabled = false; });
},
Expand All @@ -102,7 +135,7 @@ const processNotifications = notifications => notifications.forEach(async notifi
));
});

const processGenericReply = async (notificationProps) => {
const createGenericNotificationReplyData = async (notificationProps) => {
const {
subtype: type,
timestamp,
Expand All @@ -120,7 +153,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 createNotificationReplyData({ 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 +186,7 @@ const processGenericReply = async (notificationProps) => {
return { content, tags };
};

const processReply = async ({ type, timestamp, targetPostId, targetTumblelogName, targetPostSummary }) => {
const createNotificationReplyData = 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 +199,91 @@ 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 createReplyData({ type, replyingBlogName, replyingBlogUuid, targetPostSummary, targetPostUrl, replyContent });
};

const createReplyData = ({ 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 quoteNotificationReply = async (tumblelogName, notificationProps) => {
const data = notificationProps.type === 'generic'
? await createGenericNotificationReplyData(notificationProps)
: await createNotificationReplyData(notificationProps);

openPostDraft(tumblelogName, data);
};

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!

(comment copied from previous PR)

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 ({ noteProps, noteReplyType, timelineObjectData }) => {
const { note: { blogName: replyingBlogName, content: replyContent } } = noteProps;

const { content, tags } = notificationProps.type === 'generic'
? await processGenericReply(notificationProps)
: await processReply(notificationProps);
const { type, targetBlogName } = noteReplyType;

const { response: { id: responseId, displayText } } = await apiFetch(`/v2/blog/${uuid}/posts`, { method: 'POST', body: { content, state: 'draft', tags } });
const { summary: targetPostSummary, postUrl: targetPostUrl } = timelineObjectData;
const replyingBlogUuid = await apiFetch(`/v2/blog/${replyingBlogName}/info?fields[blogs]=uuid`)
.then(({ response: { blog: { uuid } } }) => uuid);

const data = createReplyData({ type, replyingBlogName, replyingBlogUuid, targetPostSummary, targetPostUrl, replyContent });
openPostDraft(targetBlogName, data);
};

const openPostDraft = async (tumblelogName, data) => {
const uuid = userBlogs.find(({ name }) => name === tumblelogName).uuid;

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

const currentDraftLocation = `/edit/${tumblelogName}/${responseId}`;

Expand All @@ -219,6 +305,7 @@ export const main = async function () {
({ tagReplyingBlog, newTab } = await getPreferences('quote_replies'));

pageModifications.register(notificationSelector, processNotifications);
pageModifications.register(`${keyToCss('replyCountButton')}:has(use[href="#managed-icon__ds-reply-outline-16"])`, processNoteReplyButtons);

const { [storageKey]: draftLocation } = await browser.storage.local.get(storageKey);
browser.storage.local.remove(storageKey);
Expand All @@ -230,6 +317,8 @@ export const main = async function () {

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

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

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

while (fiber !== null) {
const props = fiber.memoizedProps || {};
// requiring rootReplyId ensures that we return the same props signature on parent and child replies
if (props.rootReplyId && props.note?.replyId) {
return props;
}
fiber = fiber.return;
}
}
9 changes: 9 additions & 0 deletions src/utils/react_props.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export const notificationObject = notificationElement => {
return notificationElement.notificationObjectPromise;
};

/**
* @param {Element} noteElement An on-screen post note element
* @returns {Promise<object>} The element's buried note component props
*/
export const notePropsObject = noteElement => {
noteElement.notePropsObjectPromise ??= inject('/main_world/unbury_note_props.js', [], noteElement);
return noteElement.notePropsObjectPromise;
};

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