Fediverse Hashflags

X(Twitter)のHashflags(Hashmojis)をMisskey, Mastodon, Bluesky, TOKIMEKIで表示

  1. // ==UserScript==
  2. // @name Fediverse Hashflags
  3. // @namespace https://midra.me/
  4. // @version 2.1.3
  5. // @description X(Twitter)のHashflags(Hashmojis)をMisskey, Mastodon, Bluesky, TOKIMEKIで表示
  6. // @author Midra
  7. // @license MIT
  8. // @match https://*/*
  9. // @icon https://hashmojis.com/favicon.ico
  10. // @run-at document-end
  11. // @grant GM_addStyle
  12. // @grant GM_getValue
  13. // @grant GM_setValue
  14. // ==/UserScript==
  15.  
  16. // @ts-check
  17.  
  18. /**
  19. * @typedef TwitterHashflag
  20. * @property {string} hashtag
  21. * @property {string} asset_url
  22. * @property {number} starting_timestamp_ms
  23. * @property {number} ending_timestamp_ms
  24. * @property {boolean} is_hashfetti_enabled
  25. */
  26.  
  27. void (async () => {
  28. 'use strict'
  29.  
  30. const HASHFLAGS_UPDATE_INTERVAL = 2 * 60 * 60 * 1000
  31.  
  32. const service = {
  33. twitter:
  34. location.href.startsWith('https://twitter.com/') ||
  35. location.href.startsWith('https://x.com/'),
  36. misskey:
  37. document
  38. .querySelector('meta[name="application-name"]')
  39. ?.getAttribute('content') === 'Misskey',
  40. mastodon:
  41. document.querySelector(
  42. '#mastodon, #mastodon-svg-logo, #mastodon-svg-logo-full'
  43. ) !== null,
  44. // bluesky: location.href.startsWith('https://bsky.app/'),
  45. tokimeki:
  46. location.href.startsWith('https://tokimeki.blue/') ||
  47. location.href.startsWith('https://tokimekibluesky.vercel.app/'),
  48. }
  49.  
  50. if (!Object.values(service).some((v) => v)) return
  51.  
  52. /** @type {TwitterHashflag[]} */
  53. const hashflags = GM_getValue('hashflags', [])
  54. /** @type {TwitterHashflag[]} */
  55. const activeHashflags = hashflags
  56. .filter((v) => Date.now() < v.ending_timestamp_ms)
  57. .map((v) => ((v.hashtag = v.hashtag.toLowerCase()), v))
  58. const activeHashtags = activeHashflags.map((v) => v.hashtag)
  59.  
  60. /**
  61. * @type {Console['log']}
  62. */
  63. const log = (...data) => console.log('[Fediverse Hashflags]', ...data)
  64. /**
  65. * @type {Console['error']}
  66. */
  67. const error = (...data) => console.error('[Fediverse Hashflags]', ...data)
  68.  
  69. /**
  70. * @param {string} [hashtag]
  71. * @returns {TwitterHashflag | undefined}
  72. */
  73. const getHashflag = (hashtag) => {
  74. if (!hashtag) return
  75.  
  76. const hashflag =
  77. activeHashflags[activeHashtags.indexOf(hashtag.toLowerCase())]
  78.  
  79. if (
  80. hashflag &&
  81. hashflag.starting_timestamp_ms <= Date.now() &&
  82. Date.now() < hashflag.ending_timestamp_ms
  83. ) {
  84. return hashflag
  85. }
  86. }
  87.  
  88. /**
  89. * @param {Element} target
  90. */
  91. const addHashflags = (target) => {
  92. if (activeHashflags.length === 0) return
  93.  
  94. /** @type {NodeListOf<HTMLAnchorElement>} */
  95. const hashtags = target.querySelectorAll(
  96. 'a[href*="/tags/"], a[href^="/search?q="]'
  97. )
  98.  
  99. for (const tag of hashtags) {
  100. const tagUrl = new URL(tag.href)
  101.  
  102. if (
  103. !tag.classList.contains('twitter-hashflag-wrap') &&
  104. ((service.tokimeki && tagUrl.pathname === '/search') ||
  105. tagUrl.pathname.startsWith('/tags/'))
  106. ) {
  107. const text = tag.textContent
  108.  
  109. if (!text?.startsWith('#')) continue
  110.  
  111. const hashflag = getHashflag(text.substring(1))
  112.  
  113. if (hashflag) {
  114. const img = document.createElement('img')
  115. img.classList.add('twitter-hashflag')
  116. img.src = hashflag.asset_url
  117. tag.appendChild(img)
  118. tag.classList.add('twitter-hashflag-wrap')
  119. }
  120. }
  121. }
  122. }
  123.  
  124. // /**
  125. // * @param {Element} target
  126. // */
  127. // const removeHashflags = (target) => {
  128. // for (const elm of target.getElementsByClassName('twitter-hashflag')) {
  129. // elm.remove()
  130. // }
  131.  
  132. // for (const elm of target.getElementsByClassName('twitter-hashflag-wrap')) {
  133. // elm.classList.remove('twitter-hashflag-wrap')
  134. // }
  135. // }
  136.  
  137. // Twitter (Hashflagsの取得・保存)
  138. if (service.twitter) {
  139. log('Twitter')
  140.  
  141. const lastUpdated = GM_getValue('hashflags_lastupdated', 0)
  142.  
  143. if (HASHFLAGS_UPDATE_INTERVAL < Date.now() - lastUpdated) {
  144. try {
  145. const res = await fetch('https://x.com/i/api/1.1/hashflags.json')
  146. /** @type {TwitterHashflag[]} */
  147. const json = await res.json()
  148.  
  149. if (json && 0 < json.length) {
  150. GM_setValue('hashflags', json)
  151. GM_setValue('hashflags_lastupdated', Date.now())
  152.  
  153. log('Hashflagsを保存しました')
  154. }
  155. } catch (err) {
  156. error(err)
  157. }
  158. }
  159. } else {
  160. if (service.misskey) log('Misskey')
  161. if (service.mastodon) log('Mastodon')
  162. // if (service.bluesky) console.log('Bluesky')
  163. if (service.tokimeki) log('TOKIMEKI (Bluesky)')
  164.  
  165. addHashflags(document.body)
  166.  
  167. /** @type {MutationObserverInit} */
  168. const obs_options = {
  169. childList: true,
  170. subtree: true,
  171. }
  172. const obs = new MutationObserver((mutations) => {
  173. obs.disconnect()
  174.  
  175. for (const mutation of mutations) {
  176. if (!(mutation.target instanceof HTMLElement)) continue
  177.  
  178. if (0 < mutation.addedNodes.length) {
  179. addHashflags(mutation.target)
  180. }
  181. }
  182.  
  183. obs.observe(document.body, obs_options)
  184. })
  185.  
  186. obs.observe(document.body, obs_options)
  187.  
  188. // style
  189. GM_addStyle(`
  190. .twitter-hashflag-wrap {
  191. display: inline-flex;
  192. flex-wrap: wrap;
  193. align-items: center;
  194. gap: 0.2em;
  195. }
  196. .twitter-hashflag {
  197. height: 1.2em;
  198. margin-top: -0.125em;
  199. }
  200. `)
  201. }
  202. })()