diff --git a/src/features/index.json b/src/features/index.json index 35b7c4e1f3..bf706f3abf 100644 --- a/src/features/index.json +++ b/src/features/index.json @@ -11,6 +11,7 @@ "mass_privater", "mass_unliker", "mirror_posts", + "mute", "mutual_checker", "no_recommended", "notificationblock", diff --git a/src/features/mute/feature.json b/src/features/mute/feature.json new file mode 100644 index 0000000000..562d35ff6f --- /dev/null +++ b/src/features/mute/feature.json @@ -0,0 +1,26 @@ +{ + "title": "Mute", + "description": "Hide users' posts all over Tumblr", + "icon": { + "class_name": "ri-volume-mute-line", + "color": "white", + "background_color": "#C20044" + }, + "relatedTerms": [ "Blacklist", "Filter", "Savior" ], + "preferences": { + "manageBlockedPosts": { + "type": "component", + "src": "/features/mute/options/index.js" + }, + "checkTrail": { + "type": "checkbox", + "label": "Hide posts with a muted user anywhere in the trail", + "default": false + }, + "contributedContentOriginal": { + "type": "checkbox", + "label": "Treat reblogs with contributed content as original", + "default": false + } + } +} diff --git a/src/features/mute/index.css b/src/features/mute/index.css new file mode 100644 index 0000000000..4046a0545c --- /dev/null +++ b/src/features/mute/index.css @@ -0,0 +1,33 @@ +[data-mute-active] [data-mute-hidden], +[data-muted-blog-controls-mode='original'] ~ div [data-muted-blog-controls-hidden], +[data-muted-blog-controls-mode='reblogged'] ~ div [data-muted-blog-controls-hidden] { + content: linear-gradient(transparent, transparent); + height: 0; +} + +/* prevents endless post loading by preserving post height */ +[data-muted-blog-controls-mode='all'] ~ div article, +[data-muted-blog-controls-mode='all'] ~ div article :is(img, video, canvas) { + visibility: hidden !important; +} + +.xkit-mute-lengthened { + min-height: 100vh; +} + +.xkit-muted-blog-controls { + padding: 25px 20px; + border-radius: 3px; + margin-bottom: var(--post-padding); + + background-color: var(--blog-title-color-15, rgba(var(--white-on-dark), 0.25)); + color: var(--blog-title-color, rgba(var(--white-on-dark))); + + font-weight: 700; + text-align: center; + line-height: 1.5em; +} + +.xkit-muted-blog-controls button { + color: var(--blog-link-color, rgb(var(--deprecated-accent))); +} diff --git a/src/features/mute/index.js b/src/features/mute/index.js new file mode 100644 index 0000000000..ca9e172587 --- /dev/null +++ b/src/features/mute/index.js @@ -0,0 +1,304 @@ +import { keyToCss } from '../../utils/css_map.js'; +import { br, button, div, form, input, label } from '../../utils/dom.js'; +import { filterPostElements, getTimelineItemWrapper, postSelector } from '../../utils/interface.js'; +import { registerBlogMeatballItem, registerMeatballItem, unregisterBlogMeatballItem, unregisterMeatballItem } from '../../utils/meatballs.js'; +import { hideModal, modalCancelButton, showModal } from '../../utils/modals.js'; +import { onNewPosts, pageModifications } from '../../utils/mutations.js'; +import { getPreferences } from '../../utils/preferences.js'; +import { timelineObject } from '../../utils/react_props.js'; +import { + anyBlogTimelineFilter, + anyFlaggedReviewTimelineFilter, + anyPostPermalinkTimelineFilter, + blogTimelineFilter, + inboxTimelineFilter, + likesTimelineFilter, + peeprLikesTimelineFilter, + timelineSelector, +} from '../../utils/timeline_id.js'; +import { userBlogNames } from '../../utils/user.js'; +import { controlsClass as showOriginalsControlsClass } from '../show_originals/index.js'; + +const meatballButtonId = 'mute'; +const meatballButtonLabel = data => `Mute options for ${data.name ?? getVisibleBlog(data).name}`; + +const hiddenAttribute = 'data-mute-hidden'; +const mutedBlogControlsHiddenAttribute = 'data-muted-blog-controls-hidden'; +const activeAttribute = 'data-mute-active'; +const mutedBlogControlsClass = 'xkit-muted-blog-controls'; +const lengthenedClass = 'xkit-mute-lengthened'; + +const blogNamesStorageKey = 'mute.blogNames'; +const mutedBlogEntriesStorageKey = 'mute.mutedBlogEntries'; + +let checkTrail; +let contributedContentOriginal; + +let blogNames = {}; +let mutedBlogs = {}; + +const lengthenTimeline = timeline => { + if (!timeline.querySelector(keyToCss('manualPaginatorButtons'))) { + timeline.classList.add(lengthenedClass); + } +}; + +// Attempts to get the blog name and blog UUID of a timeline element if it contains the posts from a single blog. +// The element itself doesn't contain both values, so a post object must be found with the required data. +const getNameAndUuid = async timelineElement => { + for (const post of [...timelineElement.querySelectorAll(postSelector)]) { + const { blog: { name, uuid } } = await timelineObject(post); + if ( + blogTimelineFilter(name)(timelineElement) || + blogTimelineFilter(uuid)(timelineElement) + ) { + return { name, uuid }; + } + } + throw new Error('could not determine blog name / UUID for timeline element:', timelineElement); +}; + +const processBlogTimelineElement = async timelineElement => { + const { name, uuid } = await getNameAndUuid(timelineElement); + const mutedBlogMode = mutedBlogs[uuid]; + + if (mutedBlogMode) { + timelineElement.dataset.muteBlogUuid = uuid; + + const mutedBlogControls = div({ class: mutedBlogControlsClass, 'data-muted-blog-controls-mode': mutedBlogMode }, [ + `You have muted ${mutedBlogMode} posts from ${name}!`, + br(), + button({ click: () => mutedBlogControls.remove() }, ['show posts anyway']), + ]); + timelineElement.prepend(mutedBlogControls); + timelineElement.querySelector(`.${showOriginalsControlsClass}`)?.after(mutedBlogControls); + } +}; + +const shouldDisable = timelineElement => Boolean( + userBlogNames.some(name => blogTimelineFilter(name)(timelineElement)) || + anyFlaggedReviewTimelineFilter(timelineElement) || + likesTimelineFilter(timelineElement) || + userBlogNames.some(name => peeprLikesTimelineFilter(name)(timelineElement)) || + inboxTimelineFilter(timelineElement) || + anyPostPermalinkTimelineFilter(timelineElement), +); + +const processTimelines = async timelineElements => { + for (const timelineElement of [...new Set(timelineElements)]) { + const { timeline, timelineId, muteProcessedTimeline, muteProcessedTimelineId } = timelineElement.dataset; + + const alreadyProcessed = + (timeline && timeline === muteProcessedTimeline) || + (timelineId && timelineId === muteProcessedTimelineId); + if (alreadyProcessed) continue; + + timelineElement.dataset.muteProcessedTimeline = timeline; + timelineElement.dataset.muteProcessedTimelineId = timelineId; + + [...timelineElement.querySelectorAll(`.${mutedBlogControlsClass}`)].forEach(el => el.remove()); + delete timelineElement.dataset.muteBlogUuid; + timelineElement.removeAttribute(activeAttribute); + + if (shouldDisable(timelineElement) === false) { + timelineElement.setAttribute(activeAttribute, ''); + lengthenTimeline(timelineElement); + + if (anyBlogTimelineFilter(timelineElement)) { + await processBlogTimelineElement(timelineElement).catch(console.log); + } + } + } +}; + +const updateStoredName = (uuid, name) => { + blogNames[uuid] = name; + Object.keys(blogNames).forEach(uuid => { + if (!mutedBlogs[uuid]) { + delete blogNames[uuid]; + } + }); + browser.storage.local.set({ [blogNamesStorageKey]: blogNames }); +}; + +const getVisibleBlog = ({ blog, authorBlog, community }) => (community ? authorBlog : blog); + +const processPosts = async function (postElements) { + await processTimelines(postElements.map(postElement => postElement.closest(timelineSelector))); + + filterPostElements(postElements, { includeFiltered: true }).forEach(async postElement => { + const timelineObjectData = await timelineObject(postElement); + const { uuid, name } = getVisibleBlog(timelineObjectData); + const { rebloggedRootUuid, content = [], trail = [] } = timelineObjectData; + + const { muteBlogUuid: timelineBlogUuid } = postElement.closest(timelineSelector).dataset; + + if (mutedBlogs[uuid] && blogNames[uuid] !== name) { + updateStoredName(uuid, name); + } + + const hidePost = relevantBlogUuid => + getTimelineItemWrapper(postElement).setAttribute( + // Posts hidden on blog timelines can be revealed by muted blog timeline controls + // if and only if they are hidden because the current blog is muted. + relevantBlogUuid === timelineBlogUuid + ? mutedBlogControlsHiddenAttribute + : hiddenAttribute, + '', + ); + + const isRebloggedPost = contributedContentOriginal + ? rebloggedRootUuid && !content.length + : rebloggedRootUuid; + + const originalUuid = isRebloggedPost ? rebloggedRootUuid : uuid; + const reblogUuid = isRebloggedPost ? uuid : null; + + if (['all', 'original'].includes(mutedBlogs[originalUuid])) { + hidePost(originalUuid); + } + if (['all', 'reblogged'].includes(mutedBlogs[reblogUuid])) { + hidePost(reblogUuid); + } + + if (checkTrail) { + for (const { blog } of trail) { + if (['all'].includes(mutedBlogs[blog?.uuid])) { + hidePost(blog.uuid); + } + } + } + }); +}; + +const onMeatballButtonClicked = function ({ currentTarget }) { + const { name, uuid } = currentTarget.__timelineObjectData + ? getVisibleBlog(currentTarget.__timelineObjectData) + : currentTarget.__blogData; + + const currentMode = mutedBlogs[uuid]; + + const createRadioElement = value => + label({}, [ + `Hide ${value} posts`, + input({ type: 'radio', name: 'muteOption', value }), + ]); + + const formElement = form( + { id: 'xkit-mute-form', 'data-name': name, 'data-uuid': uuid, submit: muteUser }, + [ + createRadioElement('all'), + createRadioElement('original'), + createRadioElement('reblogged'), + ], + ); + + formElement.elements.muteOption.value = currentMode; + + currentMode + ? showModal({ + title: `Mute options for ${name}:`, + message: [formElement], + buttons: [ + modalCancelButton, + button({ class: 'blue', click: () => unmuteUser(uuid) }, ['Unmute']), + input({ type: 'submit', form: formElement.id, class: 'red', value: 'Update Mode' }), + ], + }) + : showModal({ + title: `Mute ${name}?`, + message: [formElement], + buttons: [ + modalCancelButton, + input({ type: 'submit', form: formElement.id, class: 'red', value: 'Mute' }), + ], + }); +}; + +const muteUser = event => { + event.preventDefault(); + + const { name, uuid } = event.currentTarget.dataset; + const { value } = event.currentTarget.elements.muteOption; + if (value === '') return; + + mutedBlogs[uuid] = value; + blogNames[uuid] = name; + + browser.storage.local.set({ + [mutedBlogEntriesStorageKey]: Object.entries(mutedBlogs), + [blogNamesStorageKey]: blogNames, + }); + + hideModal(); +}; + +const unmuteUser = uuid => { + delete mutedBlogs[uuid]; + browser.storage.local.set({ [mutedBlogEntriesStorageKey]: Object.entries(mutedBlogs) }); + + hideModal(); +}; + +export const onStorageChanged = async function (changes, areaName) { + const { + [blogNamesStorageKey]: blogNamesChanges, + [mutedBlogEntriesStorageKey]: mutedBlogsEntriesChanges, + } = changes; + + if (Object.keys(changes).some(key => key.startsWith('mute.preferences') && changes[key].oldValue !== undefined)) { + clean().then(main); + return; + } + + if (blogNamesChanges) { + ({ newValue: blogNames } = blogNamesChanges); + } + + if (mutedBlogsEntriesChanges) { + const { newValue: mutedBlogsEntries } = mutedBlogsEntriesChanges; + mutedBlogs = Object.fromEntries(mutedBlogsEntries ?? []); + + unprocess(); + pageModifications.trigger(processPosts); + } +}; + +export const main = async function () { + ({ checkTrail, contributedContentOriginal } = await getPreferences('mute')); + ({ [blogNamesStorageKey]: blogNames = {} } = await browser.storage.local.get(blogNamesStorageKey)); + const { [mutedBlogEntriesStorageKey]: mutedBlogsEntries } = await browser.storage.local.get(mutedBlogEntriesStorageKey); + mutedBlogs = Object.fromEntries(mutedBlogsEntries ?? []); + + registerMeatballItem({ + id: meatballButtonId, + label: meatballButtonLabel, + onclick: onMeatballButtonClicked, + }); + registerBlogMeatballItem({ + id: meatballButtonId, + label: meatballButtonLabel, + onclick: onMeatballButtonClicked, + }); + onNewPosts.addListener(processPosts); +}; + +const unprocess = () => { + $(`[${hiddenAttribute}]`).removeAttr(hiddenAttribute); + $(`[${mutedBlogControlsHiddenAttribute}]`).removeAttr(mutedBlogControlsHiddenAttribute); + $(`[${activeAttribute}]`).removeAttr(activeAttribute); + $(`.${lengthenedClass}`).removeClass(lengthenedClass); + $(`.${mutedBlogControlsClass}`).remove(); + $('[data-mute-processed-timeline]').removeAttr('data-mute-processed-timeline'); + $('[data-mute-processed-timeline-id]').removeAttr('data-mute-processed-timeline-id'); + $('[data-mute-blog-uuid]').removeAttr('data-mute-blog-uuid'); +}; + +export const clean = async function () { + unprocess(); + unregisterMeatballItem(meatballButtonId); + unregisterBlogMeatballItem(meatballButtonId); + onNewPosts.removeListener(processPosts); +}; + +export const stylesheet = true; diff --git a/src/features/mute/options/index.css b/src/features/mute/options/index.css new file mode 100644 index 0000000000..5dbcb2d639 --- /dev/null +++ b/src/features/mute/options/index.css @@ -0,0 +1,47 @@ +:host { + display: block; +} + +#muted-blogs { + --border-width: 1px; + + display: flex; + flex-direction: column; + row-gap: var(--border-width); + padding: 0; + border-radius: var(--border-radius-medium); + margin: 0; + overflow: hidden; + + background-color: var(--border-color); + list-style-type: none; + outline: var(--border-width) solid var(--border-color); + outline-offset: 0; +} + +#muted-blogs:empty, #muted-blogs:not(:empty) + #no-muted-blogs { + display: none; +} + +h3 + #muted-blogs { + margin-block-start: var(--space-medium); +} + +.muted-blog { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + column-gap: var(--space-small); + padding-block: var(--space-small); + padding-inline-start: var(--space-medium); + padding-inline-end: var(--space-small); + margin: 0; + + background-color: var(--background-color-canvas); +} + +.muted-blog > a { + margin-inline-end: auto; + min-width: 0; +} diff --git a/src/features/mute/options/index.js b/src/features/mute/options/index.js new file mode 100644 index 0000000000..1d07e38ec8 --- /dev/null +++ b/src/features/mute/options/index.js @@ -0,0 +1,163 @@ +import { CustomElement, fetchStyleSheets } from '../../../action/components/index.js'; + +const localName = 'mute-muted-users-management'; + +const templateDocument = new DOMParser().parseFromString(` + +`, 'text/html'); + +const adoptedStyleSheets = await fetchStyleSheets([ + '/lib/modern-normalize.css', + '/action/acorn.css', + './index.css', +].map(import.meta.resolve)); + +const blogNamesStorageKey = 'mute.blogNames'; +const mutedBlogsEntriesStorageKey = 'mute.mutedBlogEntries'; + +class MuteMutedUsersElement extends CustomElement { + /** @type {HTMLUListElement} */ #mutedBlogList; + /** @type {HTMLTemplateElement} */ #mutedBlogTemplate; + /** @type {HTMLTemplateElement} */ #unmuteTemplate; + + constructor () { + super(templateDocument, adoptedStyleSheets); + + this.#mutedBlogList = this.shadowRoot.getElementById('muted-blogs'); + this.#mutedBlogTemplate = this.shadowRoot.getElementById('muted-blog'); + this.#unmuteTemplate = this.shadowRoot.getElementById('unmute-template'); + } + + getBlogNames = async () => { + const { [blogNamesStorageKey]: blogNames = {} } = await browser.storage.local.get(blogNamesStorageKey); + return blogNames; + }; + + getMutedBlogs = async () => { + const { [mutedBlogsEntriesStorageKey]: mutedBlogsEntries } = await browser.storage.local.get(mutedBlogsEntriesStorageKey); + return Object.fromEntries(mutedBlogsEntries ?? []); + }; + + setMutedBlogs = mutedBlogs => + browser.storage.local.set({ [mutedBlogsEntriesStorageKey]: Object.entries(mutedBlogs) }); + + /** @type {(event: PointerEvent) => Promise} */ + onUnmuteButtonClick = async ({ currentTarget }) => { + const mutedBlogs = await this.getMutedBlogs(); + const blogNames = await this.getBlogNames(); + + const { uuid } = currentTarget.closest('li').dataset; + + const unmuteTemplateClone = this.#unmuteTemplate.content.cloneNode(true); + + const unmuteDialog = unmuteTemplateClone.getElementById('unmute-dialog'); + const unmuteBlognameDisplay = unmuteTemplateClone.getElementById('unmute-blogname'); + const unmuteCancelButton = unmuteTemplateClone.getElementById('unmute-cancel'); + const unmuteConfirmButton = unmuteTemplateClone.getElementById('unmute-confirm'); + + unmuteBlognameDisplay.textContent = blogNames[uuid]; + + unmuteDialog.addEventListener('close', () => unmuteDialog.remove()); + unmuteCancelButton.addEventListener('click', () => unmuteDialog.close()); + unmuteConfirmButton.addEventListener('click', async () => { + delete mutedBlogs[uuid]; + this.setMutedBlogs(mutedBlogs); + unmuteDialog.close(); + }); + + this.shadowRoot.append(unmuteDialog); + unmuteDialog.showModal(); + }; + + updateMode = async ({ currentTarget }) => { + const mutedBlogs = await this.getMutedBlogs(); + + const { uuid } = currentTarget.closest('li').dataset; + const { value } = currentTarget; + + mutedBlogs[uuid] = value; + this.setMutedBlogs(mutedBlogs); + }; + + renderMutedBlogs = async () => { + const mutedBlogs = await this.getMutedBlogs(); + const blogNames = await this.getBlogNames(); + + this.#mutedBlogList.replaceChildren(...Object.entries(mutedBlogs).map(([uuid, mode]) => { + const templateClone = this.#mutedBlogTemplate.content.cloneNode(true); + const li = templateClone.querySelector('li'); + const linkElement = templateClone.querySelector('a'); + const modeSelect = templateClone.querySelector('select'); + const unmuteButton = templateClone.querySelector('button'); + + li.dataset.uuid = uuid; + + linkElement.textContent = blogNames[uuid] ?? uuid; + linkElement.href = `https://www.tumblr.com/blog/view/${uuid}`; + + modeSelect.value = mode; + modeSelect.addEventListener('change', this.updateMode); + + unmuteButton.addEventListener('click', this.onUnmuteButtonClick); + + return templateClone; + })); + }; + + onStorageChanged = changes => { + if ( + Object.keys(changes).includes(mutedBlogsEntriesStorageKey) || + Object.keys(changes).includes(blogNamesStorageKey) + ) { + this.renderMutedBlogs(); + } + }; + + connectedCallback () { + this.ariaLabel ||= 'Manage muted users'; + this.role ||= 'listitem'; + this.slot ||= 'preferences'; + + browser.storage.local.onChanged.addListener(this.onStorageChanged); + this.renderMutedBlogs(); + } +} + +customElements.define(localName, MuteMutedUsersElement); + +export default () => document.createElement(localName); diff --git a/src/features/show_originals/index.js b/src/features/show_originals/index.js index 5a2fb49bda..1044d6e2cf 100644 --- a/src/features/show_originals/index.js +++ b/src/features/show_originals/index.js @@ -19,7 +19,7 @@ import { userBlogs } from '../../utils/user.js'; const hiddenAttribute = 'data-show-originals-hidden'; const lengthenedClass = 'xkit-show-originals-lengthened'; -const controlsClass = 'xkit-show-originals-controls'; +export const controlsClass = 'xkit-show-originals-controls'; const channelSelector = `${keyToCss('bar')} ~ *`; diff --git a/src/utils/timeline_id.js b/src/utils/timeline_id.js index f7bdcdb1b4..ad577f17c5 100644 --- a/src/utils/timeline_id.js +++ b/src/utils/timeline_id.js @@ -79,6 +79,25 @@ export const anyQueueTimelineFilter = ({ dataset: { timeline, timelineId } }) => timeline?.match(exactly(`/v2/blog/${anyBlogName}/posts/queue`)) || timelineId?.match(exactly(`queue-${uuidV4}-${anyBlogName}`)); +export const anyFlaggedReviewTimelineFilter = ({ dataset: { timeline, timelineId } }) => + timeline?.match(exactly(`/v2/blog/${anyBlogName}/posts/review`)); + +export const likesTimelineFilter = ({ dataset: { timeline, timelineId } }) => + timeline === 'v2/user/likes' || + timelineId === 'likes' || + timelineId === 'likes-asc' || + timelineId === 'likes-desc' || + timelineId?.match(exactly(`likes-${uuidV4}`)); + +export const peeprLikesTimelineFilter = blogName => + ({ dataset: { timeline, timelineId } }) => + timelineId === `peepr-likes-${blogName}` || + timelineId === `peepr-likes-${blogName}-asc` || + timelineId === `peepr-likes-${blogName}-desc`; + +export const inboxTimelineFilter = ({ dataset: { timeline, timelineId } }) => + timeline?.startsWith('/v2/user/inbox'); + export const tagTimelineFilter = tag => ({ dataset: { timeline, timelineId } }) => timeline === `/v2/hubs/${encodeURIComponent(tag)}/timeline` ||