Greasy Fork is available in English.

Twitterシンプル検索

検索用のモーダルを実装します

// ==UserScript==
// @name         Twitterシンプル検索
// @namespace    http://tampermonkey.net/
// @version      1.2.1
// @description  検索用のモーダルを実装します
// @author       y_kahou
// @license      MIT
// @match        https://twitter.com/*
// @noframes
// @require      https://greasyfork.org/scripts/439492-twiiterhelper/code/TwiiterHelper.js?version=1226868
// @require      http://code.jquery.com/jquery-3.5.1.min.js
// @require      https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js
// @resource     bootstrap https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css
// @grant        GM_addStyle
// @grant        GM_getResourceText
// ==/UserScript==

var $ = window.jQuery;
GM_addStyle(GM_getResourceText("bootstrap"));
GM_addStyle(`
[data-testid="AppTabBar_SimpleSearch"]:hover > div,
.append-menu:hover
{
    background: rgba(128, 128, 128, 0.2);
}
body.modal-open {
    padding-right: 0!important;
    overflow: visible!important;
    position: static!important;
}
.modal-item {
    margin-bottom: 10px;
}
#ft > div {
    width: 40%;
    margin-right: 10%;
}
#reaction > div {
    width: 30%;
    margin-right: 3%;
}

#searchModal {
    pointer-events: none;
    position: fixed;
    top: 0; left: 0;
    z-index: 1001;
    width: 100%;
    height: 100%;
    overflow-x: hidden;
    overflow-y: auto;
    outline: 0;
    transition: 0.2s;
}
#modalBack {
    position: fixed;
    top: 0; left: 0;
    z-index: 1000;
    width: 100%;
    height: 100%;
    transition: 0.2s;
    background: rgba(0, 0, 0, 0.4);
}
.remove {
    opacity: 0;
}
.hide {
    visibility: hidden;
}
#searchModal.remove {
    transform: translateY(-50px);
}
#searchModal svg {
    width: 14px;
}
`);

const P_SCH = '<path d="m21.53 20.47-3.66-3.66A8.98 8.98 0 0 0 20 11a9 9 0 1 0-9 9c2.215 0 4.24-.804 5.808-2.13l3.66 3.66a.746.746 0 0 0 1.06 0 .747.747 0 0 0 .002-1.06zM3.5 11c0-4.135 3.365-7.5 7.5-7.5s7.5 3.365 7.5 7.5-3.365 7.5-7.5 7.5-7.5-3.365-7.5-7.5z"/><path d="M11 6.086a.12.12 0 0 0-.108.066L9.474 9.025l-3.17.461a.12.12 0 0 0-.068.205l2.296 2.237-.543 3.16a.12.12 0 0 0 .176.127L11 13.722l2.836 1.493a.12.12 0 0 0 .175-.127l-.542-3.16 2.296-2.236a.12.12 0 0 0-.068-.204l-3.17-.461-1.419-2.875A.12.12 0 0 0 11 6.086z"/>';
const I_REP = '<svg viewBox="0 0 24 24"><g><path d="M14.046 2.242l-4.148-.01h-.002c-4.374 0-7.8 3.427-7.8 7.802 0 4.098 3.186 7.206 7.465 7.37v3.828c0 .108.044.286.12.403.142.225.384.347.632.347.138 0 .277-.038.402-.118.264-.168 6.473-4.14 8.088-5.506 1.902-1.61 3.04-3.97 3.043-6.312v-.017c-.006-4.367-3.43-7.787-7.8-7.788zm3.787 12.972c-1.134.96-4.862 3.405-6.772 4.643V16.67c0-.414-.335-.75-.75-.75h-.396c-3.66 0-6.318-2.476-6.318-5.886 0-3.534 2.768-6.302 6.3-6.302l4.147.01h.002c3.532 0 6.3 2.766 6.302 6.296-.003 1.91-.942 3.844-2.514 5.176z"></path></g></svg>';
const I_RET = '<svg viewBox="0 0 24 24"><g><path d="M23.77 15.67c-.292-.293-.767-.293-1.06 0l-2.22 2.22V7.65c0-2.068-1.683-3.75-3.75-3.75h-5.85c-.414 0-.75.336-.75.75s.336.75.75.75h5.85c1.24 0 2.25 1.01 2.25 2.25v10.24l-2.22-2.22c-.293-.293-.768-.293-1.06 0s-.294.768 0 1.06l3.5 3.5c.145.147.337.22.53.22s.383-.072.53-.22l3.5-3.5c.294-.292.294-.767 0-1.06zm-10.66 3.28H7.26c-1.24 0-2.25-1.01-2.25-2.25V6.46l2.22 2.22c.148.147.34.22.532.22s.384-.073.53-.22c.293-.293.293-.768 0-1.06l-3.5-3.5c-.293-.294-.768-.294-1.06 0l-3.5 3.5c-.294.292-.294.767 0 1.06s.767.293 1.06 0l2.22-2.22V16.7c0 2.068 1.683 3.75 3.75 3.75h5.85c.414 0 .75-.336.75-.75s-.337-.75-.75-.75z"></path></g></svg>';
const I_FAV = '<svg viewBox="0 0 24 24"><g><path d="M12 21.638h-.014C9.403 21.59 1.95 14.856 1.95 8.478c0-3.064 2.525-5.754 5.403-5.754 2.29 0 3.83 1.58 4.646 2.73.814-1.148 2.354-2.73 4.645-2.73 2.88 0 5.404 2.69 5.404 5.755 0 6.376-7.454 13.11-10.037 13.157H12zM7.354 4.225c-2.08 0-3.903 1.988-3.903 4.255 0 5.74 7.034 11.596 8.55 11.658 1.518-.062 8.55-5.917 8.55-11.658 0-2.267-1.823-4.255-3.903-4.255-2.528 0-3.94 2.936-3.952 2.965-.23.562-1.156.562-1.387 0-.014-.03-1.425-2.965-3.954-2.965z"></path></g></svg>';


class SearchModal
{
    constructor() {
        const TextField = (id, text, antiPattern, type="text") => `
        <div class="input-group text-field" id="${id}">
            <span class="input-group-text">${text}</span>
            <input type="${type}" class="form-control" data-antipattern="${antiPattern}">
        </div>`

        const ToggleField = (id, tgl, text) => `
        <input type="checkbox" class="btn-check" id="${id}" data-tgl="${tgl}">
        <label class="btn btn-outline-primary" for="${id}">${text}</label>`

        const DateTimeField = (id, text) => `
        <div class="input-group mb-3 datetime" id="${id}">
            <span class="input-group-text">${text}</span>
            <input type="date" class="form-control">
            <button class="btn btn-outline-secondary">X</button>
            <input type="time" step="1" class="form-control">
            <button class="btn btn-outline-secondary">X</button>
        </div>`

        const modal = $(`
        <div id="modalBack" class="hide remove"></div>
        <div id="searchModal" class="hide remove">
            <div class="modal-dialog">
                <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">検索</h5>
                    <button type="button" class="btn-close" aria-label="Close"></button>
                    <input type="hidden" id="tweet-id">
                </div>
                <div class="modal-body">
                    <div class="modal-item d-flex mb-3" id="ft">
                        ${TextField('from', 'from', '[^A-Za-z0-9_]')}
                        ${TextField('to'  , 'to'  , '[^A-Za-z0-9_]')}
                    </div>

                    <h6>日時</h6>
                    <div class="modal-item" id="datetime">
                        ${DateTimeField('since', '開始')}
                        ${DateTimeField('until', '終了')}
                    </div>

                    <h6>フィルター</h6>
                    <div class="modal-item">
                        ${ToggleField('t01', 'filter:images', '画像')}
                        ${ToggleField('t02', 'filter:videos', '動画')}
                        ${ToggleField('t03', 'filter:links', 'リンク')}
                    </div>

                    <h6>リアクション下限</h6>
                    <div class="modal-item d-flex" id="reaction">
                        ${TextField('min_replies',  I_REP, '[^0-9]', 'number')}
                        ${TextField('min_retweets', I_RET, '[^0-9]', 'number')}
                        ${TextField('min_faves',    I_FAV, '[^0-9]', 'number')}
                    </div>

                    <h6>エクストラ</h6>
                    <div class="modal-item">
                        ${ToggleField('t11', 'include:nativeretweets', 'RTを含む')}
                        ${ToggleField('t12', 'filter:replies', '返信のみ')}
                    </div>
                </div>
                <div class="modal-footer">
                    <div class="input-group mb-3">
                        <input type="text" class="form-control" placeholder="検索文" id="search">
                        <button class="btn btn-primary" type="button" id="search-exec">検索</button>
                    </div>
                </div>
                </div>
            </div>
        </div>
        `);
        $('#search-exec', modal).on('click', e => {
            const text = $('#search')[0].value.replace(/#/g, '%23');
            const url = 'https://twitter.com/search?q=' + text + '&f=live'
            window.open(url, '_brank')
        })
        // 手入力時に各inputへ反映
        $('#search', modal).on('keyup', e => {
            const text = e.target.value;

            // トグル
            $('.modal-item .btn-check').each((i,e) => {
                e.checked = (text.indexOf(e.dataset.tgl) != -1)
            })
            // テキスト
            $('.text-field').each((i,e) => {
                const m = text.match(new RegExp(`${e.id}:(\\w+)`))
                $('input', e)[0].value = m ? m[1]: '';
                $('span',  e).toggleClass('btn-primary', !!m)
            })
            // 日時
            $('.datetime').each((i,e) => {
                let m = text.match(new RegExp(`${e.id}:(\\d{4}-\\d{2}-\\d{2})_?(\\d{2}:\\d{2}:\\d{2})?`))
                $('input[type="date"]', e)[0].value = m ? m[1] : ''
                $('input[type="time"]', e)[0].value = m ? m[2] : ''
                $('span:eq(0)', e).toggleClass('btn-primary', !!m)
            })
        })
        // トグル
        $('.modal-item .btn-check', modal).on('change', e => {
            const tgl = e.target.dataset.tgl
            let text = $('#search').val();
            $('#search')[0].value = e.target.checked
                ? text + ' ' + tgl
                : text.replace(new RegExp(' ?' + tgl), '')
        })
        // テキスト
        $('.text-field input', modal).on('keyup', e => {
            const id = e.currentTarget.parentNode.id
            const rxp = new RegExp(` ?${id}:(\\w+)`)
            let val = $(e.target).val()
            let text = $('#search').val()

            let $cpt = $(`#${id} span`)
            $cpt.removeClass('btn-primary btn-danger')
            if ( val.match(new RegExp(e.currentTarget.dataset.antipattern)) ) {
                $cpt.addClass('btn-danger')
            } else if (val) {
                $cpt.addClass('btn-primary')
                $('#search')[0].value = !text.match(rxp)
                    ? `${text} ${id}:${val}`
                    : text.replace(rxp, ` ${id}:${val}`)
            } else {
                $('#search')[0].value = text.replace(rxp, '')
            }
        })
        // 日時
        $('.datetime input', modal).on('change', e => {
            function getDT(parent) {
                let date = $('input[type="date"]', parent).val();
                let time = $('input[type="time"]', parent).val();
                if (!date) return null;
                return time ? (date + '_' + time + '_JST') : date;
            }
            const $parent = $(e.target).parent()
            const id = $parent.attr('id')
            const dt = getDT($parent)
            const rxp = new RegExp(` ?${id}:[\\d-_:]+(JST)?`)
            let text = $('#search').val();

            $(`#${id} > span:eq(0)`).toggleClass('btn-primary', !!dt)
            if (!dt) {
                $('#search')[0].value = text.replace(rxp, '')
            }
            else {
                $('#search')[0].value = !text.match(rxp)
                    ? `${text} ${id}:${dt}`
                    : text.replace(rxp, ` ${id}:${dt}`)
            }
        })
        $('#datetime button', modal).on('click', e => {
            $(e.target).prev().val(null);
            $('#datetime input').trigger('change')
        })
        $('.btn-close', modal).on('click', e => this.hide())

        $('body').append(modal);
        $('#modalBack').on('click', e => this.hide())
    }

    val(value) {
        $('#searchModal #search').val(value);
        $('#searchModal #search').trigger('keyup');
    }
    // Bootstrapの表示を使うとトップに移動するバグがあるので自力で表示
    show() {
        $('#searchModal, #modalBack').removeClass('hide remove');
    }
    hide() {
        $('#searchModal, #modalBack').addClass('remove')
        .on('transitionend', e => {
            $(e.currentTarget).addClass('hide').off('transitionend');
        })
    }
}
const modal = new SearchModal();


function addSearchMenuItem() {
    let $mainMenu = $('nav[aria-label="メインメニュー"]')
    let $temp = $mainMenu.children('a:eq(-2)').clone(false)
    $temp.removeAttr('href')
    $temp.attr('aria-label', '検索')
    $temp.attr('data-testid', 'AppTabBar_SimpleSearch')
    $temp.find('svg').html(P_SCH)
    $temp.find('span').text( ($('span', $mainMenu).length != 0) ? '検索' : '' )
    $temp.on('click', () => modal.show())
    $mainMenu.children('a:eq(0)').after($temp)
}
function addAppendMenuItem() {
    const $menu = $('[role="menu"]');
    if ($menu.length && $('div:not([class]) span:contains("ダイレクトメッセージで送信")', $menu).length) {
        const $item = $('[role="menuitem"]', $menu).eq(-1).clone(true)
        $item.addClass('append-menu')
        $item.find('svg').html(P_SCH)
        $item.find('span').text('前後1日のツイートを検索')
        $item.on('click', e => {
            const tid = $('#searchModal #tweet-id').val();
            const article = $(`[href$="${tid}"]`).closest('article')[0];
            const uid = Twitter.getUserId(article)
            const dt = Twitter.getDateTime(article)
            let since = new Date(dt.getTime()); since.setDate(since.getDate() - 1);
            let until = new Date(dt.getTime()); until.setDate(until.getDate() + 1);
            document.elementFromPoint(15, 15).click(); // メニューを閉じるためにクリック
            modal.val(`from:${uid} since:${Twitter.timeToString(since)} until:${Twitter.timeToString(until)}`);
            modal.show();
        })
        $('[role="menuitem"]', $menu).parent().append($item)
    }
}

new MutationObserver(() => {
    if ($('[data-testid="AppTabBar_SimpleSearch"]').length == 0) {
        addSearchMenuItem();
    }
    if ($('.append-menu').length == 0) {
        addAppendMenuItem();
    }

    // シェアボタンにtweetIdを設定
    $('[aria-label="ポストを共有"]:not([data-tid])').each((i,e) => {
        let article = e.closest('article');
        e.dataset.tid = Twitter.getTweetId(article);
        $(e).parent().on('click', e2 => {
            $('#searchModal #tweet-id').val(e2.currentTarget.firstChild.dataset.tid);
        })
    })
})
.observe(document.body, { childList: true, subtree: true ,attributes: true, characterData:true })

window.onresize = function() {
    $('[data-testid="AppTabBar_SimpleSearch"]').remove()
    addSearchMenuItem();
}