├── images ├── icon.png ├── .DS_Store ├── icon.afphoto ├── screenshot1.png └── icon_with_padding.png ├── manifest.json ├── README.md ├── LICENSE └── script.js /images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/komcdo/hot_search/HEAD/images/icon.png -------------------------------------------------------------------------------- /images/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/komcdo/hot_search/HEAD/images/.DS_Store -------------------------------------------------------------------------------- /images/icon.afphoto: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/komcdo/hot_search/HEAD/images/icon.afphoto -------------------------------------------------------------------------------- /images/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/komcdo/hot_search/HEAD/images/screenshot1.png -------------------------------------------------------------------------------- /images/icon_with_padding.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/komcdo/hot_search/HEAD/images/icon_with_padding.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Twitter Hot Search", 4 | "version": "1.1", 5 | "content_scripts": [ 6 | { 7 | "matches": [ 8 | "https://*.twitter.com/*" 9 | ], 10 | "js": ["script.js"] 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twitter Hot Search 2 | ## Features 3 | - Supports 13 search functions 4 | - Activates proper typeahead for selecting users 5 | - Fully working TamperMonkey script 6 | - Persists across pages 7 | - Keyboard or click activation 8 | - Enhanced user protection 9 | ## Learn the code 10 | I added "LTC" (learn this code) steps in the code. Read them in order to quickly get familiar with how to code is structured. Dive into the details from there! 11 | ## Contribute! 12 | Let's keep making this better! File a bug in the "Issues" section, report it to [komcdo_](https://twitter.com/komcdo_) on Twitter, or best of all fix it yourself and submit a Pull Request! 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 komcdo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Twitter Hot Search 3 | // @namespace http://tampermonkey.net/ 4 | // @version 1.1 5 | // @description Twitter Hot Search 6 | // @author komcdo 7 | // @match https://twitter.com/* 8 | // @icon https://www.google.com/s2/favicons?sz=64&domain=twitter.com 9 | // @grant none 10 | // ==/UserScript== 11 | 12 | (async function() { 13 | 'use strict'; 14 | let tokenAnimationSpeed = ".3s .3s"; // speed, delay 15 | const style = document.createElement('style'); 16 | style.textContent = ` 17 | .hotzearch {font-family: TwitterChirp, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; transition: transform ${tokenAnimationSpeed}; will-change: transform;} 18 | .hotzearch.noDelayAnim {transition: transform 0.3s;} 19 | .hotzearch .inlineToken { position: relative; top: 8px; left: 5px; background: #5e5e5e; padding: 3px 16px 5px; border-radius: 13px; display: block; white-space: nowrap; height: 19px; display: none; max-width: 100%; transition: width ${tokenAnimationSpeed}, padding ${tokenAnimationSpeed}; will-change: width, padding;} 20 | .hotzearch .inlineToken::first-letter { text-transform:capitalize; } 21 | .hotzearch .inlineToken.invisible {opacity: 0} 22 | .hotzearch .inlineToken.minSize.creating {width: 0px !important; padding: 3px 0 5px;} 23 | .hotzearch .inlineToken.minSize.deleting {width: 0px !important; padding: 3px 0 5px;} 24 | .hotzearch .hotzearch_inline .inlineToken {display: block;} 25 | .hotzearch .staticToken { white-space: nowrap; position: relative; float: left; color: white; background: #5d5d5d; font-size: 14px; padding: 8px 16px; line-height: 10px; border-radius: 16px; cursor: pointer; margin:0 8px 8px 0; opacity: 1; transition: width ${tokenAnimationSpeed}, height ${tokenAnimationSpeed}, margin ${tokenAnimationSpeed}; will-change: width, height, margin-top;} 26 | .hotzearch .staticToken::first-letter { text-transform:capitalize; } 27 | .hotzearch .staticToken .staticTokenRemove{ height: 16px; width: 16px; position: absolute; right: 2px; top: 2px; background: #cfcfcf; border-radius: 50%; padding: 3px 0px 3px 6px; line-height: 10px; color: black; opacity: 0;} 28 | .hotzearch .staticToken:hover .staticTokenRemove {opacity: 1;} 29 | .hotzearch .staticToken.invisible {opacity: 0} 30 | .hotzearch .cloneToken {display: block; position: absolute; z-index: 1; transition: transform ${tokenAnimationSpeed}, font-size ${tokenAnimationSpeed}, padding ${tokenAnimationSpeed}, line-height ${tokenAnimationSpeed}, height ${tokenAnimationSpeed}; will-change: transform, font-size, padding, line-height, height;} 31 | .hotzearch .zearchTokenWrap {position: relative; height: 0; padding-bottom: 4px; margin-top: -4px; transition: margin-top ${tokenAnimationSpeed}, height ${tokenAnimationSpeed}; will-change: margin-top, height;} 32 | .hotzearch .zearchTokenWrap.noDelayAnim {transition: margin-top .3s, height .3s; } 33 | .hotzearch .filterButton { position: relative; top: 7px; margin-right: 5px; height: 20px; width: 20px; color: #999da1; border-radius: 13px; padding: 4px 6px; display: block; white-space: nowrap; cursor: pointer;} 34 | .hotzearch .filterDialog { position: absolute; top: 41px; right: -7px; background: #36393f; padding-right: 8px; border-radius: 16px;} 35 | .hotzearch .filterDialog select{ background: #4a4c52; border: none; font-size: 15px; border-top-left-radius: 16px; border-bottom-left-radius: 16px; padding: 5px 0px 5px 15px; color: white;} 36 | .hotzearch .filterDialog input{ background: none; border: none; padding: 6px 0 6px 6px; font-size: 15px; outline: none; width: 200px; color: white } 37 | .hotzearch.noAnim {transition: none !important;} 38 | .hotzearch.noAnim .inlineToken{transition: none;} 39 | .hotzearch.noAnim .staticToken{transition: none;} 40 | .hotzearch.noAnim .zearchTokenWrap{transition: none !important;} 41 | @media (prefers-color-scheme: light) { 42 | .hotzearch .filterDialog select, 43 | .hotzearch .staticToken, 44 | .hotzearch .cloneToken, 45 | .hotzearch .hotzearch_inline .inlineToken { background: #1d9bf0; color: white; } 46 | .hotzearch .filterDialog { background: #e6e6e6; } 47 | .hotzearch .staticToken .staticTokenRemove{ background: #fafafa; } 48 | .hotzearch .filterDialog input{ color:black;} 49 | .hotzearch .filterDialog select{ color:black;} 50 | }`; 51 | document.head.append(style); 52 | const closeBtn = ``; 53 | const filterBtn = ``; 54 | 55 | function waitForElm(selector) { 56 | return new Promise(resolve => { 57 | if (document.querySelector(selector)) return resolve(document.querySelector(selector)); 58 | let elmFinder = setInterval(()=>{ 59 | if (document.querySelector(selector)) { 60 | clearInterval(elmFinder); 61 | return resolve(document.querySelector(selector)); 62 | } 63 | }, 50); 64 | }); 65 | } 66 | 67 | const setNativeValue = (element, value) => { 68 | const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; 69 | const prototype = Object.getPrototypeOf(element); 70 | const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set; 71 | if (valueSetter && valueSetter !== prototypeValueSetter) { 72 | prototypeValueSetter.call(element, value); 73 | } else { 74 | valueSetter.call(element, value); 75 | } 76 | element.dispatchEvent(new Event('input', { bubbles: true })); 77 | } 78 | 79 | const refreshInlineToken = (newInlineContent) => { 80 | if(newInlineContent) inlineContent = {...inlineContent, ...newInlineContent}; 81 | if(!inlineContent.method) return removeInlineToken(); 82 | inlineTokenExists = true; 83 | let text = inlineContent.method; 84 | if(inlineContent.value) { 85 | text += " " + inlineContent.value.trim().replace(/,/g, " or ").trim(); 86 | } 87 | searchInput.parentElement.classList.add("hotzearch_inline"); 88 | inlineToken.textContent = text; 89 | } 90 | 91 | const removeInlineToken = () => { 92 | inlineTokenExists = false; 93 | searchInput.parentElement.classList.remove("hotzearch_inline"); 94 | } 95 | 96 | const createStaticToken = (method, value, addColon, preventAnim) => { 97 | let allowAnim = !preventAnim && inited; 98 | let newTokenId = (Math.random() + 1).toString(36).substring(7); 99 | let newToken = document.createElement("div"); 100 | newToken.classList.add("staticToken"); 101 | allowAnim && newToken.classList.add("invisible"); 102 | newToken.addEventListener("click", removeStaticToken); 103 | newToken.setAttribute("data-id", newTokenId); 104 | let removeButton = "
"+closeBtn+"
"; 105 | staticTokens.push({ 106 | method, 107 | value, 108 | id: newTokenId, 109 | elem : newToken, 110 | status: "active" 111 | }); 112 | addColon && (method += ":"); 113 | newToken.innerHTML = method +" "+ value + removeButton; 114 | staticTokenWrap.append(newToken); 115 | animateStaticTokenChange(newToken, "push", allowAnim) 116 | } 117 | 118 | const removeStaticToken = (event) => { 119 | let tokenId = event.target.closest(".staticToken").getAttribute("data-id"); 120 | let staticTokenObj = staticTokens.find(token => token.id === tokenId); 121 | if(staticTokenObj && staticTokenObj.elem) { 122 | if(!event.target.closest(".staticTokenRemove")){ 123 | // Close button wasn't clicked, so the token body was clicked -> Edit token 124 | refreshInlineToken({method: staticTokenObj.method, value: null, spaceAfterValue: null, searchString: staticTokenObj.value.trim()}); 125 | setNativeValue(searchInput, staticTokenObj.value.trim()); 126 | searchInput.focus(); 127 | } 128 | animateStaticTokenChange(staticTokenObj.elem, "pull", false); 129 | staticTokenObj.elem.remove(); 130 | staticTokenObj.status = "removed"; 131 | } 132 | } 133 | 134 | const animateStaticTokenChange = (staticToken, direction, allowAnim) => { 135 | !allowAnim && primaryColumn.classList.add("noAnim"); 136 | let src, target 137 | if(direction == "push") [src, target] = [inlineToken, staticToken]; 138 | if(direction == "pull") [src, target] = [staticToken, inlineToken]; 139 | const wrapRect = staticTokenWrap.getBoundingClientRect(); 140 | const srcRect = src.getBoundingClientRect(); 141 | const targetRect = target.getBoundingClientRect(); 142 | const compSrc = getComputedStyle(src); 143 | const compTarget = getComputedStyle(target); 144 | let clone = src.cloneNode(true); 145 | clone.classList.add("cloneToken"); 146 | clone.style = `top: ${srcRect.top - wrapRect.top}px; 147 | left: ${srcRect.left - wrapRect.left}px; 148 | font-size: ${getComputedStyle(src).fontSize}; 149 | padding: ${getComputedStyle(src).padding}; 150 | line-height: ${getComputedStyle(src).lineHeight}; 151 | height: ${getComputedStyle(src).height};`; 152 | allowAnim && direction == "push" && staticTokenWrap.append(clone); //Pull animation needs work 153 | src.setAttribute("style", `width: ${srcRect.width}px`); 154 | setTimeout(() => {src.classList.add("minSize", "invisible", "deleting")}, 0); 155 | target.classList.remove("minSize"); 156 | clone.style = `top: ${srcRect.top - wrapRect.top}px; 157 | left: ${srcRect.left - wrapRect.left}px; 158 | transform: translate(${targetRect.left - srcRect.left}px, ${targetRect.top - srcRect.top}px); 159 | font-size: ${getComputedStyle(target).fontSize}; 160 | padding: ${getComputedStyle(target).padding}; 161 | line-height: ${getComputedStyle(target).lineHeight}; 162 | height: ${getComputedStyle(target).height};`; 163 | if((direction == "push" && targetRect.left - wrapRect.left == 0) || 164 | (direction == "pull" && srcRect.left - wrapRect.left == 0)){ 165 | let lineWidth = staticTokenWrap.clientWidth, thisLineWidth = 0, lineCount = 0; 166 | staticTokenWrap.childNodes.forEach(token => { 167 | if(token == src || token == clone) return; 168 | if(thisLineWidth == 0 || thisLineWidth + token.clientWidth > lineWidth){ 169 | lineCount++; 170 | thisLineWidth = token.clientWidth + 8; 171 | }else{ 172 | thisLineWidth += token.clientWidth + 8; 173 | } 174 | }) 175 | staticLineCount = lineCount; 176 | if(direction == "pull") { 177 | primaryColumn.classList.add("noDelayAnim"); 178 | staticTokenWrap.classList.add("noDelayAnim"); 179 | } 180 | let setTopHeight = () => { 181 | let wrapHeight = staticLineCount * 34; 182 | staticTokenWrap.style = `margin-top: ${0 - wrapHeight - 4}px; height: ${wrapHeight}px`; 183 | primaryColumn.style.transform = "translateY("+(wrapHeight + 10)+"px)"; 184 | } 185 | allowAnim && setTimeout(setTopHeight, 0); 186 | !allowAnim && setTopHeight(); 187 | } 188 | let animationTime = allowAnim && direction == "push" ? 600 : 0; 189 | setTimeout(() => { 190 | !allowAnim && primaryColumn.classList.remove("noAnim"); 191 | target.classList.remove("invisible", "creating"); 192 | clone.remove(); 193 | if(direction == "pull") src.remove(); // Remove staticToken 194 | if(direction == "push"){ 195 | inlineContent = {...inlineContent, method: null, value: null, spaceAfterValue: null} 196 | refreshInlineToken(); 197 | } 198 | src.style.width = null; 199 | src.classList.remove("invisible", "minSize", "deleting"); 200 | staticTokenWrap.classList.remove("noDelayAnim"); 201 | }, animationTime); 202 | } 203 | 204 | const getInlineTokenAsText = () => { 205 | if(!inlineContent.method) return ""; 206 | if(!inlineContent.value) return inlineContent.method + ":"; 207 | return inlineContent.method + ":" +inlineContent.value + " "; 208 | } 209 | 210 | const methods = { 211 | spaceIsEnter: ["from","to","lang","mentions","faves","replies","retweets","min_faves","min_replies","min_retweets"], 212 | anyFillIsEnter: ["until","since","before","after"], 213 | requireEnter: ["all","any","exact","none"], 214 | } 215 | methods.all = [...methods.spaceIsEnter, ...methods.anyFillIsEnter, ...methods.requireEnter]; 216 | methods.regex = methods.all.join("|"); 217 | 218 | const checkForParensText = (searchString) => { 219 | // Find text filter (any, all, exact, none) in search string: 220 | let fullMatch, match, method, value; 221 | let regex = /\((.*?)\) ?/g; 222 | while ((match = regex.exec(searchString)) != null) { 223 | [fullMatch, value] = match; 224 | method = "all"; // Default unless overridden 225 | if(value.indexOf(" OR ") > -1) [method, value] = ["any", value.replace(/ OR /g, " ")] 226 | if(value.indexOf("-") > -1) [method, value] = ["none", value.replace(/-/g, "")] 227 | if(value.startsWith("@")) [method, value] = ["mentions", value.replace(/ OR /g, " ")] 228 | if(value.startsWith("\"") && value.endsWith("\"")) [method, value] = ["exact", value.replace(/\"/g, "")] 229 | inited && refreshInlineToken({method, value}); 230 | createStaticToken(method, value, true); 231 | } 232 | return searchString.replace(regex, ""); 233 | } 234 | 235 | const processSearchString = (origString) => { 236 | // LTC4: Regex match the search string to pull out tokens. This matches either the full search string on page load 237 | // or the input from a keydown event. 238 | // Test regex at: https://regex101.com/r/MuEJ59/1 239 | let regex = new RegExp("\\(?("+methods.regex+")(?:: ?)(.+?)?\\)?($|(? (from:u1 or u2) 249 | if(searchString || spaceAfterValue == " "){ 250 | if(!value.trim().endsWith(" or") && !value.trim().endsWith(",")){ 251 | // Inline token is complete, and we can set it to be static 252 | if(["before", "after", "since", "until"].includes(method)) searchInput.type = "text"; 253 | if(["min_faves","min_replies","min_retweets"].includes(method)) method = method.replace("min_", ""); 254 | inited && refreshInlineToken({method, value, spaceAfterValue, searchString}); 255 | // LTC5: A token was found, push it to a static token 256 | createStaticToken(method, value); 257 | [method, value, spaceAfterValue, searchString] = [null, null, null, searchString || ""]; 258 | } 259 | searchString = checkForParensText(searchString); 260 | }else{ 261 | // Inline token not complete, set value as the editable search string 262 | let valueParts = /(.*[ ,])(.*)$|(.*)/g.exec(value); 263 | value = valueParts[1]; 264 | searchString = valueParts[2] || valueParts[3]; 265 | } 266 | } 267 | } 268 | // LTC6: Methods like any, all, exact and none don't have a method prefix. Find these matches with separate regex. 269 | searchString = checkForParensText(searchString || ""); 270 | inlineContent = {method, value, spaceAfterValue, searchString}; 271 | if(method){ 272 | // LTC7: After all full token matches, a "method" still remains so show the inline token 273 | refreshInlineToken(); 274 | if(["from", "to", "mentions"].includes(method) && searchString[0] != "@") 275 | searchString = "@" + searchString; 276 | } 277 | // LTC8: After all static and inline tokens, set any remaining text to be editable. 278 | setNativeValue(searchInput, searchString); 279 | } 280 | 281 | const protectUser = (cont) => { 282 | let protectionContent = document.createElement("div"); 283 | protectionContent.innerHTML = '
Search blocked
To make Twitter more fun, our AI has detected this account is not funny and has blocked searching for this user\'s tweets.
' 284 | cont.append(protectionContent); 285 | return true; 286 | } 287 | 288 | const userSelectedFromList = () => { 289 | const typeaheadSelected = searchForm.querySelector("[data-testid='typeaheadResult'][aria-selected=true]"); 290 | if(!typeaheadSelected) return false; 291 | const username = typeaheadSelected.querySelectorAll("span")[2].textContent.slice(1); 292 | if(username == "kathygriffin") return protectUser(typeaheadSelected); 293 | processSearchString("@" + username + " "); 294 | return true; 295 | } 296 | 297 | let selectOptions = ""; 298 | methods.all.filter(x => !["after", "before", "until", "since", "min_faves", "min_replies", "min_retweets"].includes(x)).forEach(method => { 299 | selectOptions+=``; 300 | }) 301 | const filterDialog = `
302 | 303 | 304 |
`; 305 | let filterDialogShown = false; 306 | const showFilterDialog = () => { 307 | event.preventDefault(); 308 | event.stopPropagation(); 309 | if(!filterDialogShown){ 310 | filterDialogShown = true; 311 | filterButton.innerHTML = filterBtn + filterDialog; // Can't hide/show with a class because on search submit Twitter will select this input 312 | filterButton.querySelector("input").addEventListener("keydown", e => e.key == "Enter" && submitFilterDialog()); 313 | } 314 | } 315 | const hideFilterDialog = () => { 316 | filterDialogShown = false; 317 | filterButton.innerHTML = filterBtn; 318 | } 319 | const submitFilterDialog = () => { 320 | let [select, input] = [filterButton.querySelector("select"), filterButton.querySelector("input")]; 321 | let includeColon = methods.requireEnter.includes(select.value) ? true : false; 322 | createStaticToken(select.value, input.value, includeColon, true); 323 | hideFilterDialog(); 324 | select.value = "from"; 325 | input.value = ""; 326 | setTimeout(()=>searchInput.focus(), 0); 327 | } 328 | 329 | const tokenizeSearch = (event) => { 330 | // LTC2: On key down, evaluate the entry 331 | if(event.key == "ArrowUp" || event.key == "ArrowDown") return true; // Up/Down arrows 332 | 333 | let value = event.target.value.trimStart(); 334 | 335 | if(event.key == "Backspace" && event.target.value == ""){ 336 | if(inlineContent.value){ 337 | setNativeValue(searchInput, inlineContent.value); 338 | inlineContent.value = null 339 | }else{ 340 | inlineContent.method = null; 341 | } 342 | refreshInlineToken(); 343 | return; 344 | } 345 | if(event.key == "Enter" && (inlineTokenExists || staticTokens.length)){ 346 | if(userSelectedFromList()){ 347 | // Enter was pressed to select a user from the typeahead list 348 | event.stopPropagation() 349 | event.preventDefault(); 350 | return false; 351 | } 352 | if(methods.requireEnter.includes(inlineContent.method)){ 353 | refreshInlineToken({...inlineContent, value: inlineContent.searchString, spaceAfterValue: "", searchString: ""}); 354 | createStaticToken(inlineContent.method, inlineContent.value, true); 355 | setNativeValue(searchInput, ""); 356 | return false; 357 | } 358 | // Reconstruct full query, and submit 359 | let prepend = ""; 360 | staticTokens.forEach((token) => { 361 | if(token.status == "active") { 362 | let {method, value} = token; 363 | if(method == "any") [method, value] = [null, value.replace(/ +/g, " OR ")]; 364 | if(method == "all") [method, value] = [null, value]; 365 | if(method == "exact") [method, value] = [null, "\"" + value + "\""]; 366 | if(method == "none") [method, value] = [null, value.replace(/(^| +)/g, "$1-")]; 367 | if(method == "mentions") [method, value] = [null, value.replace(/ *, *| +or +/g, " OR ")]; 368 | if(["faves", "replies", "retweets"].includes(method)) method = "min_"+method; 369 | if(["from", "to", "mentions"].includes(method)) value = value.replace(/ *, *| +or +/g, " OR " + token.method +":"); 370 | method = method ? method + ":" : ""; 371 | prepend = prepend + "("+method+value+") "; 372 | } 373 | }); 374 | prepend += getInlineTokenAsText(); 375 | setNativeValue(searchInput, prepend + value); 376 | return true; 377 | } 378 | if(event.key == " " && value.endsWith("@")) return false; 379 | if (event.key.length == 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { 380 | // LTC3: Add key entry to value 381 | event.stopPropagation(); 382 | event.preventDefault(); 383 | if(searchInput.selectionStart != value.length) value = value.trimLeft(); // Remove trailing spaces that were not just entered 384 | let newInput = event.key; 385 | if(event.key == "," && value.startsWith("@")) newInput = " or @"; 386 | value = value.slice(0, searchInput.selectionStart) + newInput + value.slice(searchInput.selectionEnd); 387 | processSearchString(value); 388 | searchInput.type != "date" && (searchInput.selectionStart += newInput.length); 389 | } 390 | }; 391 | 392 | let inited, searchInput, searchForm, primaryColumn, inlineToken, inlineContent, staticTokens, staticTokenWrap, staticLineCount, inlineTokenExists, filterButton; 393 | const init = async () => { 394 | // LTC1: Find and create page elements, add event listers 395 | inited = false; 396 | searchInput = await waitForElm("[data-testid='SearchBox_Search_Input']"); 397 | searchInput.placeholder = "Twitter Hot Search"; 398 | searchForm = searchInput.closest("form"); 399 | primaryColumn = searchForm.closest("[data-testid='primaryColumn'] > div") || searchForm.closest("[data-testid='sidebarColumn'] > div") ; 400 | primaryColumn.classList.add("hotzearch", "initing", "noAnim"); 401 | if(inlineToken && inlineToken.isConnected) inlineToken.remove(); 402 | inlineToken = document.createElement("div") 403 | inlineToken.classList.add("inlineToken"); 404 | searchInput.parentElement.prepend(inlineToken); 405 | if(staticTokenWrap && staticTokenWrap.isConnected) staticTokenWrap.remove(); 406 | staticTokenWrap = document.createElement("div") 407 | staticTokenWrap.classList.add("zearchTokenWrap"); 408 | searchForm.parentElement.prepend(staticTokenWrap); 409 | searchForm.querySelector('* > div').style.zIndex = 1; 410 | staticTokenWrap.innerHTML = ""; 411 | staticTokens = []; 412 | staticLineCount = 0; 413 | inlineContent = {}; 414 | inlineTokenExists = false; 415 | if(filterButton && filterButton.isConnected) filterButton.remove(); 416 | filterDialogShown = false; 417 | filterButton = document.createElement("div"); 418 | filterButton.classList.add("filterButton"); 419 | filterButton.innerHTML = filterBtn; 420 | searchInput.parentElement.append(filterButton); 421 | 422 | filterButton.addEventListener("click", showFilterDialog); 423 | document.addEventListener("click", hideFilterDialog); 424 | searchInput.addEventListener("keydown", tokenizeSearch); 425 | let lastValue = ""; 426 | searchInput.addEventListener("input", (e) => { 427 | if(lastValue == searchInput.value) return true; 428 | lastValue = searchInput.value; 429 | if(["before", "after", "since", "until"].includes(inlineContent.method)){ 430 | if(searchInput.type == "date" && searchInput.value.indexOf('m') == -1){ 431 | processSearchString(searchInput.value + " "); 432 | searchInput.type = "text"; 433 | lastValue = ""; 434 | }else{ 435 | searchInput.type = "date"; 436 | searchInput.showPicker(); 437 | } 438 | }else{ 439 | searchInput.type = "text"; 440 | } 441 | }); 442 | searchForm.addEventListener("click", (event) => { 443 | const el = event.target.closest("[data-testid='typeaheadResult']"); 444 | if(el){ 445 | el.setAttribute("aria-selected", true); 446 | if(userSelectedFromList(el)) { 447 | return event.stopPropagation() && event.preventDefault() && false; 448 | }; 449 | } 450 | }) 451 | searchInput.addEventListener('DOMNodeRemoved', (e) => e.target == searchInput && init()); 452 | 453 | // LTC9: On page load, tokenize the query 454 | processSearchString(searchInput.value);// Evalute searchInput.value on page load 455 | primaryColumn.classList.remove("initing", "noAnim"); 456 | inited = true; 457 | } 458 | 459 | await init(); 460 | 461 | let previousLoc = {...location}; 462 | let observer = new MutationObserver(function(mutations) { 463 | if (location.href !== previousLoc.href) { 464 | previousLoc = {...location}; 465 | init(); 466 | } 467 | }); 468 | const config = {subtree: true, childList: true}; 469 | observer.observe(document, config); 470 | 471 | console.log("Twitter Hot Search enabled"); 472 | })(); --------------------------------------------------------------------------------