Skip to content

Instantly share code, notes, and snippets.

@unarist
Last active November 13, 2022 09:14
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save unarist/08f56c49986d3b1775fe88bc918cac50 to your computer and use it in GitHub Desktop.
Save unarist/08f56c49986d3b1775fe88bc918cac50 to your computer and use it in GitHub Desktop.
Mastodon - Mobile Composer
// ==UserScript==
// @name Mastodon - Mobile Composer
// @namespace https://github.com/unarist/
// @version 0.14
// @description add composer to page bottom
// @author unarist
// @downloadURL https://gist.github.com/unarist/08f56c49986d3b1775fe88bc918cac50/raw/mastodon-mobile-composer.user.js
// @match https://*/web/*
// @match https://mstdn.maud.io/*
// @grant none
// @run-at document-idle
// ==/UserScript==
/*
v0.14: Mastodon4.0で/web/消えるぽいので、とりあえずmstdn.maud.ioは追加(他は各自なんとかして)。マウント待ちとかガードとか追加。
v0.13: クリアボタンのラベルをアイコンにして、各ボタンにtitle追加
v0.12: シングルカラム時に表示されないのを修正
v0.11: CSPが適用されていそうならstyleタグを試さないようにして、余分な警告が出ないように
v0.10: CSPにstyleタグが蹴られるのをなんとかした。あと細かい修正。
v0.9: 詰ボタン追加(絵文字前後のスペースをzwspにする)
v0.8: v2.3.0の浮いてる投稿ボタン(#6594)対応
v0.7でShift押下の挙動を潰しました。はい。
旧挙動が必要な方はGistのリビジョンから漁ってください。(設定を変更していたら再設定も必要)
公開範囲を変更・指定する代わりの方法はそのうち考えます。
*/
(async function() {
'use strict';
const app_root = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).document.querySelector('#mastodon');
if (!app_root) return;
const log = x => console.debug(`${GM.info.script.name}: ${x}`);
const tag = (name, props = {}, children = '') => {
const e = Object.assign(document.createElement(name), props);
Object.assign(e.style, props.style);
if (children.forEach) children.forEach(c => e.appendChild(c));
else e.textContent = children;
return e;
};
const setupStylesheet = css => {
if (!document.querySelector('meta[name="style-nonce"]')) {
const styleEl = document.head.appendChild(tag('style', { textContent: css }));
if (styleEl.sheet) {
return styleEl.sheet;
}
styleEl.remove(); // 多分CSPで弾かれてるので削除
}
// workaround for Blink
if (document.adoptedStyleSheets) {
log('<style> may be blocked by CSP, using adoptedStylesheets instead.');
const sheet = new CSSStyleSheet();
sheet.replaceSync(css);
document.adoptedStyleSheets = document.adoptedStyleSheets.concat(sheet);
return sheet;
}
// workaround2
const usableSheet = [...document.styleSheets].filter(x=>(x.href || "").startsWith(location.origin)).slice(-1)[0];
if (usableSheet) {
log(`<style> may be blocked by CSP, inserting into ${usableSheet.href} instead.`);
usableSheet.insertRule(`@supports (display:block) { ${css} }`);
return usableSheet;
}
throw Error(`Cannot setup custom styles (probably due to CSP).\nUA: ${navigator.userAgent}`);
};
const stylesheet = setupStylesheet(`
.mobile-composer {
display: flex;
position: unset;
height: auto;
flex: 0 0 auto;
padding: 5px;
}
.layout-single-column .mobile-composer {
/* シングルカラム時はページ全体でスクロールさせる設計なので、fixedにする */
position: fixed;
top: unset;
bottom: 0;
}
@media (min-width: 1025px) {
.mobile-composer { display: none }
}
.mobile-composer .spoiler-input__input,
.mobile-composer .autosuggest-textarea__textarea {
margin-bottom: 5px;
font-size: 14px;
padding: 0.5em;
border-radius: 4px;
}
.mobile-composer .autosuggest-textarea__textarea {
min-height: unset;
height: 5em !important;
}
.mobile-composer__buttons {
display: flex;
justify-content: flex-end;
}
.mobile-composer__button {
padding: 0 1em;
height: 2em;
line-height: 2em;
margin-left: 5px;
}
`);
const refs = {};
const icon = (name) => tag('i', { className: `fa fa-${name}`, role: 'presentation' });
const button = (label, title, onclick) =>
tag('button', {
className: 'button mobile-composer__button',
title,
onclick
}, label);
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`);
};
const target = await waitForSelector('.ui', 200, 10000);
const initialState = JSON.parse(document.querySelector('#initial-state').textContent);
const api = (method, url, body) =>
fetch(url, {
method,
headers: {
'Authorization': 'Bearer ' + initialState.meta.access_token,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
const clear = () => {
[refs.cw, refs.body].forEach(e => e.value = '');
refs.body.focus();
};
const post = (visibility) => {
if (!refs.body.value) return;
refs.buttons.style.display = 'none';
api('post', '/api/v1/statuses', {
status: refs.body.value,
spoiler_text: refs.cw.value,
visibility: visibility
})
.then(resp => resp.ok ? clear() : Promise.reject(resp.statusText))
.catch(window.alert)
.then(() => refs.buttons.style.display = null);
};
const collapseEmojiSpaces = () => {
// Replace spaces around of emoji code to U+200B ZERO WIDTH SPACE.
// It still needs to be wrapped with U+0020 SPACE unless it's placed at start/end of the line.
// inspired by https://gist.github.com/pacochi/ae6714d1c71a8ef52a3d5a3407886e94
// testcase: ":aa: a\nfoo :aa: :bb: bar\n:aa: :bar foo: :bar\nb :aa:"
refs.body.value =
refs.body.value
.replace(/: :/g, ':\x00\x00:')
.replace(/(?:(^|\n)|[ \u200b\x00])(:[\w\-]{2,}:)(?:($|\n)|[ \u200b\x00])/g, '$1\u200b$2\u200b$3')
.replace(/(:\x00+:)|(\u200b?)\x00(\u200b?)/g, (x,a,b,c) => a ? ': :' : (b || '') + (c || ''))
.replace(/\u200b{2,}/g, '\u200b')
.replace(/\u200b*(^|$|\n)\u200b*/g, '$1');
};
const getVisibility = (alternative = false) => 'public';
refs.root = tag('div', { className: 'drawer__inner mobile-composer' }, [
refs.cw = tag('input', {
type: 'text',
placeholder: 'CW',
className: 'spoiler-input__input'
}),
refs.body = tag('textarea', {
className: 'autosuggest-textarea__textarea',
onkeydown: e => void(e.keyCode == 13 && e.ctrlKey && post(getVisibility(e.shiftKey))),
oninput: e => void(refs.body.style.backgroundColor = (e.target.value.length > 500 ? 'pink' : null))
}),
refs.buttons = tag('div', { className: 'mobile-composer__buttons' }, [
button('詰', '絵文字の間のスペースを詰める', collapseEmojiSpaces),
button([icon('trash')], '入力をクリア', clear),
button('DM', '公開範囲「ダイレクト」で投稿', post.bind(null, 'direct')),
button('非公開', '公開範囲「フォロワー限定」で投稿', post.bind(null, 'private')),
button('未収載', '公開範囲「未収載」で投稿', post.bind(null, 'unlisted')),
button('公開', '公開範囲「公開」で投稿', post.bind(null, 'public'))
])
]);
target.appendChild(refs.root);
// set offset for compose button introduced at v2.3.0rc1 (#6594)
stylesheet.insertRule(`.floating-action-button { margin-bottom: ${refs.root.clientHeight}px; }`, stylesheet.cssRules.length);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment