Last active
October 11, 2025 07:36
-
-
Save mohitmun/3063340a7eb7058b24651dcc9a967a49 to your computer and use it in GitHub Desktop.
gpt_starred_feature.js - Use it your own risk. ChatGPT account might get blocked
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name ChatGPT – Pin Starred Chats + Visible White Star Toggle | |
| // @namespace mohit.chatgpt.star.pin.fixed | |
| // @version 1.0.1 | |
| // @description Pins starred chats to top, shows visible white-outline star icons, and adds a toggle beside Share. Uses Authorization Bearer + cookies. | |
| // @match https://chat.openai.com/* | |
| // @match https://chatgpt.com/* | |
| // @grant none | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| (function () { | |
| "use strict"; | |
| const STAR_QUERY = { | |
| offset: 0, | |
| limit: 50, | |
| order: "updated", | |
| is_archived: "false", | |
| is_starred: "true", | |
| }; | |
| const CLASSES = { | |
| rowStarIcon: "cgpt-row-star-icon", | |
| headerStarBtn: "cgpt-header-star-btn", | |
| processedRow: "cgpt-row-processed", | |
| }; | |
| const SELECTORS = { | |
| nav: [ | |
| 'nav[aria-label="Chat history"]', | |
| 'aside nav[aria-label]', | |
| 'div[role="navigation"] nav', | |
| 'aside:has(nav) nav' | |
| ], | |
| chatRow: [ | |
| 'nav[aria-label="Chat history"] a[href^="/c/"][role="link"]', | |
| 'nav[aria-label="Chat history"] a[href^="/c/"]' | |
| ], | |
| shareBtn: [ | |
| 'button[aria-label*="Share"]', | |
| '[data-testid="share-conversation-button"]', | |
| 'button:has(svg[aria-label*="Share"])' | |
| ], | |
| convHeader: [ | |
| '[data-testid="conversation-header"]', | |
| 'header:has(button)', | |
| 'div:has(> h1):has(button)' | |
| ], | |
| }; | |
| const sleep = (ms) => new Promise(r => setTimeout(r, ms)); | |
| const log = (...a) => console.log("[StarPin]", ...a); | |
| function qsAny(root, arr) { for (const s of arr) { const el = (root || document).querySelector(s); if (el) return el; } return null; } | |
| function getConversationIdFromUrl() { const m = location.pathname.match(/\/c\/([a-z0-9-]+)/i); return m ? m[1] : null; } | |
| async function getAccessToken() { | |
| const r = await fetch("/api/auth/session", { credentials: "include" }); | |
| if (!r.ok) throw new Error("session fetch failed"); | |
| const j = await r.json(); | |
| if (!j?.accessToken) throw new Error("no accessToken"); | |
| return j.accessToken; | |
| } | |
| async function apiGetStarred() { | |
| const token = await getAccessToken(); | |
| const usp = new URLSearchParams(); | |
| Object.entries(STAR_QUERY).forEach(([k, v]) => usp.set(k, String(v))); | |
| const r = await fetch(`/backend-api/conversations?${usp}`, { | |
| headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, | |
| credentials: "include" | |
| }); | |
| if (!r.ok) throw new Error(`GET starred -> ${r.status}`); | |
| return r.json(); | |
| } | |
| async function apiGetConversation(id) { | |
| const token = await getAccessToken(); | |
| const r = await fetch(`/backend-api/conversation/${id}`, { | |
| headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, | |
| credentials: "include" | |
| }); | |
| if (!r.ok) throw new Error(`GET conv -> ${r.status}`); | |
| return r.json(); | |
| } | |
| async function apiPatchConversation(id, patch) { | |
| const token = await getAccessToken(); | |
| const r = await fetch(`/backend-api/conversation/${id}`, { | |
| method: "PATCH", | |
| headers: { | |
| Authorization: `Bearer ${token}`, | |
| "Content-Type": "application/json", | |
| Accept: "application/json" | |
| }, | |
| credentials: "include", | |
| body: JSON.stringify(patch) | |
| }); | |
| if (!r.ok) throw new Error(`PATCH conv -> ${r.status}`); | |
| return r.json().catch(() => ({})); | |
| } | |
| /* ---------- STAR ICON ---------- */ | |
| function makeStarSVG({ filled = false, size = 16 } = {}) { | |
| const svgNS = "http://www.w3.org/2000/svg"; | |
| const svg = document.createElementNS(svgNS, "svg"); | |
| svg.setAttribute("viewBox", "0 0 24 24"); | |
| svg.setAttribute("width", size); | |
| svg.setAttribute("height", size); | |
| svg.setAttribute("stroke", "#fff"); // ✅ force visible white | |
| svg.setAttribute("stroke-width", "2"); | |
| svg.setAttribute("stroke-linecap", "round"); | |
| svg.setAttribute("stroke-linejoin", "round"); | |
| svg.style.opacity = "0.8"; | |
| const poly = document.createElementNS(svgNS, "polygon"); | |
| poly.setAttribute("points", "12 2 15 9 22 9 17 14 19 21 12 17 5 21 7 14 2 9 9 9"); | |
| if (filled) svg.setAttribute("fill", "#fff"); else svg.setAttribute("fill", "none"); | |
| svg.appendChild(poly); | |
| return svg; | |
| } | |
| /* ---------- SIDEBAR ---------- */ | |
| function getSidebarNav() { return qsAny(document, SELECTORS.nav); } | |
| function getAllChatRows() { | |
| const nav = getSidebarNav(); if (!nav) return []; | |
| return Array.from(nav.querySelectorAll(SELECTORS.chatRow.join(","))); | |
| } | |
| function getRowConversationId(aEl) { | |
| const m = aEl.getAttribute("href")?.match(/\/c\/([a-z0-9-]+)/i); | |
| return m ? m[1] : null; | |
| } | |
| function ensureRowStarIcon(aEl) { | |
| if (aEl.querySelector("." + CLASSES.rowStarIcon)) return; | |
| const iconWrap = document.createElement("span"); | |
| iconWrap.className = CLASSES.rowStarIcon; | |
| iconWrap.style.display = "inline-flex"; | |
| iconWrap.style.alignItems = "center"; | |
| iconWrap.style.marginRight = "6px"; | |
| iconWrap.appendChild(makeStarSVG({ filled: false, size: 13 })); | |
| aEl.prepend(iconWrap); | |
| } | |
| function removeRowStarIcon(aEl) { | |
| const el = aEl.querySelector("." + CLASSES.rowStarIcon); | |
| if (el) el.remove(); | |
| } | |
| async function pinStarredToTop() { | |
| const nav = getSidebarNav(); | |
| if (!nav) return; | |
| let starredIds = new Set(); | |
| try { | |
| const data = await apiGetStarred(); | |
| const items = data.items || data.data || data.conversations || []; | |
| starredIds = new Set(items.map(i => i.id)); | |
| } catch (e) { log("load starred failed", e); } | |
| const rows = getAllChatRows(); | |
| if (!rows.length) return; | |
| const container = rows[0].parentElement; | |
| const starred = [], others = []; | |
| for (const row of rows) { | |
| const id = getRowConversationId(row); | |
| if (id && starredIds.has(id)) { ensureRowStarIcon(row); starred.push(row); } | |
| else { removeRowStarIcon(row); others.push(row); } | |
| } | |
| const frag = document.createDocumentFragment(); | |
| starred.forEach(r => frag.appendChild(r)); | |
| others.forEach(r => frag.appendChild(r)); | |
| container.appendChild(frag); | |
| } | |
| /* ---------- HEADER STAR BUTTON ---------- */ | |
| let headerStarBtn = null; | |
| function ensureHeaderStarToggle() { | |
| if (headerStarBtn && document.body.contains(headerStarBtn)) return headerStarBtn; | |
| const share = qsAny(document, SELECTORS.shareBtn); | |
| const header = share?.closest("div") || qsAny(document, SELECTORS.convHeader); | |
| if (!header) return null; | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = CLASSES.headerStarBtn; | |
| btn.title = "Star / Unstar conversation"; | |
| Object.assign(btn.style, { | |
| marginLeft: "8px", | |
| border: "none", | |
| background: "transparent", | |
| cursor: "pointer", | |
| display: "inline-flex", | |
| alignItems: "center", | |
| justifyContent: "center", | |
| padding: "2px", | |
| color: "inherit" | |
| }); | |
| btn.appendChild(makeStarSVG({ filled: false, size: 16 })); | |
| btn.addEventListener("click", onHeaderStarClicked); | |
| header.appendChild(btn); | |
| headerStarBtn = btn; | |
| return btn; | |
| } | |
| async function onHeaderStarClicked() { | |
| const id = getConversationIdFromUrl(); | |
| if (!id) return; | |
| try { | |
| setHeaderBusy(true); | |
| const conv = await apiGetConversation(id); | |
| const desired = !conv?.is_starred; | |
| await apiPatchConversation(id, { is_starred: desired }); | |
| await syncHeaderStarVisual(); | |
| await pinStarredToTop(); | |
| } catch (e) { console.error(e); alert("Star toggle failed: " + e.message); } | |
| finally { setHeaderBusy(false); } | |
| } | |
| function setHeaderBusy(busy) { | |
| if (!headerStarBtn) return; | |
| headerStarBtn.style.opacity = busy ? "0.5" : "1"; | |
| headerStarBtn.style.pointerEvents = busy ? "none" : "auto"; | |
| } | |
| async function syncHeaderStarVisual() { | |
| const id = getConversationIdFromUrl(); | |
| if (!id) return; | |
| const btn = ensureHeaderStarToggle(); | |
| if (!btn) return; | |
| try { | |
| const conv = await apiGetConversation(id); | |
| const starred = !!conv?.is_starred; | |
| btn.innerHTML = ""; | |
| btn.appendChild(makeStarSVG({ filled: starred, size: 16 })); | |
| btn.title = starred ? "Unstar conversation" : "Star conversation"; | |
| } catch { | |
| btn.innerHTML = ""; | |
| btn.appendChild(makeStarSVG({ filled: false, size: 16 })); | |
| } | |
| } | |
| /* ---------- INIT ---------- */ | |
| async function bootstrap() { | |
| for (let i = 0; i < 30; i++) { | |
| if (getSidebarNav()) break; | |
| await sleep(150); | |
| } | |
| await pinStarredToTop(); | |
| await syncHeaderStarVisual(); | |
| const mo = new MutationObserver(async () => { | |
| ensureHeaderStarToggle(); | |
| await pinStarredToTop(); | |
| }); | |
| mo.observe(document.documentElement, { childList: true, subtree: true }); | |
| let last = location.pathname; | |
| setInterval(async () => { | |
| if (location.pathname !== last) { | |
| last = location.pathname; | |
| await syncHeaderStarVisual(); | |
| await pinStarredToTop(); | |
| } | |
| }, 900); | |
| } | |
| if (document.readyState === "complete" || document.readyState === "interactive") bootstrap(); | |
| else document.addEventListener("DOMContentLoaded", bootstrap); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment