Last active
September 6, 2018 04:47
-
-
Save unarist/348f4d39184330cd2b3c78efe9b9f75e to your computer and use it in GitHub Desktop.
Mastodon - Expand Quesdon answer
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 - Expand Quesdon answer | |
// @namespace https://github.com/unarist/ | |
// @version 0.3.1 | |
// @description Automatically loads remain parts of the answer into WebUI | |
// @author unarist | |
// @downloadURL https://gist.github.com/unarist/348f4d39184330cd2b3c78efe9b9f75e/raw/mastodon-expand-quesdon-answer.user.js | |
// @match https://*/web/* | |
// @connect quesdon.rinsuki.net | |
// @require https://twemoji.maxcdn.com/2/twemoji.min.js?11.0 | |
// @grant GM.xmlHttpRequest | |
// @noframes | |
// ==/UserScript== | |
/* | |
Note: This script uses GM_xmlhttprequest because Quesdon doesn't offer CORS for foreign origins by default. | |
Your extensions may want your consent to connect Quesdon domains (e.g. quesdon.rinsuki.net). | |
You may want to use userstyle to limit the status height, like this: | |
.status__content--with-action { | |
max-height: 30em; | |
overflow-y: auto; | |
} | |
done | |
* support third-party instances | |
* emojify | |
* linkify | |
history | |
v0.3: avoid to use innerHTML in formatting to prevent XSS (thanks pacochi) | |
v0.2: suppress duplicated requests, use anonymous option on GM.xhr | |
*/ | |
/* global twemoji */ | |
(function() { | |
'use strict'; | |
const appHolder = document.querySelector('#mastodon'); | |
const cache = {}, reqCache = {}; | |
const tag = (name, props = {}) => Object.assign(document.createElement(name), props); | |
const xhr = options => new Promise((onload, onerror) => GM.xmlHttpRequest(Object.assign({}, options, { onload, onerror }))); | |
/** | |
* | |
* @param {Node} target | |
* @param {string|RegExp} pattern | |
* @param {function(...string): Node} replacer | |
*/ | |
const replaceTextNodes = (target, pattern, replacer) => { | |
const nodeIterator = document.createNodeIterator(target, NodeFilter.SHOW_TEXT); | |
// RegExp has a state property "lastIndex", so we should create new instance even if it's already RegExp object. | |
const re = new RegExp(pattern); | |
let queue = []; | |
let currentNode; | |
while ((currentNode = nodeIterator.nextNode()) !== null ) { | |
let lastIndex = 0; | |
let items = []; | |
let match; | |
while ((match = re.exec(currentNode.textContent)) !== null) { | |
items.push({ | |
index: match.index - lastIndex, | |
length: match[0].length, | |
replacement: replacer(...match) | |
}); | |
lastIndex = match.index + match[0].length; | |
} | |
if (items.length) { | |
queue.push([currentNode, items]); | |
} | |
} | |
for (const [node, items] of queue) { | |
let currentNode = node; | |
for (const item of items) { | |
currentNode = currentNode.splitText(item.index); | |
currentNode.textContent = currentNode.textContent.slice(item.length); | |
node.parentNode.insertBefore(item.replacement, currentNode); | |
} | |
} | |
}; | |
const formatToElement = text => { | |
const elem = tag('p', { textContent: text }); | |
twemoji.parse(elem, { callback: icon => `/emoji/${icon}.svg`, className: 'emojione' }); | |
// TODO: host部分をもうちょっと厳しくしたい感(IDNA、うーん) | |
replaceTextNodes(elem, /\b(https?:\/\/\S+?\/(?:[-a-zA-Z0-9@:%_\+.~#?&/=]+\/)*(?:[-a-zA-Z0-9@:%_\+.~#?&/=]*\w)?)/g, | |
(_, url) => tag('a', { href: url, rel: 'noopener', target: '_blank', className: 'status-link', textContent: url })); | |
return elem; | |
}; | |
const fetchQuestion = async (domain, qid) => { | |
const cacheKey = domain + qid; | |
if (cache[cacheKey]) return cache[cacheKey]; | |
const request = reqCache[cacheKey] || | |
(reqCache[cacheKey] = xhr({ | |
method: 'GET', | |
responseType: 'json', | |
anonymous: true, | |
url: `https://${domain}/api/web/questions/${qid}` | |
})); | |
const resp = await request; | |
delete reqCache[cacheKey]; | |
return (cache[cacheKey] = resp.status === 200 ? JSON.parse(resp.responseText) : null); | |
}; | |
const loadQuestion = async (statusContentNode) => { | |
const existingNode = statusContentNode.querySelector('.-expanded-quesdon-answer'); | |
if (existingNode) existingNode.remove(); | |
const contentCache = statusContentNode.textContent; | |
const match = contentCache.match(/#quesdon https:\/\/([a-z\.]+)\/[\w@\.]+\/questions\/([0-9a-f]+)$/); | |
const snipped = /\.\.\.\(続きはリンク先で\)/.test(contentCache); | |
if (!match || !snipped) return false; | |
const [, domain, qid] = match; | |
const question = await fetchQuestion(domain, qid); | |
if (!question) return false; | |
// target node may have been changed during async operations | |
if (statusContentNode.textContent !== contentCache) throw new Error('target element has been changed'); | |
const additionalElem = formatToElement(question.answer.substring(200)); | |
additionalElem.className = '-expanded-quesdon-answer'; | |
statusContentNode.querySelector('.status__content__text--visible').appendChild(additionalElem); | |
return true; | |
}; | |
new MutationObserver(records => { | |
for (const elem of appHolder.querySelectorAll('.status__content__text--visible .hashtag[href$="quesdon"]:not(.-expand-quesdon-answer--visited)')) { | |
elem.classList.add('-expand-quesdon-answer--visited'); | |
loadQuestion(elem.closest('.status__content')) | |
.catch(() => elem.classList.remove('-expand-quesdon-answer--visited')); | |
} | |
}).observe(appHolder, { childList: 1, subtree: 1 }); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment