Last active
November 13, 2022 09:14
-
-
Save unarist/08f56c49986d3b1775fe88bc918cac50 to your computer and use it in GitHub Desktop.
Mastodon - Mobile Composer
This file contains 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 - 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