// ==UserScript== // @name Misstter for TweetDeck // @namespace https://github.com/AranoYuki1 // @version 1.0.7 // @description TweetDeckからMisskeyに投稿できる拡張機能です! // @author AranoYuki // @match https://tweetdeck.com/* // @icon https://lh3.googleusercontent.com/d/1bG16Aj1geU3sfOx1Wtfh7-vJY5coRRib // @grant none // @sandbox DOM // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/527079/Misstter%20for%20TweetDeck.user.js // @updateURL https://update.greasyfork.icu/scripts/527079/Misstter%20for%20TweetDeck.meta.js // ==/UserScript== /** * WebExtensionの代わり * addListenerで実行する関数を定義して、sendMessageで実行 * getURLで拡張機能の画像の代わりにGoogleドライブの画像を使用 * storage.syncの代わりにローカルストレージを使用 */ let browser = { runtime: { onMessage: { addListener(func) { browser.runtime.onMessage.run = func; } }, url: { "misskey_icon.png": "https://lh3.googleusercontent.com/d/1bAuXKE8UoSRkJ-vlX3PoT6SVLGR2kDVx" }, getURL(url) { return browser.runtime.url[url]; }, sendMessage(postMessage) { return browser.runtime.onMessage.run(postMessage, "", ""); } }, storage: { sync: { get(keys) { let data = {}; for (let i = 0; i < keys.length; i++) { let d = localStorage.getItem(keys[i]); if (/^(true|false)$/.test(d)) d = d == "true"; else if (/^\d+$/.test(d)) d = Number(d); data[keys[i]] = d; } return new Promise((resolve, reject) => { resolve(data); }); }, set(data) { for (let i in data) { localStorage.setItem(i, data[i]); } } } } }; /** * common/browser.ts */ const getBrowserName = () => { const ua = navigator.userAgent const isIE = ua.indexOf("MSIE ") > -1 || ua.indexOf("Trident/") > -1 const isEdge = ua.indexOf("Edge/") > -1 const isChrome = ua.indexOf("Chrome/") > -1 const isFirefox = ua.indexOf("Firefox/") > -1 const isSafari = ua.indexOf("Safari/") > -1 && !isChrome const isOpera = ua.indexOf("Opera/") > -1 || ua.indexOf("OPR/") > -1 const isBlink = isChrome && !!window.chrome if (isIE) { return "IE" } else if (isEdge) { return "Edge" } else if (isChrome) { return "Chrome" } else if (isFirefox) { return "Firefox" } else if (isSafari) { return "Safari" } else if (isOpera) { return "Opera" } else if (isBlink) { return "Blink" } else { return "Unknown" } } /** * common/CommonType.ts */ /** * common/constants.ts */ const DEFAULT_INSTANCE_URL = "https://misskey.io" /** * リプライボタンの文字列一覧 */ const REPLY_BUTTON_LABELS = [ "返信", "Reply", "답글", "回复", "回覆", "Répondre", "Responder", "Antworten", "Rispondi", "Responder", "Responder", "Antwoorden", "Svara", "Svar" ] /** * background/background.ts */ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type == "post") { const postMessage = message return postToMisskeyB( postMessage.text, postMessage.attachments, postMessage.options ) } }) /** * background/MisskeyAPI.ts */ const uploadAttachment = async (attachment, options) => { const blob = await (await fetch(attachment.data)).blob() if (blob instanceof Blob == false) { console.error("blob is not Blob") return } const formData = new FormData() // create UUID const filename = `${Date.now()}.png` formData.append("file", blob, filename) formData.append("i", options.token) formData.append("name", filename) if (options.sensitive || attachment.isSensitive) { formData.append("isSensitive", "true") } const res = await fetch(`${options.server}/api/drive/files/create`, { method: "POST", body: formData }) const resJson = await res.json() const fileID = resJson["id"] return fileID } // postToMisskeyと名前が重複するのでbackground.tsのほうにBをつけた const postToMisskeyB = async (text, attachments, options) => { let fileIDs = [] if (attachments.length != 0) { fileIDs = await Promise.all( attachments.map(attachment => uploadAttachment(attachment, options)) ) } const body = { i: options.token } if (text) { body["text"] = text } if (fileIDs.length > 0) { body["fileIds"] = fileIDs } if (options.cw) { body["cw"] = "" } if (options.scope) { body["visibility"] = options.scope } if (options.localOnly) { body["localOnly"] = options.localOnly } const res = await fetch(`${options.server}/api/notes/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) if (res.status != 200) { const errorRes = await res.json() const message = errorRes["error"]["message"] throw new Error(message) } } /** * content_script/Clients/tweetdeck.ts */ // ミスキーへの投稿ボタンを追加する const addMisskeyPostButton = (tweetButton, tweetBox) => { // すでにボタンがある場合は何もしない if (tweetBox.querySelector(`.${misskeyButtonClassName}`)) return const misskeybutton = createMisskeyPostButton(tweetToMisskey, tweetButton) misskeybutton.style.width = "40px" misskeybutton.style.height = "30px" misskeybutton.style.marginLeft = "8px" tweetBox.appendChild(misskeybutton) syncDisableState(tweetButton, misskeybutton) } // スコープボタンを作成する const addScopeButton = iconBox => { // すでにボタンがある場合は何もしない if (iconBox.querySelector(`.${scopeButtonClassName}`)) return const scopeButton = createScopeButton() iconBox.appendChild(scopeButton) } // 連合なしボタンを作成する const addLocalOnlyButton = iconBox => { if (iconBox.querySelector(`.${localOnlyButtonClassName}`)) return const localOnlyButton = createLocalOnlyButton() iconBox.appendChild(localOnlyButton) } // ミスキーへのセンシティブ設定ボタンを追加する const addMisskeyImageOptionButton = (editButton, attachmentsImage) => { const misskeybutton = createMisskeyImageOptionButton() editButton.parentElement.insertBefore(misskeybutton, editButton) } const foundTweetButtonHandler = tweetButton => { if (!tweetButton) return // リプライボタンの場合は後続の処理を行わない const isReplyButton = REPLY_BUTTON_LABELS.indexOf(tweetButton.innerText) !== -1 if (isReplyButton) return // add misskey post button const tweetBox = tweetButton.parentElement?.parentElement if (tweetBox) { addMisskeyPostButton(tweetButton, tweetBox) } // // add scope button and local only button const iconsBlock = document.querySelector(gifButtonSelector)?.parentElement if (iconsBlock) { addScopeButton(iconsBlock) addLocalOnlyButton(iconsBlock) } } const foundAttachmentsImageHandler = attachmentsImage => { // すでにボタンがある場合は何もしない if (attachmentsImage.getAttribute("data-has-flag-button")) return attachmentsImage.setAttribute("data-has-flag-button", "true") const editButton = Array.from( attachmentsImage.querySelectorAll("div[role='button']") )[1] if (!editButton) return addMisskeyImageOptionButton(editButton, attachmentsImage) } const gifButtonSelector = 'div[data-testid="gifSearchButton"]' const buttonSelector = '//*[@id="react-root"]/div/div/div[3]/div/div[2]/div/div/div[1]/div/div/div/div[3]/div' const attachmentsImageSelector = 'div[data-testid="attachments"] div[role="group"]' const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.type !== "childList") return mutation.addedNodes.forEach(node => { if (node.nodeType !== Node.ELEMENT_NODE) return // select with xpath const tweetButton = node.ownerDocument.evaluate( buttonSelector, node, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null ).singleNodeValue if (tweetButton) { foundTweetButtonHandler(tweetButton) } const attachmentsImages = document.querySelectorAll( attachmentsImageSelector ) if (attachmentsImages) { attachmentsImages.forEach(attachmentsImage => { foundAttachmentsImageHandler(attachmentsImage) }) } }) }) }) observer.observe(document.body, { childList: true, subtree: true }) /** * content_script/System/PostAPI.ts */ const blobToBase64 = blob => { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onloadend = () => { const base64 = reader.result if (base64) { resolve(base64.toString()) } else { reject(new Error("Failed to convert blob to base64")) } } reader.readAsDataURL(blob) }) } const makeAttachmentData = async image => { const base64 = await blobToBase64(image.blob) return { data: base64, isSensitive: image.isSensitive } } const postToMisskey = async (text, images, video, options) => { const imageData = await Promise.all( images.map(async image => { return await makeAttachmentData(image) }) ) const videoData = video ? await makeAttachmentData(video) : undefined let uploadNotification = undefined if (imageData.length != 0) { uploadNotification = showNotification( "画像をアップロードしています...", "success", 1000_0000 ) } if (videoData) { uploadNotification = showNotification( "動画をアップロードしています...", "success", 1000_0000 ) } const attachments = imageData if (videoData) { attachments.push(videoData) } const postMessage = { type: "post", text: text, options: options, attachments } try { uploadNotification?.close() await browser.runtime.sendMessage(postMessage) showNotification("Misskeyへの投稿に成功しました", "success") } catch (error) { showNotification(error.message, "error") } } /** * content_script/System/StorageReader.ts */ const getToken = async () => { return await new Promise((resolve, reject) => { browser.storage.sync.get(["misskey_token"]).then(result => { const token = result?.misskey_token if (!token) { showNotification("Tokenが設定されていません。", "error") reject() } else { resolve(token) } }) }) } const getServer = async () => { return await new Promise((resolve, reject) => { browser.storage.sync.get(["misskey_server"]).then(result => { let server = result?.misskey_server ?? DEFAULT_INSTANCE_URL if (server.endsWith("/")) { server = server.slice(0, -1) } resolve(server) }) }) } const getCW = async () => { return await new Promise((resolve, reject) => { browser.storage.sync.get(["misskey_cw"]).then(result => { resolve(result?.misskey_cw ?? false) }) }) } const getSensitive = async () => { return await new Promise((resolve, reject) => { browser.storage.sync.get(["misskey_sensitive"]).then(result => { resolve(result?.misskey_sensitive ?? false) }) }) } const getScope = async () => { return await new Promise((resolve, reject) => { browser.storage.sync.get(["misskey_scope"]).then(result => { resolve(result?.misskey_scope ?? "public") }) }) } const getLocalOnly = async () => { return await new Promise((resolve, reject) => { browser.storage.sync.get(["misskey_local_only"]).then(result => { resolve(result?.misskey_local_only ?? false) }) }) } /** * content_script/System/TwitterCrawler.ts */ const getTweetText = () => { let textContents = document.querySelectorAll( 'div[data-testid="tweetTextarea_0"] div[data-block="true"]' ) //スマホに対応 let text if (textContents.length > 0) { text = Array.from(textContents) .map(textContent => { return textContent.textContent }) .join("\n") } else { textContents = document.querySelector('textarea[data-testid="tweetTextarea_0"]') if (!textContents) return text = textContents.value } return text } const getTweetVideo = async () => { const video = document.querySelector( "div[data-testid='attachments'] video > source" ) if (!video) return null const videoRoot = video.parentElement?.parentElement const flagButton = videoRoot?.querySelector(`.${misskeyFlagClassName}`) const isFlagged = flagButton?.getAttribute(misskeyFlagAttribute) === "true" ?? false const url = video.getAttribute("src") if (!url) return null if (!url.startsWith("blob:")) return null const blob = await fetch(url).then(res => res.blob()) return { blob: blob, isSensitive: isFlagged } } const getTweetImages = async () => { const images = document.querySelectorAll("div[data-testid='attachments'] img") const res = [] for (const image of images) { const imageRoot = image.parentElement?.parentElement?.parentElement?.parentElement const flagButton = imageRoot?.querySelector(`.${misskeyFlagClassName}`) const isFlagged = flagButton?.getAttribute(misskeyFlagAttribute) === "true" ?? false const url = image.getAttribute("src") if (!url) continue if (!url.startsWith("blob:")) continue const blob = await (await fetch(url)).blob() res.push({ blob: blob, isSensitive: isFlagged }) } return res } const tweetToMisskey = async () => { try { const text = getTweetText() const images = await getTweetImages() const video = await getTweetVideo() if (!text && images.length == 0 && !video) { showNotification("Misskeyへの投稿内容がありません", "error") return } const [token, server, cw, sensitive, scope, localOnly] = await Promise.all([ getToken(), getServer(), getCW(), getSensitive(), getScope(), getLocalOnly() ]) const options = { cw, token, server, sensitive, scope: scope, localOnly } await postToMisskey(text ?? "", images, video, options) } catch (e) { console.error(e) showNotification("Misskeyへの投稿に失敗しました", "error") } } /** * content_script/UI/Icons.ts */ const public_scope_icon = ` ` const home_scope_icon = ` ` const lock_scope_icon = ` ` const flag_icon = ` ` const modal_pin_icon = ` ` const global_icon = ` ` const local_only_icon = ` ` /** * content_script/UI/ImageFlagButton.ts */ const misskeyFlagClassName = "misskey-flag" const misskeyFlagAttribute = "data-misskey-flag" const createMisskeyImageOptionButton = () => { const misskeybutton = document.createElement("button") misskeybutton.innerHTML = flag_icon misskeybutton.style.fill = "rgb(255, 255, 255)" misskeybutton.className = misskeyFlagClassName misskeybutton.style.backgroundColor = "rgba(15, 20, 25, 0.75)" // @ts-ignore misskeybutton.style.backdropFilter = "blur(4px)" misskeybutton.style.borderRadius = "9999px" misskeybutton.style.height = "32px" misskeybutton.style.width = "32px" misskeybutton.style.marginLeft = "8px" misskeybutton.style.marginRight = "8px" misskeybutton.style.outline = "none" misskeybutton.style.display = "flex" misskeybutton.style.alignItems = "center" misskeybutton.style.justifyContent = "center" misskeybutton.style.cursor = "pointer" misskeybutton.style.border = "solid 1px rgb(134, 179, 0)" misskeybutton.onclick = () => { if (misskeybutton.getAttribute(misskeyFlagAttribute) === "true") { misskeybutton.setAttribute(misskeyFlagAttribute, "false") misskeybutton.style.backgroundColor = "rgba(15, 20, 25, 0.75)" } else { misskeybutton.setAttribute(misskeyFlagAttribute, "true") misskeybutton.style.backgroundColor = "rgb(134, 179, 0)" } } misskeybutton.style.transition = "background-color 0.2s ease-in-out" misskeybutton.onmouseover = () => { if (misskeybutton.getAttribute(misskeyFlagAttribute) === "true") return misskeybutton.style.backgroundColor = "rgba(39, 44, 48, 0.75)" } misskeybutton.onmouseout = () => { if (misskeybutton.getAttribute(misskeyFlagAttribute) === "true") return misskeybutton.style.backgroundColor = "rgba(15, 20, 25, 0.75)" } return misskeybutton } /** * content_script/UI/LocalOnlyButton.ts */ const localOnlyButtonClassName = "misskey-local-only-button" const createLocalOnlyButton = () => { const localOnlyButton = document.createElement("div") const updateLocalOnlyIcon = () => browser.storage.sync.get(["misskey_local_only"]).then(result => { const localOnly = result?.misskey_local_only ?? false updateLocalOnlyButton(localOnlyButton, localOnly) }) setInterval(() => { updateLocalOnlyIcon() }, 2000) updateLocalOnlyIcon() browser.storage.sync.get(["misskey_show_local_only"]).then(result => { const showLocalOnly = result?.misskey_show_local_only ?? true if (!showLocalOnly) { localOnlyButton.style.display = "none" } }) localOnlyButton.className = localOnlyButtonClassName localOnlyButton.style.minWidth = "34px" localOnlyButton.style.width = "34px" localOnlyButton.style.maxWidth = "34px" localOnlyButton.style.minHeight = "34px" localOnlyButton.style.height = "34px" localOnlyButton.style.maxHeight = "34px" localOnlyButton.style.backgroundColor = "transparent" localOnlyButton.style.display = "flex" localOnlyButton.style.alignItems = "center" localOnlyButton.style.justifyContent = "center" localOnlyButton.style.borderRadius = "9999px" localOnlyButton.style.cursor = "pointer" localOnlyButton.style.transition = "background-color 0.2s ease-in-out" localOnlyButton.onmouseover = () => { localOnlyButton.style.backgroundColor = "rgba(134, 179, 0, 0.1)" } localOnlyButton.onmouseout = () => { localOnlyButton.style.backgroundColor = "transparent" } localOnlyButton.onclick = () => { browser.storage.sync.get(["misskey_local_only"]).then(result => { const localOnly = result?.misskey_local_only ?? false browser.storage.sync.set({ misskey_local_only: !localOnly }) updateLocalOnlyIcon() }) } return localOnlyButton } const updateLocalOnlyButton = (localOnlyButton, localOnly) => { if (localOnly) { localOnlyButton.innerHTML = local_only_icon } else { localOnlyButton.innerHTML = global_icon } } /** * content_script/UI/MisskeyPostButton.ts */ const misskeyButtonClassName = "misskey-button" const createMisskeyPostButton = (tweetToMisskeyFunc, tweetButton) => { const misskeyIcon = document.createElement("img") misskeyIcon.src = browser.runtime.getURL("misskey_icon.png") misskeyIcon.style.width = "24px" misskeyIcon.style.height = "24px" misskeyIcon.style.verticalAlign = "middle" misskeyIcon.style.display = "inline-block" misskeyIcon.style.userSelect = "none" const misskeybutton = document.createElement("button") misskeybutton.appendChild(misskeyIcon) misskeybutton.className = misskeyButtonClassName misskeybutton.style.backgroundColor = "rgb(134, 179, 0)" misskeybutton.style.borderRadius = "9999px" misskeybutton.style.height = "36px" misskeybutton.style.width = "36px" misskeybutton.style.marginLeft = "8px" misskeybutton.style.marginRight = "8px" misskeybutton.style.outline = "none" misskeybutton.style.display = "flex" misskeybutton.style.alignItems = "center" misskeybutton.style.justifyContent = "center" misskeybutton.style.border = "none" misskeybutton.onmouseover = () => { misskeybutton.style.backgroundColor = "rgb(100, 134, 0)" } misskeybutton.onmouseout = () => { misskeybutton.style.backgroundColor = "rgb(134, 179, 0)" } misskeybutton.style.transition = "background-color 0.2s ease-in-out" misskeybutton.onclick = () => { misskeybutton.disabled = true misskeybutton.style.opacity = "0.5" tweetToMisskeyFunc().then(() => { misskeybutton.style.opacity = "1" misskeybutton.disabled = false browser.storage.sync.get(["misskey_auto_tweet"]).then(result => { const autoTweet = result?.misskey_auto_tweet ?? false if (autoTweet) tweetButton.click() }) }) } return misskeybutton } const syncDisableState = (tweetButton, misskeybutton) => { const syncOpacity = () => { const isDisabled = parseFloat(window.getComputedStyle(tweetButton).opacity) != 1 if (isDisabled) { misskeybutton.disabled = true misskeybutton.style.opacity = "0.5" misskeybutton.style.cursor = "default" } else { misskeybutton.disabled = false misskeybutton.style.opacity = "1" misskeybutton.style.cursor = "pointer" } } const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.type !== "attributes") return if (mutation.attributeName !== "class") return syncOpacity() }) }) syncOpacity() observer.observe(tweetButton, { attributes: true }) } /** * content_script/UI/Notification.ts */ class Notification { close() { console.log("close", this) this._close() } constructor(text, type, duration) { this.text = text this.type = type this.duration = duration this._closePromise = new Promise(resolve => { this._close = resolve }) } } const notificationStack = [] let currentNotification = null const _showNotification = notification => { if (currentNotification) { notificationStack.push(notification) return } currentNotification = notification const notificationBar = document.createElement("div") notificationBar.textContent = notification.text notificationBar.style.position = "fixed" notificationBar.style.top = "0" notificationBar.style.left = "0" notificationBar.style.right = "0" if (notification.type === "success") { notificationBar.style.backgroundColor = "rgb(134, 179, 0)" } else if (notification.type === "error") { notificationBar.style.backgroundColor = "rgb(211, 30, 30)" } notificationBar.style.color = "white" notificationBar.style.padding = "8px" notificationBar.style.zIndex = "9999" notificationBar.style.fontSize = "16px" notificationBar.style.textAlign = "center" notificationBar.style.fontFamily = "sans-serif" notificationBar.style.userSelect = "none" notificationBar.style.pointerEvents = "none" // show animation notificationBar.style.transition = "opacity 0.2s ease-in-out" notificationBar.style.opacity = "0" setTimeout(() => { notificationBar.style.opacity = "1" }, 0) document.body.appendChild(notificationBar) notification._closePromise.then(() => { notificationBar.style.opacity = "0" setTimeout(() => { document.body.removeChild(notificationBar) }, 2000) currentNotification = null const nextNotification = notificationStack.shift() if (nextNotification) { _showNotification(nextNotification) } }) setTimeout(() => { notification.close() }, notification.duration) } const showNotification = (text, type, duration = 2000) => { const notification = new Notification(text, type, duration) _showNotification(notification) return notification } /** * content_script/UI/ScopeButton.ts */ const scopeButtonClassName = "misskey-scope-button" const createScopeButton = () => { const scopeButton = document.createElement("div") const updateScopeIcon = () => browser.storage.sync.get(["misskey_scope"]).then(result => { const scope = result?.misskey_scope ?? "public" updateScopeButton(scopeButton, scope) }) setInterval(() => { updateScopeIcon() }, 2000) updateScopeIcon() browser.storage.sync.get(["misskey_access"]).then(result => { const access = result?.misskey_access ?? true if (!access) { scopeButton.style.display = "none" } }) scopeButton.className = scopeButtonClassName scopeButton.style.minWidth = "34px" scopeButton.style.width = "34px" scopeButton.style.maxWidth = "34px" scopeButton.style.minHeight = "34px" scopeButton.style.height = "34px" scopeButton.style.maxHeight = "34px" scopeButton.style.backgroundColor = "transparent" scopeButton.style.display = "flex" scopeButton.style.alignItems = "center" scopeButton.style.justifyContent = "center" scopeButton.style.borderRadius = "9999px" scopeButton.style.cursor = "pointer" scopeButton.style.transition = "background-color 0.2s ease-in-out" scopeButton.onmouseover = () => { scopeButton.style.backgroundColor = "rgba(134, 179, 0, 0.1)" } scopeButton.onmouseout = () => { scopeButton.style.backgroundColor = "transparent" } scopeButton.onclick = () => { if (isShowingScopeModal()) { closeScopeModal() } else { showScopeModal(scopeButton) } } return scopeButton } /** * content_script/UI/ScopeModal.ts */ const createScopeModal = callback => { const modal = document.createElement("div") modal.style.fontFamily = "sans-serif" modal.style.position = "absolute" modal.style.width = "200px" modal.style.minWidth = "200px" modal.style.top = "45px" modal.style.backgroundColor = "white" modal.style.borderRadius = "10px" modal.style.boxShadow = "rgba(101, 119, 134, 0.2) 0px 0px 15px, rgba(101, 119, 134, 0.15) 0px 0px 3px 1px" // transition on opacity modal.style.transition = "opacity 0.2s ease 0s" // place pin at top center const modal_pin = document.createElement("div") modal_pin.innerHTML = modal_pin_icon modal_pin.style.fill = "white" modal_pin.style.width = "24px" modal_pin.style.height = "24px" modal_pin.style.position = "absolute" modal_pin.style.top = "-12px" modal_pin.style.left = "calc(50% - 12px)" modal.appendChild(modal_pin) const html = `
サーバーのURLを入力してください。デフォルトではmisskey.ioが設定されています。
Tokenはお使いのMisskeyサーバーの 「設定 > API」の画面から取得できます。
投稿権限とファイルアップロード権限が必要です。(全てを許可すると自動で設定されます)
開発の支援をお願いします! / Donation