Last active
October 20, 2024 16:38
-
-
Save unarist/9bed2c719f42853b9588104e6fdb0a20 to your computer and use it in GitHub Desktop.
Mastodon - Open post link in WebUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Mastodon - Open post link in WebUI | |
// @description Click fa-comment just before URLs in posts, then it shows linked page in WebUI | |
// @namespace https://github.com/unarist/ | |
// @downloadURL https://gist.github.com/unarist/9bed2c719f42853b9588104e6fdb0a20/raw/mastodon-open-link-in-webui.user.js | |
// @version 0.14 | |
// @author unarist | |
// @match https://*/web/* | |
// @match https://mstdn.maud.io/* | |
// @grant none | |
// @run-at document-idle | |
// @noframes | |
// ==/UserScript== | |
/* | |
* X/Twitter: popup tweet widget | |
* Pixiv: popup thumbnail (click to open in new window) | |
* OS/AP compatible account/post: resolve via Mastodon and open in WebUI | |
Todo | |
* use lang on twitter widget | |
Changelog | |
v0.14: | |
* Mastodon 4.4.0 あたりで FontAwesome が使えなくなっていたっぽいので、Unicode 絵文字に乗り換え | |
v0.13: | |
* X (formerly Twitter) のドメイン x.com に対応 | |
v0.12: | |
* Mastodon 4.0.0rc3 で動かなかったところを直した(マウント待ち、 _reactRootContainer が消えてたのをどうにかした、/web/ 廃止対応) | |
v0.11: | |
* Firefox + Greasemonkey で動かないのを修正(unsafeWindowを使わないと_reactRootContainerが見えない) | |
v0.10: | |
* Twitter投稿をiframeで埋め込む方法を見つけたのでGM.xmlHttpRequestを捨てた。やったぜ。 | |
v0.9: | |
* Twitter対応を復活させた(CSPを迂回するためにGM.xmlHttpRequestに手を出した…そしてallow-same-originも必須になっていた…) | |
* Pixiv対応を復活させた(当時の経緯を忘れたがもう普通のプレビューでいいのでは感ある) | |
* 多分Mastodon投稿も開けなくなっていたのを修正 | |
v0.8: | |
* react-redux 6.0.0 (Mastodon 2.7.0) 対応 (c.f. reduxjs/react-redux #1000) | |
* Twitter: CSPでどうしようもないので当面無効化 | |
v0.7: Twitter: support mobile.twitter.com urls | |
v0.6: React 16.3 (Mastodon 2.4.0)対応 | |
v0.5: Twitter: fix embed shaking, relax sandbox in Firefox | |
v0.4: Pixiv: click to open | |
v0.3: Twitter: fix action buttons, set dnt flag | |
*/ | |
(async function() { | |
'use strict'; | |
const app_root = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).document.querySelector('#mastodon'); | |
if (!app_root) return; | |
const wait = t => new Promise(r => setTimeout(r, t)); | |
const waitForSelector = async (q, t, max_t) => { | |
const until = Date.now() + max_t; | |
do { | |
const v = document.querySelector(q); | |
if (v) return v; | |
await wait(t); | |
} while (Date.now() < until); | |
throw new Error(`waiting for query ${q} has been timed out in ${max_t}ms`); | |
}; | |
// Reactのマウントを待つ | |
await waitForSelector(".ui", 200, 2000); | |
const cache = {}; | |
const keys = { | |
state: 'webuiLinkState', | |
icon: 'webuiLinkIcon', | |
}; | |
const tag = (name, props = {}, children = []) => { | |
const e = Object.assign(document.createElement(name), props); | |
if (typeof props.style === "object") Object.assign(e.style, props.style); | |
(children.forEach ? children : [children]).forEach(c => e.appendChild(c)); | |
return e; | |
}; | |
const defaultOptions = { | |
headers: { | |
'Authorization': 'Bearer ' + JSON.parse(document.getElementById('initial-state').textContent).meta.access_token | |
} | |
}; | |
const api = (path, options) => | |
fetch(location.origin + path, Object.assign({}, defaultOptions, options)) // should be deepMerge | |
.then(resp => { if (!resp.ok) throw new Error(new URL(resp.url).pathname + ' ' + resp.status); return resp.json(); }); | |
const getHtmlLang = () => document.documentElement.getAttribute('lang'); | |
const getHistory = () => { | |
try { | |
const v16_root_node_prop = Object.keys(app_root).find(k => k.startsWith("__reactContainer")); | |
if (v16_root_node_prop) { | |
// >= v16: to descendant | |
let current_node = app_root[v16_root_node_prop]; | |
while (current_node) { | |
const history = current_node.memoizedProps?.history; | |
if (history) return history; | |
current_node = current_node.child; | |
} | |
} else { | |
// < v16: to ancestor | |
const root_instance = Object.entries(app_root.firstElementChild).find(prop=>prop[0].startsWith('__reactInternalInstance'))[1]; | |
let current_node = root_instance._currentElement._owner; | |
while (current_node) { | |
const history = current_node._instance.props.history; | |
if (history) return history; | |
current_node = current_node._currentElement._owner; | |
} | |
} | |
} catch (e) { | |
console.log('mastodon-open-link-in-webui: Failed to get History instance: ', e); | |
return null; | |
} | |
}; | |
const modal = { | |
open(elem) { | |
this.container.appendChild(elem); | |
Object.assign(this.container.style, { display: 'block', opacity: 1 }); | |
}, | |
close() { | |
this.container.addEventListener('transitionend', () => { | |
this.container.style.display = 'none'; | |
while (this.container.firstChild) this.container.removeChild(this.container.firstChild); | |
}, { once: 1 }); | |
this.container.style.opacity = 0; | |
}, | |
container: tag('div', { | |
style: "position:fixed; top:0; width:100%; height:100%; background: rgba(0,0,0,0.8); display:none; z-index: 999; transition: 100ms", | |
onclick: e => modal.close(), | |
onkeydown: e => e.keyCode === 27 && modal.close() | |
}) | |
}; | |
document.body.appendChild(modal.container); | |
const processors = []; | |
processors.push(function() { | |
window.addEventListener('message', e => { | |
const iframe = document.querySelector('.webui-link-iframe'); | |
if (iframe && iframe.contentWindow === e.source && e.origin === 'https://platform.twitter.com' && e.data['twttr.embed'].method === 'twttr.private.resize') { | |
iframe.style.height = e.data['twttr.embed'].params[0].height + 'px'; | |
iframe.style.opacity = '1'; | |
} | |
}); | |
return url => { | |
const match = url.match(/\/(?:mobile\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/); | |
if (match) | |
return Promise.resolve(() => modal.open(tag('iframe', { | |
className: 'webui-link-iframe', | |
style: ` | |
width: 480px; max-width: 90vw; height: 80vh; max-height: 80vh; | |
position: absolute; top: 0; left: 0; right: 0; bottom: 0; margin: auto; border: none; | |
opacity:0; transition: opacity .1s ease-in`, | |
// widget.js の中で使われていた | |
src: `https://platform.twitter.com/embed/index.html?dnt=true&id=${match[1]}` | |
}))); | |
else | |
return Promise.resolve(); | |
}; | |
}()); | |
processors.push(url => { | |
const match = url.match(/\/www\.pixiv\.net\/.*illust_id=(\d+)/) || url.match(/\/www\.pixiv\.net\/.*artworks\/(\d+)/); | |
if (match) | |
return Promise.resolve(() => modal.open(tag('a', { | |
href: url, | |
target: '_blank' | |
}, [ | |
tag('img', { | |
src: `https://embed.pixiv.net/decorate.php?illust_id=${match[1]}&mode=sns-automator`, | |
style: 'max-width: 80vw; max-height: 80vh; position: absolute; top: 0; left: 0; right: 0; bottom: 0; margin: auto;' | |
}) | |
]))); | |
else | |
return Promise.resolve(); | |
}); | |
processors.push(function () { | |
const history = getHistory(); | |
if (!history) return () => Promise.reject(); | |
const openStatus = id => history.push('/statuses/' + id); | |
const openAccount = id => history.push('/accounts/' + id); | |
return url => api('/api/v2/search?resolve=true&q=' + url) | |
.then(json => { | |
if (json.statuses.length) { | |
return openStatus.bind(null, json.statuses[0].id); | |
} else if (json.accounts.length) { | |
return openAccount.bind(null, json.accounts[0].id); | |
} | |
}); | |
}()); | |
const actions = { | |
_history: undefined, | |
attach(elem) { | |
elem.insertBefore(tag('span', { className: keys.icon, style: 'margin-right: .1em', textContent: '💬' }), elem.firstChild); | |
elem.addEventListener('click', handleClick); | |
}, | |
detatch(elem) { | |
elem.removeChild(elem.querySelector('.' + keys.icon)); | |
elem.removeEventListener('click', handleClick); | |
}, | |
open(elem) { | |
cache[elem.href](); | |
}, | |
refresh(elem) { | |
const icon_elem = elem.querySelector('.' + keys.icon); | |
elem.dataset[keys.state] = 'refreshing'; | |
icon_elem.classList.replace('fa-comment', 'fa-spinner'); | |
const tryNext = (url, rest) => { | |
rest[0](url).then(result => { | |
if (result) { | |
elem.dataset[keys.state] = 'found'; | |
cache[elem.href] = result; | |
icon_elem.classList.replace('fa-spinner', 'fa-comment'); | |
result(); | |
} else { | |
if (rest.length > 1) { | |
tryNext(url, rest.slice(1)); | |
} else { | |
elem.dataset[keys.state] = 'notfound'; | |
actions.detatch(elem); | |
} | |
} | |
}).catch(e => (console.log(e), elem.dataset[keys.state] = 'error', actions.detatch(elem))); | |
}; | |
tryNext(elem.href, processors); | |
} | |
}; | |
const handleClick = e => { | |
if (!e.target.classList.contains(keys.icon)) return; | |
const link = e.currentTarget; | |
switch (link.dataset[keys.state]) { | |
case 'candidate': | |
actions.refresh(link); | |
break; | |
case 'found': | |
actions.open(link); | |
e.preventDefault(); | |
break; | |
} | |
e.preventDefault(); | |
}; | |
new MutationObserver(mutations => { | |
for (const elem of document.querySelectorAll('.status__content a:not([data-webui-link-state])')) { | |
if (!elem.textContent.startsWith('https') || cache[elem.href] === null) { | |
elem.dataset[keys.state] = 'none'; | |
} else { | |
elem.dataset[keys.state] = cache[elem.href] ? 'found' : 'candidate'; | |
actions.attach(elem); | |
} | |
} | |
}).observe(app_root, {childList: true, subtree: true}); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment