├── LICENSE
├── README.md
└── script.js
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 TJ
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TwitterSearchTokenTest
2 |
3 | Comma AI guy made a Tweet ;)
4 |
5 | https://twitter.com/realGeorgeHotz/status/1595270867402956801?s=20&t=zL5Om6wTGfjsFiik4Z63Pw
6 |
7 | I made this:
8 |
9 | 
10 |
11 | [Demo Video](https://twitter.com/TJEvarts/status/1595600733914669062?s=20&t=_cdefDme6RcCFnC4_3ngkQ)
12 |
13 | Thank you George! I am honored! Big thanks to the Twitter community and can't wait to see Twitter 2.0 develop. I do love working on cool projects so hit me up on Twitter anyone working on changing the world.
14 |
15 | 
16 |
17 | ---
18 |
19 | [Challenge Results Thread](https://twitter.com/realGeorgeHotz/status/1596003668599328768?s=20&t=aOEUqX3LdV3dOEh3sTIevg)
20 |
21 | ## Version 0.1.3!!! SUPER SUPER GREAT NOW!
22 |
23 | ### What's new?
24 |
25 | - Completed the functionality of tokens using twitter advanced search
26 | - Created way to define arbitrary search tokens using JSON
27 | - Added TypeAhead aka Suggestions Box for multiple types of tokens
28 | - Added support for non-text Tokens (dates, times, colors, etc)
29 | - Added support for client side suggestion sorting with custom tokens
30 | - Added Keyboard Navigation
31 | - Styling update
32 | - Attempt to hook in search clear button if it exists
33 | - You can now tab to select suggestions
34 | - Dark Mode Support
35 | - Works on mobile!
36 |
37 | ## Usage
38 |
39 | Copy and paste the contents of script.js into the JS console of any twitter page with a search bar (i.e. [https://twitter.com/explore](https://twitter.com/explore)
40 |
41 | 
42 |
43 | I tried to keep the code/approach and usage as simple as possible.
44 |
45 | 
46 |
47 | ## Contribute
48 |
49 | Feel free to fork and make it your own! Also I eat PR's for breakfast :)
50 |
--------------------------------------------------------------------------------
/script.js:
--------------------------------------------------------------------------------
1 | /* Twitter Search Tokenization UI Demo
2 | * Author: TJ Evarts
3 | * Date: 11/23/2022
4 | * Version: 0.1.3
5 | * Free to Use by anybody
6 | *
7 | * Usage: Copy and paste this whole file into the js console of any twitter page that has a search box
8 | * and start typing in search and some of the supported tokens defined below
9 | *
10 | * Future Expansion
11 | * Impliment the token Pre (prefix). Add all supported advanced search supported tokens. Maybe also add client side sorting features to add additional
12 | * Capabilities to the search. Add bookmarks search token. Figure out how to load search results without refreshing page.
13 | */
14 |
15 | //main search mod object
16 | const search = {
17 | default: `
`,
18 | //define supported tokens:
19 | tokenObjects: [
20 | {
21 | t: "from:",
22 | pre: "@",
23 | type: "text", //html input type to take advantage of formatting and native pickers
24 | q: (v) => `from:${v}`, //function used to resolve token to query string
25 | searchMethod: "users", //define what kind of suggestions script should show the user
26 | },
27 | {
28 | t: "to:",
29 | pre: "@",
30 | type: "text",
31 | q: (v) => `to:${v}`,
32 | searchMethod: "users",
33 | },
34 | {
35 | t: "mention:",
36 | pre: "@",
37 | type: "text",
38 | q: (v) => `(${v})`,
39 | searchMethod: "users",
40 | },
41 | {
42 | t: "before:",
43 | pre: " ",
44 | type: "date",
45 | q: (v) => `until:${v}`,
46 | searchMethod: "",
47 | },
48 | {
49 | t: "after:",
50 | pre: " ",
51 | type: "date",
52 | q: (v) => `since:${v}`,
53 | searchMethod: "",
54 | },
55 | {
56 | t: "exact:",
57 | pre: " ",
58 | type: "text",
59 | q: (v) => `"${v.substring(1)}"`,
60 | searchMethod: "",
61 | }, // This token below totally works! Uncomment to check it out:
62 | // {
63 | // t: "time:",
64 | // pre: " ",
65 | // type: "time",
66 | // q: (v) => `"${v}"`,
67 | // searchMethod: "",
68 | // },
69 | ],
70 |
71 | //set up helper functions
72 | container: (val) => (document.querySelector(".filter-container").innerHTML = val),
73 | containerLive: (val) => (document.querySelector(".filter-container-live").innerHTML = val),
74 | listBox: () => document.querySelector("[role=listbox]") || document.querySelector("[role=search] div.r-zchlnj"),
75 | input: () => document.querySelector("[data-testid=SearchBox_Search_Input]"),
76 | tokenTemplate: (key, value) => {
77 | return `${key} ${value}
`;
78 | },
79 | liveTokenTemplate: (key, value) => {
80 | return `${value}
`;
81 | },
82 |
83 | //Inject Token CSS
84 | injectCSS: () => {
85 | var css = `
86 | .filter-container, .filter-container-live, .flex-center{
87 | white-space: nowrap;
88 | }
89 | .flex-center {
90 | align-items: center;
91 | }
92 | .filter-wrapper{
93 | max-width: 50%;
94 | overflow-x: hidden;
95 | white-space: nowrap;
96 | cursor:grab;
97 | }
98 | .token {
99 | white-space: nowrap;
100 | display: inline-block;
101 | padding: 3px 13px;
102 | background-color: #1d9bf0;
103 | color: white;
104 | border-radius: 1em;
105 | margin-right: 1px;
106 | }
107 | .token.live{
108 | background-color: #dadada;
109 | color: #222;
110 | }
111 | .token.live::after {
112 | content: "|";
113 | opacity: 0.1;
114 | animation: blink 0.5s infinite alternate;
115 | }
116 | @keyframes blink {
117 | 0%{
118 | opacity:0.1;
119 | }
120 | 100% {
121 | opacity:1;
122 | }
123 | }
124 | .list-item.active, .list-item:hover{
125 | background-color: rgb(29, 155, 240);
126 | }
127 | .list-item .text-wrap{
128 | display:inline-block;
129 | }
130 | .list-item .text-wrap, .list-item img{
131 | vertical-align:middle;
132 | }
133 | input:not([type=text]) {
134 | opacity:0;
135 | }
136 | .darkmode .list-item div{
137 | color: rgb(247, 249, 249);
138 | }
139 | .filter-wrapper.darkmode div{
140 | color: rgb(247, 249, 249);
141 | }
142 | .filter-wrapper.darkmode .live{
143 | color: #222;
144 | }
145 | `,
146 | head = document.head || document.getElementsByTagName("head")[0],
147 | style = document.createElement("style");
148 | head.appendChild(style);
149 | style.type = "text/css";
150 | style.id = "injected_css";
151 | if (style.styleSheet) {
152 | // This is required for IE8 and below.
153 | style.styleSheet.cssText = css;
154 | } else {
155 | style.appendChild(document.createTextNode(css));
156 | }
157 | search.input().parentNode.classList.add("flex-center");
158 | },
159 |
160 | //tokens are stored in data attributes in the search input tag
161 | init: () => {
162 | search.reset();
163 | search.injectCSS();
164 | //intialize dom containers
165 | search.input().parentNode.innerHTML = "" + $("[data-testid=SearchBox_Search_Input]").parentNode.innerHTML;
166 | //add event listeners on input
167 | search.addListeners();
168 | search.input().dataset.tokens = "";
169 | search.input().dataset.liveToken = "";
170 | //mouse click and drag listeners for token container
171 | let slider = document.querySelector(".filter-wrapper");
172 | slider.addEventListener("mousedown", (e) => {
173 | search.slide.isDown = true;
174 | slider.classList.add("active");
175 | search.slide.startX = e.pageX - slider.offsetLeft;
176 | search.slide.scrollLeft = slider.scrollLeft;
177 | });
178 | slider.addEventListener("mouseleave", () => {
179 | search.slide.isDown = false;
180 | slider.classList.remove("active");
181 | });
182 | slider.addEventListener("mouseup", () => {
183 | search.slide.isDown = false;
184 | slider.classList.remove("active");
185 | });
186 | slider.addEventListener("mousemove", (e) => {
187 | if (!search.slide.isDown) return;
188 | e.preventDefault();
189 | const x = e.pageX - slider.offsetLeft;
190 | const walk = (x - search.slide.startX) * 3; //scroll-fast
191 | slider.scrollLeft = search.slide.scrollLeft - walk;
192 | });
193 | search.checkDarkMode();
194 | },
195 | checkDarkMode: () => {
196 | //check for darkmode
197 | if ((document.querySelector("body").style.backgroundColor !== "rgb(255, 255, 255)" && document.querySelector("body").style.backgroundColor !== "#FFFFFF") || (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
198 | document.querySelector(".filter-wrapper").classList.add("darkmode");
199 | try {
200 | search.listBox().classList.add("darkmode");
201 | } catch {}
202 | }
203 | },
204 | getTokens: (obj) => {
205 | if (!obj.dataset || !obj.dataset.tokens || obj.dataset.tokens === "") return [];
206 | else return JSON.parse(obj.dataset.tokens);
207 | },
208 | saveTokens: (obj, tokens) => {
209 | obj.dataset.tokens = JSON.stringify(tokens);
210 | return tokens;
211 | },
212 | liveToken: (obj, val) => {
213 | if (val || val === "") obj.dataset.liveToken = val;
214 | return obj.dataset.liveToken;
215 | },
216 | renderTokens: (obj) => {
217 | let res = "";
218 | search.getTokens(obj).forEach((ele) => {
219 | res += search.tokenTemplate(ele.k, ele.v);
220 | });
221 | return res;
222 | },
223 | renderLiveToken: (obj, input) => {
224 | return search.liveTokenTemplate(search.liveToken(obj), search.liveToken(obj) + input);
225 | },
226 | slide: { isDown: false, startX: 0, scrollLeft: 0 },
227 | addListeners: (e) => {
228 | search.input().addEventListener("input", search.tokenizer, false);
229 | search.input().addEventListener("keydown", search.inputHandler, false);
230 | search.input().addEventListener(
231 | "focus",
232 | (ev) => {
233 | setTimeout(() => {
234 | //attempt to attach listener to search clear button if it exists
235 | try {
236 | search.checkDarkMode();
237 | document.querySelector("[role=search] [data-testid=clearButton]").addEventListener("click", (e) => {
238 | e.preventDefault();
239 | e.stopImmediatePropagation();
240 | search.input().dataset.tokens = "";
241 | search.input().dataset.liveToken = "";
242 | search.input().value = "";
243 | search.container("");
244 | search.containerLive("");
245 | search.clearList();
246 | });
247 | } catch {}
248 | }, 200);
249 | },
250 | false
251 | );
252 | },
253 | tokenizer: (e) => {
254 | let activeTok = "";
255 | if (
256 | search.tokenObjects
257 | .map((o) => o.t)
258 | .some((t) => {
259 | if (e.target.value.includes(t)) activeTok = t;
260 | return e.target.value.includes(t);
261 | }) > 0
262 | ) {
263 | search.liveToken(e.target, e.target.value);
264 | //clear input and make further text invisible;
265 | e.target.style.color = "transparent";
266 | e.target.style.width = "50%";
267 | e.target.type = search.tokenObjects.filter((o) => o.t === activeTok)[0].type;
268 | e.target.showPicker();
269 | if (e.target.type !== "text")
270 | setTimeout(() => {
271 | e.target.addEventListener(
272 | "change",
273 | (e) => {
274 | search.switchToken(e.target);
275 | let elClone = search.input().cloneNode(true);
276 | search.input().parentNode.replaceChild(elClone, search.input());
277 | search.addListeners();
278 | search.input().focus();
279 | },
280 | false
281 | );
282 | }, 200);
283 | e.target.value = " "; //this space is important
284 | search.clearList();
285 | }
286 | //update live token
287 | if (search.liveToken(e.target) !== "") {
288 | search.containerLive(search.renderLiveToken(e.target, e.target.value));
289 | let ltok = search.tokenObjects.filter((o) => search.liveToken(e.target) === o.t)[0];
290 | if (e.target.value.length > 1 && ltok.searchMethod !== "") search.predict(ltok.searchMethod, e.target.value);
291 | document.querySelector(".filter-wrapper").scrollLeft += 500;
292 | } else {
293 | search.predict("tokens", e.target.value);
294 | }
295 | },
296 | popToken: function (obj) {
297 | //Remove Last saved token from UI and memory
298 | let appended = search.getTokens(obj);
299 | appended.pop();
300 | search.saveTokens(obj, appended);
301 | search.container(search.renderTokens(obj));
302 | },
303 | inputHandler: (e) => {
304 | //look for keyboard navigation
305 | if (e.key === "Tab" || (e.key === "Enter" && search.liveToken(e.target) !== "" && !search.listBox().querySelector(".list-item.active"))) {
306 | e.preventDefault();
307 | e.stopImmediatePropagation();
308 | if (e.key === "Tab" && search.listBox().querySelector(".list-item.active")) {
309 | search.listBox().querySelector(".list-item.active").click();
310 | return;
311 | }
312 | search.switchToken(e.target);
313 | } else if (e.key === "Backspace" && e.target.value.length == 0) {
314 | e.target.style.color = "inherit";
315 | if (search.liveToken(e.target) === "") {
316 | e.preventDefault();
317 | e.stopImmediatePropagation();
318 | search.popToken(e.target);
319 | } else {
320 | search.liveToken(e.target, "");
321 | search.containerLive("");
322 | }
323 | } else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
324 | let actv = search.listBox().querySelector(".list-item.active");
325 | if (actv) {
326 | actv.classList.remove("active");
327 | let nxt = e.key === "ArrowDown" ? actv.nextSibling : actv.previousSibling;
328 | nxt.classList.add("active");
329 | } else {
330 | search.listBox().querySelector(".list-item").classList.add("active");
331 | }
332 | } else if (e.key === "Enter") {
333 | e.preventDefault();
334 | e.stopImmediatePropagation();
335 | if (search.listBox().querySelector(".list-item.active")) {
336 | search.listBox().querySelector(".list-item.active").click();
337 | return;
338 | }
339 | //clear live tokens
340 | search.search(e.target);
341 | }
342 | },
343 | switchToken: (obj) => {
344 | //switch live token to saved token
345 | obj.style.color = "inherit";
346 | obj.type = "text";
347 | //add live token to tokens
348 | let appended = search.getTokens(obj);
349 | appended.push({ k: search.liveToken(obj), v: obj.value });
350 | search.saveTokens(obj, appended);
351 | search.container(search.renderTokens(obj));
352 | //clear live token
353 | search.liveToken(obj, "");
354 | search.containerLive("");
355 | obj.value = "";
356 | search.clearList();
357 | search.clearDebounceTimeout();
358 | document.querySelector(".filter-wrapper").scrollLeft += 500;
359 | },
360 | getTokenString: (obj) => {
361 | //assemble query string from tokens
362 | return search
363 | .getTokens(obj)
364 | .map((o) => {
365 | let tok = search.tokenObjects.filter((f) => f.t === o.k)[0];
366 | return `${tok.q(o.v)} `;
367 | })
368 | .join("");
369 | },
370 | search: (obj) => {
371 | //simple version, anything further requires Twitter API
372 | window.open(`https://twitter.com/search?q=${search.getTokenString(obj)}${obj.value}`, "_self");
373 | // window.location = `https://twitter.com/search?q=${search.getTokenString(obj)}${obj.value}`;
374 | },
375 | predict: async (listType, query) => {
376 | //get type ahead list
377 | let url = null,
378 | userString = null,
379 | insert,
380 | list,
381 | hint;
382 | if (query) {
383 | if (query[0] == " ") query = query.substring(1);
384 | if (listType === "tokens") {
385 | insert = `ele.t
`;
386 | list = search.tokenObjects;
387 | hint = "t";
388 | } else if (listType === "users") {
389 | insert = `
`;
390 | userString = query.replace(" ", "");
391 | //v2 api - very limited:
392 | //url = "https://api.twitter.com/2/users/by?user.fields=created_at,description,name,profile_image_url,url,username,verified&usernames=" + query.replace(" ", "");
393 | //hint = "username";
394 | hint = "";
395 | }
396 | if (url != null) {
397 | const params = { method: "GET", headers: { Authorization: authorization_t } };
398 | list = await fetch(url, params)
399 | .then((data) => data.json())
400 | .then((data) => data.data);
401 | } else if (userString != null) {
402 | list = await search.debounceGetTypeahead(userString).then((data) => JSON.parse(data).users);
403 | }
404 | if (list) search.renderList(list, insert, hint);
405 | }
406 | },
407 | renderList: (list, insert, hint = "") => {
408 | //render suggestions list
409 | //client side sorting:
410 | if (hint !== "") {
411 | list.sort((o1, o2) => o2[hint].indexOf(search.input().value));
412 | }
413 | let res = list
414 | .map((ele, i) => {
415 | let item = ``;
416 | delete ele["profile_image_url"];
417 | Object.keys(ele).forEach((e) => {
418 | item = item.replaceAll("ele." + e, ele[e]);
419 | });
420 | return item;
421 | })
422 | .join("");
423 |
424 | search.listBox().innerHTML = res;
425 | document.querySelectorAll(".list-item").forEach((box) => {
426 | box.addEventListener(
427 | "click",
428 | (e) => {
429 | search.input().value = e.target.closest(".list-item").querySelector(".list-value").innerHTML;
430 | search.tokenizer({ target: search.input() });
431 | search.input().focus();
432 | if (!e.target.closest(".list-item").querySelector(".list-value").classList.contains("tokens")) search.switchToken(search.input());
433 | setTimeout(search.clearList, 200);
434 | },
435 | false
436 | );
437 | });
438 | return res;
439 | },
440 | clearList: () => {
441 | search.listBox().innerHTML = search.default;
442 | },
443 | //Optional reset function that allows you to keep pasting this file into the js console multiple times without errors
444 | reset: () => {
445 | search.input().type = "text";
446 | search.input().style.color = "inherit";
447 | search.input().removeEventListener("input", search.tokenizer, true);
448 | search.input().removeEventListener("keydown", search.inputHandler, true);
449 | try {
450 | document.querySelector("#injected_css").remove();
451 | document.querySelector(".filter-container").remove();
452 | document.querySelector(".filter-container-live").remove();
453 | } catch {}
454 | },
455 | /* More twitter api 1.1 helper functions:
456 | * ===================================
457 | * The code below was by Yaroslav (@512x512), the mad lad cracking the private non-documented Twitter apis. Go show him some love!
458 | * ===================================
459 | */
460 | getCookie: (cname) => {
461 | let name = cname + "=";
462 | let ca = document.cookie.split(";").find((v) => {
463 | return v.match(name);
464 | });
465 | return ca ? decodeURIComponent(ca).trim().replace(name, "") : "";
466 | },
467 | typeAheadUrl: "https://twitter.com/i/api/1.1/search/typeahead.json",
468 | getTypeAhead: (twitterHandle) => {
469 | return new Promise((resolve, reject) => {
470 | const requestUrl = new URL(search.typeAheadUrl);
471 | const csrfToken = search.getCookie("ct0");
472 | const gt = search.getCookie("gt");
473 |
474 | // constant in twitter js code
475 | const authorization = "AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
476 |
477 | requestUrl.searchParams.set("include_ext_is_blue_verified", 1);
478 | requestUrl.searchParams.set("q", `@${twitterHandle}`);
479 | requestUrl.searchParams.set("src", "search_box");
480 | requestUrl.searchParams.set("result_type", "users");
481 |
482 | const xmlHttp = new XMLHttpRequest();
483 | xmlHttp.open("GET", requestUrl.toString(), false);
484 | xmlHttp.setRequestHeader("x-csrf-token", csrfToken);
485 | xmlHttp.setRequestHeader("x-twitter-active-user", "yes");
486 |
487 | if (search.getCookie("twid")) {
488 | //check if user is logged in
489 | xmlHttp.setRequestHeader("x-twitter-auth-type", "OAuth2Session");
490 | } else {
491 | xmlHttp.setRequestHeader("x-guest-token", gt);
492 | }
493 | xmlHttp.setRequestHeader("x-twitter-client-language", "en");
494 | xmlHttp.setRequestHeader("authorization", `Bearer ${authorization}`);
495 |
496 | xmlHttp.onload = (e) => {
497 | if (xmlHttp.readyState === 4) {
498 | if (xmlHttp.status === 200) {
499 | resolve(xmlHttp.responseText);
500 | } else {
501 | reject(xmlHttp.statusText);
502 | }
503 | }
504 | };
505 |
506 | xmlHttp.onerror = (e) => {
507 | reject(xmlHttp.statusTexT);
508 | };
509 |
510 | xmlHttp.send(null);
511 | });
512 | },
513 | debounceGetTypeaheadTimeout: null,
514 | clearDebounceTimeout: () => {
515 | if (search.debounceGetTypeaheadTimeout) {
516 | clearTimeout(search.debounceGetTypeaheadTimeout);
517 | }
518 | },
519 | debounceGetTypeahead: (twitterHandle) => {
520 | return new Promise((resolve, reject) => {
521 | search.clearDebounceTimeout();
522 |
523 | search.debounceGetTypeaheadTimeout = setTimeout(() => {
524 | search
525 | .getTypeAhead(twitterHandle)
526 | .then((data) => {
527 | resolve(data);
528 | })
529 | .catch((err) => {
530 | reject(err);
531 | });
532 | }, 400);
533 | });
534 | },
535 | };
536 | search.init();
537 |
--------------------------------------------------------------------------------