🏠 Home 

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/[email protected]/dist/js/bootstrap.bundle.min.js
// @resource     bootstrap https://cdn.jsdelivr.net/npm/[email protected]/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();
}