Greasy Fork

FriendsKit

friends.nico の独自機能を再現するユーザスクリプト

// ==UserScript==
// @name            FriendsKit
// @namespace       https://github.com/yuzulabo
// @version         1.3.2
// @description     friends.nico の独自機能を再現するユーザスクリプト
// @author          nzws
// @match           https://knzk.me/*
// @match           https://best-friends.chat/*
// @grant           GM_addStyle
// @grant           GM_setClipboard
// @grant           GM_xmlhttpRequest
// @grant           GM_notification
// @grant           unsafeWindow
// @connect         friendskit.nzws.me
// @connect         media.knzk.me
// @connect         media.best-friends.chat
// @require         https://unpkg.com/blob-util/dist/blob-util.min.js
// ==/UserScript==

const version = '1.3.2';
const s = localStorage.friendskit;
const F = {
    conf: s ? JSON.parse(s) : {
        keyword: []
    },
    imgcache: {},
    iconcache: {}
};
const api = F.conf.api_server ? F.conf.api_server : 'https://friendskit.nzws.me/api/';
const user_emoji_regexp = new RegExp(':(_|@)([A-Za-z0-9_@.]+):', 'gm');
const nico_ms_shorten_regexp = new RegExp('(sm|nm|im|sg|mg|bk|lv|co|ch|ar|ap|jk|nw|so|l\/|dic\/|user\/|mylist\/)([0-9]+)', 'gmi');
const nico_ms_watch_regexp = new RegExp('(watch\/)([0-9]+)', 'gmi');

const keyword_escaped = [];
F.conf.keyword.forEach(value => {
    ['\\', '^', '$', '*', '+', '?', '.', '(', ')', '|', '{', '}', '[', ']'].forEach(meta => {
        value = value.replace(meta, '\\' + meta);
    });
    keyword_escaped.push(value);
});
const keyword_regexp = new RegExp(`(${keyword_escaped.join('|')})`, 'gim');

const observer = new MutationObserver((mutations) => {
    mutations.forEach((mutation) => {
        mutation.addedNodes.forEach(node => runner(node));
    });
});

function watcher() {
    const p = location.pathname;
    if (F.path !== p) {
        runner(document.querySelector('.column:last-child'));

        if (p === '/web/getting-started' && !document.querySelector('.friendskit-cp-btn')) {
            const settingLi = document.createElement('li');
            settingLi.innerHTML = ` · <a href="#" class="friendskit-cp-btn">FriendsKit CP (v${version})</a>`;

            document.querySelector('.getting-started__footer ul').appendChild(settingLi);
            settingLi.addEventListener('click', openCP);
        }
    }
    F.path = p;
}

function runner(node) {
    if (!node.tagName) return;
    const statusAll = node.querySelectorAll('.status__content');
    if (!statusAll[0]) return;

    for (let status of statusAll) {
        const display_name_account = status.parentNode.querySelector('.display-name__account');
        const status_display_name = status.parentNode.querySelector('.status__display-name');
        const origin_acct = display_name_account ? display_name_account.textContent.slice(1) : status_display_name.title;
        const origin_domain = '@' + (origin_acct.indexOf('@') !== -1 ? origin_acct.split('@')[1] : location.hostname);

        replaceTool(status, origin_domain);
    }
}

function replaceTool(status, domain) {
    if (status.hasChildNodes()) {
        for (let node of status.childNodes) {
            replaceTool(node, domain);
        }
    } else {
        if (status.nodeName !== '#text') return;

        let is_replaced = false;
        const html = document.createElement('span');
        html.innerHTML = status.data;

        if (F.conf.keyword.length > 0 && F.conf.keyword[0]) {
            html.innerHTML = html.innerHTML.replace(keyword_regexp, `<span style='color: orange'>$1</span>`);
        }

        if (!findParentByTagName(status, 'A')) {
            html.innerHTML = html.innerHTML.replace(nico_ms_shorten_regexp, `<a href="http://nico.ms/$1$2" target="_blank" rel=”nofollow”>$1$2</a>`)
            .replace(nico_ms_watch_regexp, `<a href="http://nico.ms/$2" target="_blank" rel=”nofollow”>$1$2</a>`);
        }

        if (html.innerHTML !== status.data) {
            status.parentNode.replaceChild(html, status);
            is_replaced = true;
        }

        const ue_found = html.innerHTML.match(user_emoji_regexp);
        if (ue_found) {
            ue_found.forEach(async data => {
                let acct = data.slice(2).slice(0, -1);
                if (acct.indexOf('@') === -1) acct += domain;

                const image = await getIconUrl(acct);
                html.innerHTML = html.innerHTML.replace(new RegExp(data, 'gm'), `<img draggable="false" class="emojione" alt=":_${acct}:" title="${acct}" src="${image}"/>`);
            });

            if (!is_replaced) {
                status.parentNode.replaceChild(html, status);
            }
        }
    }
}

function findParentByTagName(element, tagName, max = 3) {
    if (max < 1 || element.tagName === tagName) {
        return element.tagName === tagName;
    } else if (element.parentNode) {
        return findParentByTagName(element.parentNode, tagName, max - 1);
    } else {
        return false;
    }
}

function openCP() {
    const div = document.createElement('div');
    div.className = 'friendskit-cp';
    div.innerHTML = `
<div class="close fcp-clickable" data-fcp="close"><i class="fa fa-times fa-2x" data-fcp="close"></i></div>
<div class="h1">FriendsKit CP</div>

<div class="h2">キーワードハイライト設定</div>
カンマ(,)区切りで指定
<textarea id="friendskit-keyword" rows="5">${F.conf.keyword.join(',')}</textarea>

<div class="h2">お気に入りアイコン設定</div>
<p>
* アイコン設定で複数の設定がされている時、この設定で一番上の物が優先されます。
</p>

<label for="fav_icon_default_force"><input class="check_boxes" type="checkbox" id="fav_icon_default_force" ${F.conf.fav_icon_default_force ? 'checked' : ''}>強制的に星に戻す</label>

文字にする (1~2文字程度):<br>
<input id="fav_icon_char" placeholder="空欄で解除" class="input-text" value="${F.conf.fav_icon_char ? F.conf.fav_icon_char : ''}">

画像にする (画像URLを指定):<br>
<input id="fav_icon" placeholder="アクティブ(明るい方)" class="input-text" value="${F.conf.fav_icon ? F.conf.fav_icon : ''}">
<input id="fav_icon_gray" placeholder="暗い方" class="input-text" value="${F.conf.fav_icon_gray ? F.conf.fav_icon_gray : ''}">

<div class="h2">お気に入りアイコン拡大</div>

<label for="no_fav_icon_big"><input class="check_boxes" type="checkbox" id="no_fav_icon_big" ${F.conf.no_fav_icon_big ? 'checked' : ''}>無効にする</label>

<div class="h2">細かなやつ</div>
<button class="button fcp-clickable" data-fcp="import-settings">設定のインポート</button>
<button class="button fcp-clickable" data-fcp="export-settings">設定のエクスポート</button>
<button class="button danger fcp-clickable" data-fcp="reset-settings">設定のリセット</button>

<button class="button button--block fcp-clickable" style="margin: 20px 0" data-fcp="save">設定を保存</button>

<div class="h3">FriendsKit v${version}</div>
<p>
<a href="https://github.com/yuzulabo/FriendsKit/releases" target="_blank">リリースノート(更新履歴) を見る</a>
</p>

<p style="margin-top: 5px">
GitHub: <a href="https://github.com/yuzulabo/FriendsKit" target="_blank">yuzulabo/FriendsKit</a><br>
Greasy Fork: <a href="https://greasyfork.org/ja/scripts/381132-friendskit" target="_blank">381132-friendskit</a>
</p>
`;
    document.body.appendChild(div);
    document.querySelector('.app-holder').classList.add('friendskit-disable');

    const btns = document.querySelectorAll(".fcp-clickable");
    for (let btn of btns) {
        btn.addEventListener('click', (e) => CPOpr(e));
    }
}

function CPOpr(e) {
    const mode = e.target.dataset.fcp;
    if (!mode) return;
    if (mode === 'save') {
        const keyword = document.getElementById('friendskit-keyword').value;
        const keywords = keyword ? Array.from(new Set(keyword.split(','))) : [];

        const newConf = {
            keyword: keywords,
            fav_icon_default_force: document.getElementById('fav_icon_default_force').checked,
            fav_icon_char: document.getElementById('fav_icon_char').value,
            fav_icon: document.getElementById('fav_icon').value,
            fav_icon_gray: document.getElementById('fav_icon_gray').value,
            no_fav_icon_big: document.getElementById('no_fav_icon_big').checked,
        };
        Object.assign(F.conf, newConf);
        save();

        alert('保存しました。再読み込みします...');
        location.reload();
    } else if (mode === 'close') {
        if (!confirm('変更は破棄されますがよろしいですか?')) return;

        const element = document.querySelector('.friendskit-cp');
        element.parentNode.removeChild(element);
        document.querySelector('.app-holder').classList.remove('friendskit-disable');
    } else if (mode === 'import-settings') {
        const code = prompt('エクスポート時に出力したコードを入力してください:');
        if (!code) return;

        if (friendskit.importSettings(code)) {
            alert('インポートしました。再読み込みします...');
            location.reload();
        } else {
            alert('このコードは壊れています。インポートできません。');
        }
    } else if (mode === 'export-settings') {
        friendskit.exportSettings('CP');
        alert('設定のエクスポートをクリップボードにコピーしました。');
    } else if (mode === 'reset-settings') {
        if (!confirm('設定をリセットします!よろしいですか?')) return;
        if (!confirm('まじで?')) return;
        if (!confirm('まじか...')) return;

        if (friendskit.resetSettings()) {
            location.reload();
        }
    }
}

const friendskit = {
    keyword: {
        add: (word) => {
            const key = F.conf.keyword.indexOf(word);
            if (key !== -1) {
                console.warn('[FriendsKit]', 'このワードは追加済みです');
                return;
            }

            F.conf.keyword.push(word);
            save();
            console.log('[FriendsKit]', 'Done✨');
        },
        remove: (word) => {
            const key = F.conf.keyword.indexOf(word);
            if (key === -1) {
                console.warn('[FriendsKit]', 'このワードは存在しません');
                return;
            }

            delete F.conf.keyword[key];
            save();
            console.log('[FriendsKit]', 'Done✨');
        },
        list: () => {
            console.log('[FriendsKit]', F.conf.keyword);
        },
        reset: () => {
            F.conf.keyword = [];
            save();
            console.log('[FriendsKit]', 'Done✨');
        }
    },
    changeSettings: (name, value) => {
        F.conf[name] = value ? value : null;
        save();
        console.log('[FriendsKit]', 'Done✨');
    },
    exportSettings: (type) => {
        if (type === 'CP') {
            GM_setClipboard(localStorage.friendskit);
        } else {
            GM_setClipboard('friendskit.importSettings(`' + localStorage.friendskit + '`)');
            console.log('[FriendsKit]', 'Done✨\nクリップボードにコピーしたコードをインポートしたいページの Console にそのまま打ち込んでください。');
        }
    },
    resetSettings: () => {
        delete localStorage.friendskit;
        return !localStorage.friendskit;
    },
    importSettings: (data) => {
        try {
            JSON.parse(data);
        } catch(e) {
            console.warn('[FriendsKit]', 'このデータは壊れています', e);
            return false;
        }
        localStorage.friendskit = data;
        console.log('[FriendsKit]', 'Done✨');
        return true;
    }
};
exportFunction(friendskit, unsafeWindow, {defineAs: 'friendskit' });

async function getImage(url) {
    return new Promise(resolve => {
        if (F.imgcache[url]) {
            resolve(F.imgcache[url]);
            return;
        }
        blobUtil.imgSrcToDataURL(url, 'image/png', 'Anonymous').then(function (dataurl) {
            F.imgcache[url] = dataurl;
            resolve(F.imgcache[url]);
        }).catch(function (err) {
            console.warn('[FriendsKit]', '画像取得に失敗', url);
        });
    });
}

async function getIconUrl(acct) {
    return new Promise(resolve => {
        if (F.iconcache[acct]) {
            resolve(F.iconcache[acct]);
            return;
        }

        GM_xmlhttpRequest({
            method: 'POST',
            responseType: 'json',
            url: api + 'get_icon.php?acct=' + acct + '&domain=' + location.hostname,
            onerror: () => {
                console.warn('[FriendsKit]', 'json取得に失敗', acct);
                return;
            },
            onload: (response) => {
                if (response.status !== 200) {
                    console.warn('[FriendsKit]', `json取得に失敗 ${response.status}`, acct);
                    return;
                }

                if (response.response.error) {
                    console.warn('[FriendsKit]', response.response.error, acct);
                    return;
                }

                F.iconcache[acct] = response.response.url;
                resolve(F.iconcache[acct]);
            }
        });
    });
}

function save() {
    const data = JSON.stringify(F.conf);
    localStorage.friendskit = data;
}

function at_pizza() {
    const textarea = document.querySelector('.autosuggest-textarea__textarea');
    if (!textarea) return;

    if (textarea.value.match(/[@|@]ピザ/)) {
        window.open('https://www.google.com/search?q=近くのピザ屋さん');
    }

    if (textarea.value.match(/[@|@]ハローワーク/)) {
        window.open('https://www.hellowork.go.jp/');
    }
}

const mainElem = document.getElementById('mastodon');
if (!mainElem) return;

observer.observe(mainElem, { childList: true, subtree: true });

let css = `
.friendskit-cp {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #e6ebf0;
color: #000;
padding: 20px;
border-radius: 5px;
min-width: 55%;
max-width: 95%;
min-height: 50%;
max-height: 65%;
overflow-y: scroll;
}

.friendskit-disable {
filter: blur(4px);
pointer-events: none;
}

.friendskit-cp textarea, .friendskit-cp .input-text, .friendskit-cp label {
display: block;
width: 100%;
margin: 10px 0;
}

.fcp-clickable {
cursor: pointer;
}

.h1 {
font-size: 2.4rem;
}

.h2 {
font-size: 1.7rem;
}

.h3 {
font-size: 1.3rem;
}

.h1, .h2, .h3 {
margin: 1rem 0;
padding-bottom: 0.3rem;
font-weight: 500;
line-height: 1.2;
border-bottom: 1px solid #c0cdd9;
}

.close {
float: right;
color: #000;
text-shadow: 0 1px 0 #fff;
opacity: .5;
}

.button.danger {
background: #df405a;
}
`;

window.onload = async () => {
    setInterval(watcher, 1000);

    document.querySelector('.compose-form__publish-button-wrapper button').addEventListener('click', at_pizza, false);
    document.querySelector('.autosuggest-textarea__textarea').onkeydown = (e) => {
        if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) at_pizza();
    };

    if (!F.conf.fav_icon_default_force) {
        const i = await getImage(F.conf.fav_icon ? F.conf.fav_icon : 'https://i.imgur.com/iZQJgSW.png');
        const ig = await getImage(F.conf.fav_icon_gray ? F.conf.fav_icon_gray : 'https://i.imgur.com/ninYNIi.png');

        const char = F.conf.fav_icon_char ? F.conf.fav_icon_char : null;

        css += `
.fa-star {
background-image: ${char || !ig ? `none` : `url('${ig}')`};
width: 16px;
height: 16px;
background-size: cover;
background-repeat: no-repeat;
background-position: center center;
}

.fa-star:before {
content: '${char ? char : (ig ? '' : '\\f005')}';
}

.active .fa-star, .notification__message .fa-star {
background-image: ${char || !i ? `none` : `url('${i}')`};
}

.active .fa-star:before, .notification__message .fa-star:before {
content: '${char ? char : (i ? '' : '\\f005')}';
}
`;

        if (i && !char) {
            css += `
.active .fa-star, .notification__message .fa-star {
background-image: url('${i}');
}
`;
        }
    }

    if (!F.conf.no_fav_icon_big) {
        css += `
.status__info, .status__content {
margin-right: 40px;
}

.status button.star-icon {
position: absolute;
top: 20px;
right: 10px;
z-index: 999999;
}

.status .fa-star {
width: 40px;
height: 40px;
font-size: 2em;
}
`;
    }

    GM_addStyle(css);

    console.log(`%c..: FriendsKit v${version} :..`, '    background: black;font-size: large;color: orange');
    if (localStorage.friendskit_version !== version) {
        GM_notification({
            title: `FriendsKit v${version}にアップデートしました。`,
            text: `クリックしてリリースノート(更新履歴)を表示`,
            highlight: true,
            onclick: () => {
                window.open('https://github.com/yuzulabo/FriendsKit/releases');
            }
        });
    }
    localStorage.friendskit_version = version;
};