Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Quote Replies: Add action buttons to replies in post notes #1662

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
87 changes: 78 additions & 9 deletions src/features/quote_replies.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';

const originalPostTagStorageKey = 'quick_tags.preferences.originalPostTag';
Expand Down Expand Up @@ -57,7 +60,7 @@ const processNotifications = notifications => notifications.forEach(async notifi

const quoteReply = async (tumblelogName, notificationProps) => {
const uuid = userBlogs.find(({ name }) => name === tumblelogName).uuid;
const { type, targetPostId, targetPostSummary, targetTumblelogName, targetTumblelogUuid, timestamp } = notificationProps;
const { type, targetPostId, targetPostSummary, postUrl, targetTumblelogUuid, timestamp } = notificationProps;

const { response } = await apiFetch(
`/v2/blog/${targetTumblelogUuid}/post/${targetPostId}/notes/timeline`,
Expand All @@ -69,15 +72,28 @@ const quoteReply = async (tumblelogName, notificationProps) => {
if (!reply) throw new Error('No replies found on target post.');
if (Math.floor(reply.timestamp) !== timestamp) throw new Error('Reply not found.');

openQuoteReplyPost({
type,
replyingBlogName: reply.blog.name,
replyingBlogUuid: reply.blog.uuid,
reply,
postSummary: targetPostSummary,
postUrl,
targetBlogUuid: uuid,
targetBlogName: tumblelogName
});
};

const openQuoteReplyPost = async ({ type, replyingBlogName, replyingBlogUuid, postSummary, postUrl, reply, targetBlogUuid, targetBlogName }) => {
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${postSummary.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: postUrl }
];

const content = [
Expand All @@ -87,17 +103,17 @@ const quoteReply = async (tumblelogName, notificationProps) => {
];
const tags = [
...originalPostTag ? [originalPostTag] : [],
...tagReplyingBlog ? [reply.blog.name] : []
...tagReplyingBlog ? [replyingBlogName] : []
].join(',');

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

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

if (newTab) {
await browser.storage.local.set({ [storageKey]: currentDraftLocation });

const openedTab = window.open(`/blog/${tumblelogName}/drafts`);
const openedTab = window.open(`/blog/${targetBlogName}/drafts`);
if (openedTab === null) {
browser.storage.local.remove(storageKey);
notify(displayText);
Expand All @@ -107,12 +123,63 @@ const quoteReply = async (tumblelogName, notificationProps) => {
}
};

const processNoteProps = ([noteProps, parentNoteProps]) => {
if (userBlogNames.includes(noteProps.note.blogName) || 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 onMeatballButtonClicked = async ({ currentTarget }) => {
const [{ note: reply }] = currentTarget.__notePropsData;

const { type, targetBlogName } = processNoteProps(currentTarget.__notePropsData);
const targetBlogUuid = userBlogs.find(({ name }) => name === targetBlogName).uuid;

const { summary: postSummary, postUrl } = await timelineObject(currentTarget.closest(keyToCss('meatballMenu')));

const replyingBlogName = reply.blogName;
const replyingBlogUuid = await apiFetch(`/v2/blog/${replyingBlogName}/info?fields[blogs]=uuid`)
.then(({ response: { blog: { uuid } } }) => uuid);

openQuoteReplyPost({ type, replyingBlogName, replyingBlogUuid, reply, postSummary, postUrl, targetBlogUuid, targetBlogName });
};

export const main = async function () {
({ [originalPostTagStorageKey]: originalPostTag } = await browser.storage.local.get(originalPostTagStorageKey));
({ tagReplyingBlog, newTab } = await getPreferences('quote_replies'));

pageModifications.register(notificationSelector, processNotifications);

registerReplyMeatballItem({
id: meatballButtonId,
label: 'Quote this reply',
notePropsFilter: notePropsData => Boolean(processNoteProps(notePropsData)),
onclick: event => onMeatballButtonClicked(event).catch(showErrorModal)
});

const { [storageKey]: draftLocation } = await browser.storage.local.get(storageKey);
browser.storage.local.remove(storageKey);

Expand All @@ -123,6 +190,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;
}
}
}
17 changes: 17 additions & 0 deletions src/main_world/unbury_note_props.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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;
}
return Object.values(resultsByReplyId);
}
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[0]?.note?.type === 'reply') {
addTypedMeatballItems({
meatballMenu,
type: 'reply',
reactData: __notePropsData,
reactDataKey: '__notePropsData'
});
}
}
});

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

/**
* @param {Element} noteElement - An on-screen post note element
* @returns {Promise<object[]>} - An array 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