Skip to content

Instantly share code, notes, and snippets.

@unarist
Last active September 6, 2018 04:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save unarist/348f4d39184330cd2b3c78efe9b9f75e to your computer and use it in GitHub Desktop.
Save unarist/348f4d39184330cd2b3c78efe9b9f75e to your computer and use it in GitHub Desktop.
Mastodon - Expand Quesdon answer
// ==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