Mastodon - Collapse Media in Notifications By Default

Adds a collapsible toggle to posts in your notifications for media

  1. // ==UserScript==
  2. // @name Mastodon - Collapse Media in Notifications By Default
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.1.0
  5. // @description Adds a collapsible toggle to posts in your notifications for media
  6. // @author LupoMikti
  7. // @license MIT
  8. // @match https://mastodon.social/*
  9. // @match https://mstdn.jp/*
  10. // @match https://mastodon.art/*
  11. // @match https://pawoo.net/*
  12. // @match https://baraag.net/*
  13. // @icon https://www.google.com/s2/favicons?sz=64&domain=mastodon.social
  14. // @grant none
  15. // @run-at document-end
  16. // ==/UserScript==
  17.  
  18. (function() {
  19. 'use strict';
  20.  
  21. let css = `.media-toggle {
  22. margin-top: 10px;
  23. margin-inline-start: 48px;
  24. width: calc(100% - 48px);
  25. & span {
  26. cursor: pointer;
  27. }
  28. }
  29.  
  30. .notification-ungrouped:not(.notification-ungrouped--unread) .media-toggle span {
  31. color: #606984;
  32. filter: brightness(1.5);
  33. }
  34.  
  35. article:not([data-toggle-open]) .notification-ungrouped .status > .media-gallery,
  36. article:not([data-toggle-open]) .notification-ungrouped .status > .media-gallery__item,
  37. article:not([data-toggle-open]) .notification-ungrouped .status div:has(.video-player),
  38. article[data-toggle-open="false"] .notification-ungrouped .status > .media-gallery,
  39. article[data-toggle-open="false"] .notification-ungrouped .status > .media-gallery__item,
  40. article[data-toggle-open="false"] .notification-ungrouped .status div:has(.video-player) {
  41. display: none;
  42. }
  43.  
  44. .notification-ungrouped .status-card.expanded .status-card__image {
  45. visibility: collapse;
  46. }
  47. `
  48.  
  49. // This waits until we have the notifications response JSON then makes the script wait 1 second more for all DOM changes to take place
  50. var origOpen = XMLHttpRequest.prototype.open
  51. XMLHttpRequest.prototype.open = function(method, url) {
  52. this.addEventListener('load', function() {
  53. // console.log('XHR finished loading', method, this.status, url);
  54. if (url.includes('api/v2/notifications?') && window.location.pathname.includes(`/notifications`)) {
  55. return setTimeout(init, 1000)
  56. }
  57. })
  58.  
  59. this.addEventListener('error', function() {
  60. console.log('XHR errored out', method, url)
  61. })
  62. origOpen.apply(this, arguments)
  63. }
  64.  
  65. window.addEventListener('load', () => {
  66. let oldHref = document.location.href
  67. const bodyObserver = new MutationObserver((mutationList) => {
  68. if (oldHref !== document.location.href) {
  69. oldHref = document.location.href
  70. refreshScript()
  71. }
  72. })
  73. bodyObserver.observe(document.body, {childList: true, subtree: true})
  74. })
  75.  
  76. // We cannot inject a style element without the nonce, so get it and use it
  77. let styleNonce = document.querySelector('meta[name=style-nonce]').content
  78. document.head.insertAdjacentHTML('beforeend',`<style id="notif-media-toggle-css" nonce="${styleNonce}">${css}</style>`)
  79.  
  80. let notificationColumn
  81.  
  82. window.addEventListener('popstate', refreshScript)
  83.  
  84. function init() {
  85. notificationColumn = document.querySelector('.column[aria-label="Notifications"] .item-list')
  86. let initialPostsWithMedia = document.querySelectorAll(`.item-list article[style=""] .notification-ungrouped .status > .media-gallery,
  87. .item-list article[style=""] .notification-ungrouped .status > .media-gallery__item,
  88. .item-list article[style=""] .notification-ungrouped .status div:has(.video-player),
  89. .item-list article:not(article[style]) .notification-ungrouped .status > .media-gallery,
  90. .item-list article:not(article[style]) .notification-ungrouped .status > .media-gallery__item,
  91. .item-list article:not(article[style]) .notification-ungrouped .status div:has(.video-player)`)
  92.  
  93. initialPostsWithMedia.forEach((mediaSection) => {insertToggle(mediaSection)})
  94.  
  95. startObserving()
  96. }
  97.  
  98. function refreshScript() {
  99. if (!document.location.pathname.includes(`/notifications`)) return
  100. return setTimeout(init, 500)
  101. }
  102.  
  103. function getAncestorWithDataId(node) {
  104. while (node && Array.from(node.attributes).every(attr => attr.name !== 'data-id')) {
  105. node = node.parentNode
  106. }
  107. return node
  108. }
  109.  
  110. // Inserts the toggle span into a post
  111. function insertToggle(mediaSection, parentElementId = null, wasKeptOpen = 'false') {
  112. if (!mediaSection) return
  113. if (mediaSection.parentNode.querySelector('.media-toggle')) return
  114. const showingMedia = (wasKeptOpen === 'true')
  115. if (!parentElementId) {
  116. parentElementId = getAncestorWithDataId(mediaSection)?.getAttribute('data-id')
  117. }
  118. if (!parentElementId) {
  119. console.error("Failed to retrieve the element with the corresponding data-id")
  120. return
  121. }
  122. const targetSelector = mediaSection.className ? mediaSection.className.split(' ').map(name => `.${name}`).join('') : 'div:has(>.video-player)'
  123. mediaSection.insertAdjacentHTML('beforebegin',
  124. `<div class="media-toggle" data-toggle-target="[data-id='${parentElementId}'] ${targetSelector}"><span>Click to ${showingMedia ? 'hide' : 'show'} media</span></div>`)
  125. mediaSection.parentNode.querySelector('.media-toggle').addEventListener('click', doToggle)
  126. }
  127.  
  128. // Click handler for the toggle span
  129. function doToggle(event) {
  130. if(event.target.nodeName !== 'SPAN') return
  131. let toggle = event.currentTarget
  132. let toggleTargetSelector = toggle?.getAttribute('data-toggle-target')
  133. let parentElementSelector = toggleTargetSelector?.split(' ')[0]
  134. let toggleTarget = document.querySelector(toggleTargetSelector)
  135. let parentElement = document.querySelector(parentElementSelector)
  136. if (!toggleTarget.checkVisibility()) {
  137. parentElement.setAttribute('data-toggle-open', 'true')
  138. toggleTarget.style = toggleTarget.style.cssText + " display: grid;"
  139. toggle.childNodes[0].innerText = "Click to hide media"
  140. }
  141. else {
  142. parentElement.setAttribute('data-toggle-open', 'false')
  143. toggleTarget.style = toggleTarget.style.cssText.replace(" display: grid;", "")
  144. toggle.childNodes[0].innerText = "Click to show media"
  145. }
  146. }
  147.  
  148. // Mutation observer needed because page dynamicaly hides + removes/adds post content as you scroll
  149. function startObserving() {
  150. const observeColumn = (mutationList) => {
  151. for (const mutation of mutationList) {
  152. if (mutation.type === 'attributes') {
  153. const article = mutation.target
  154. if (article.nodeName !== 'ARTICLE') continue // if the target is not an article element skip
  155. if (!mutation.oldValue) continue // if the target's old attribute value is empty, skip
  156. if (article.style.cssText) continue // if the target article's style attribute is NOT being changed to empty, skip
  157. insertToggle(article.querySelector('.notification-ungrouped .status > .media-gallery, .notification-ungrouped .status > .media-gallery__item, .notification-ungrouped .status div:has(.video-player)'),
  158. article.getAttribute('data-id'),
  159. article.getAttribute('data-toggle-open') || 'false')
  160. }
  161.  
  162. if (mutation.type === 'childList') {
  163. const parent = mutation.target
  164. let movedNodes = []
  165. if (mutation.addedNodes.length > 0) {
  166. movedNodes = Array.from(mutation.addedNodes)
  167. if (!(parent.matches('div.status') && parent.hasAttribute('data-id') && movedNodes.some(node => node.matches('.media-gallery, .media-gallery__item, div:has(.video-player)')))) continue // if the target is not the parent of the media container we want, skip
  168. if (parent.hasAttribute('data-toggle-open') && parent.getAttribute('data-toggle-open') === 'true') {
  169. let mediaNode = movedNodes.find(node => node.matches('.media-gallery, .media-gallery__item, div:has(.video-player)'))
  170. mediaNode.style = mediaNode.style.cssText + " display: grid;"
  171. }
  172. insertToggle(parent.querySelector('.media-gallery, .media-gallery__item, div:has(.video-player)'),
  173. parent.getAttribute('data-id'),
  174. parent.getAttribute('data-toggle-open') || 'false')
  175. }
  176.  
  177. if (mutation.removedNodes.length > 0) {
  178. movedNodes = Array.from(mutation.removedNodes)
  179. if (!(parent.matches('div.status') && parent.hasAttribute('data-id') && movedNodes.some(node => node.matches('.media-gallery, .media-gallery__item, div:has(.video-player)')))) continue // if the target is not the parent of the media container we want, skip
  180. parent.querySelector('.media-toggle').replaceWith()
  181. }
  182. }
  183. }
  184. }
  185.  
  186. const observer = new MutationObserver(observeColumn)
  187. observer.observe(notificationColumn, {
  188. subtree: true,
  189. attributeFilter: ["style"],
  190. attributeOldValue: true
  191. })
  192.  
  193. const observer2 = new MutationObserver(observeColumn)
  194. observer2.observe(notificationColumn, { subtree: true, childList: true })
  195. }
  196. })();