ThundrPlus

avoid bots, save chat data and more features

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         ThundrPlus
// @namespace    http://tampermonkey.net/
// @version      2025-10-25
// @description  avoid bots, save chat data and more features
// @author       YassinMi
// @match        https://thundr.com/text
// @icon         https://www.google.com/s2/favicons?sz=64&domain=thundr.com
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_listValues
// @grant        GM_getValues
// @connect      ipapi.co
// @license MIT
// ==/UserScript==

//@ts-check



/**
 * @typedef {{ip:string,id:string,country:string,sex:string}} PartnerInfo
 */

/**
 * @typedef {{partnerInfo:PartnerInfo,matchedAt:Date,matchId:string}} Match
 */

/**
 * @typedef {{disconnectedAt:Date,matchId:string, from:string?, to:string}} DisconnectedArgs when disconection is pushed form me the from are nulll
 */
/**
 * @typedef {{from:"You"|"Stranger",content:string}[]} ConversationContent
 */
/**
 * @typedef {{match:Match,conversationContent:ConversationContent,disconnectedBy:"You"|"Stranger",disconnectedAt:Date}} Interaction
 */

class DBLayer {
    /**
     * 
     */
    static verifyReadyForNewMatchEntry() {
        if (this.matchesBuffer.length) {
            var latestMatchEntry = this.matchesBuffer[this.matchesBuffer.length - 1]
            if (latestMatchEntry.disconnectArgs === undefined) {
                console.warn("not ready for new match, the last match isn't properly disconnected, an implicit closure is made")
                latestMatchEntry.disconnectArgs = { disconnectedAt: new Date(), matchId: latestMatchEntry.match.matchId, from: "", to: "" }
            }
        }
    }

    /**
     * 
     * @param {string} matchId 
     * @returns {{match:Match,matchRaw:any,disconnectArgs:DisconnectedArgs?,scrapedConvo:ConversationContent?}|undefined}
     */
    static getMatchFromMatchBuffer(matchId) {
        return DBLayer.matchesBuffer.find(i => i.match.matchId === matchId);
    }

    /**
     * @type {DBLayer}
     */
    static DB = new DBLayer()
    /**
     * @type {{match:Match,matchRaw:any,disconnectArgs:DisconnectedArgs?,scrapedConvo:ConversationContent?}[]}
     */
    static matchesBuffer = []


    /**
     * @type {string?}
     */
    static latestKnownMatchId
    constructor() {

    }
    /**
     * Get all stored data
     * @returns {any}
     */
    getAllData() {
        // @ts-ignore
        var allKeys = GM_listValues()
        console.log(allKeys)
        // @ts-ignore
        const data = GM_getValues()
        const locStorageData = {...localStorage};
        const allData = { locStorage: locStorageData, GM: data }
       
        return allData
    }

   /**
    * created for a one time migration from local storage to GM storage,
    */
    copyToGMStorage(){
         //copy all keys that start with convoOf and noteOfIp_ and noteOfId_ from local storage to GM
        for (const key of Object.keys(localStorage)) {
            if (key.startsWith("convosOfId_") || key.startsWith("snapshotOfLegacyInteractions_") || key.startsWith("noteOfIp_") || key.startsWith("noteOfId_")) {
                // @ts-ignore
                GM_setValue(key, localStorage[key]);
            }
        }
    }

    /**
     * 
     * @param {string} matchId 
     * @param {ConversationContent} scrapedConvo 
     */
    updateScrapedConvoForMatchId(matchId, scrapedConvo) {
        console.log(`DB Cache: updating match entry ${matchId} with scraped convo ${scrapedConvo?.length}`)
        var matchEntry = DBLayer.getMatchFromMatchBuffer(matchId)
        if (matchEntry === undefined) {
            console.log("no such match entry registred")
            throw new Error("no such match entry registred")
        }
        else {

        }

        var maybeExists = matchEntry.scrapedConvo
        if (maybeExists) {

            if ((!scrapedConvo) || (maybeExists.length > scrapedConvo.length)) {
                return;
            }
            else {

            }
        }

        matchEntry.scrapedConvo = scrapedConvo


    }
    /**
     * 
     * @param {string} id 
     * @returns 
     */
    getInteractionssByID(id) {
        var oldData = window.localStorage.getItem("convosOfId_" + id)
        var parsedOldData = JSON.parse(oldData ?? "[]")
        return parsedOldData;
    }
    /**
     * 
     * @param {string} id 
     * return boolean
     */
    getIsIdPresentInLegacyHistory(id){
        //returns true if the id string appeans in any way in the local strorage
        return JSON.stringify(window.localStorage).includes(id)
    }
    /**
     * 
     * @param {Interaction} interaction 
     */
    addInteraction(interaction) {
        console.log("DB: saving interaction for id:", interaction)
        var oldData = window.localStorage.getItem("convosOfId_" + interaction.match.partnerInfo.id)
        var parsedOldData = JSON.parse(oldData ?? "[]")
        parsedOldData.push(interaction)
        window.localStorage.setItem("convosOfId_" + interaction.match.partnerInfo.id, JSON.stringify(parsedOldData))
        // @ts-ignore
        GM_setValue("convosOfId_" + interaction.match.partnerInfo.id, JSON.stringify(parsedOldData));
    }
    /**
     * 
     * @param {string} ip 
     * @param {string} note 
     */
    storeIPNote(ip, note) {
        console.log("DB: saving note:", note, ip)
        window.localStorage.setItem("noteOfIp_" + ip, note)
        // @ts-ignore
        GM_setValue("noteOfIp_" + ip, note);
    }
    /**
     * 
     * @param {string} ip 
     */
    getIPNote(ip) {
        return window.localStorage.getItem("noteOfIp_" + ip)
    }
    /**
     * 
     * @param {string} id 
     * @param {string} note 
     */
    storeIDNote(id, note) {
        console.log("DB: saving note:", note, id)
        window.localStorage.setItem("noteOfId_" + id, note)
        // @ts-ignore
        GM_setValue("noteOfId_" + id, note);
    }
    /**
     * 
     * @param {string} id 
     */
    getIDNote(id) {
        return window.localStorage.getItem("noteOfId_" + id)
    }
}

/**
 * this layer requires maintainance, it injects code that intercepts events and fires them in the the standard
 *  TP_MATCHED, TP_MESSAGE, TP_DISCONNECTED, TP_INIT layer 
 */
function hookEvents() {
    console.log("####### hookEvents called")
    // @ts-ignore
    if (( window)["tp_hookEvents_called"]) {
        throw new Error("hook events already called")
    }
    // @ts-ignore
    window["tp_hookEvents_called"] = true
    const originalLog = console.log;

    var matchLogDetector = /** @param {any[]} args */ function (...args) {
        if (args.length >= 2 && typeof args[0] === "string" && args[0].includes("$$$ matched! $$$")) {
            var arg = args[1]
            if (typeof arg === "object" && arg !== null && "match_id" in arg && "room" in arg && "partner" in arg) {
                return arg
            }
        }
    }
    var disconnectedLogDetector = /** @param {any[]} args */ function (...args) {
        if (args.length >= 2 && typeof args[0] === "string" && args[0].includes("$$$ user disconnected $$$")) {
            var arg = args[1]
            if (typeof arg === "object" && arg !== null && "match_id" in arg && "from" in arg && "to" in arg) {
                return arg
            }
        }
        return undefined
    }
    var disconnectedPushedFromMeLogDetector = /** @param {any[]} args */ function (...args) {
        if (args.length >= 1 && typeof args[0] === "string" && args[0].includes("$$$ push disconnect $$$")) {
            var concat = args.join("");
            var partnerId = (/**@type {string}*/(concat)).replace("$$$ push disconnect $$$", "").trim()
            return partnerId
        }
        return undefined
    }
    var experimentalPreDisconnectLog = /** @param {any[]} args */ function (...args) {
        if (args.length >= 1 && typeof args[0] === "string" && args[0].includes("fired event start_text_m")) {
            return true
        }
        return false
    }
    var experimentalLefRoomLog = /** @param {any[]} args */ function (...args) {
        if (args.length >= 1 && typeof args[0] === "string" && args[0].includes("left room")) {
            return true
        }
        return false
    }
    var startMatchLogDetector = /** @param {any[]} args */ function (...args) {
        if (args.length >= 1 && typeof args[0] === "string" && args[0].includes("$$$ start match $$$")) {
            return true
        }
        return false
    }
    //
    //$$$ push disconnect $$$ 230d75c2-d3f8-4bbb-86c0-16462add3fe7
    console.log = function (...args) {
        originalLog("fake log")
        originalLog.apply(console, args);

        var maybeMatchLogArg = matchLogDetector(...args)
        var maybeDisconnectLogArg = disconnectedLogDetector(...args)
        var maybeDisconnectFromMeLogArg = disconnectedPushedFromMeLogDetector(...args)
        var experimentalPreDisconnectLog_ = experimentalPreDisconnectLog(...args)
        var experimentalLefRoomLog_ = experimentalLefRoomLog(...args)
        var startMatchLogDetector_ = startMatchLogDetector(...args)
        if (startMatchLogDetector_) {
            console.log("####### TP_INIT fired")
            window.postMessage({ type: "TP_INIT", data: null }, "*");
        }
        else if (maybeMatchLogArg) {
            console.log(" log detected as maybeMatchLogArg")
            var arg = maybeMatchLogArg;
            const summary = {
                matchId: arg.match_id,
                matchedAt: new Date(),
                /**@type {PartnerInfo} */
                partnerInfo: { ip: arg.partner.ip, id: arg.partner.id, country: arg.partner?.country, sex: arg.partner.settings.profile.sex },

            };
            console.log("####### TP_MATCHED fired", summary)
            window.postMessage({ type: "TP_MATCHED", data: summary }, "*");
        }
        else if (maybeDisconnectLogArg) {
            console.log(" log detected as maybeDisconnectLogArg")
            //pollUpdateScrapedConvoForLatestMatch()
            var arg = maybeDisconnectLogArg;
            /**
             * @type {DisconnectedArgs}
             */
            const summary = {
                matchId: arg.match_id,
                disconnectedAt: new Date(),
                from: arg.from,
                to: arg.to
            };
            console.log("####### TP_DISCONNECTED fired", summary)
            window.postMessage({ type: "TP_DISCONNECTED", data: summary }, "*");
        }
        else if (maybeDisconnectFromMeLogArg) {
            console.log(" log detected as maybeDisconnectFromMeLogArg")
            //pollUpdateScrapedConvoForLatestMatch()
            var partnerId = maybeDisconnectFromMeLogArg;
            /**
             * @type {DisconnectedArgs}
             */
            const summary = {
                matchId: "DBLayer.latestKnownMatchId",
                disconnectedAt: new Date(),
                from: null,
                to: partnerId
            };
            console.log("####### TP_DISCONNECTED fired", summary)
            window.postMessage({ type: "TP_DISCONNECTED", data: summary }, "*");
        }
        else if (experimentalPreDisconnectLog_) {
            console.log(" log detected as experimentalPreDisconnectLog_")
            //pollUpdateScrapedConvoForLatestMatch()
        }
        else if (experimentalLefRoomLog_) {
            console.log(" log detected as experimentalLefRoomLog_")
            //pollUpdateScrapedConvoForLatestMatch()
        }
    };

}
/**
 * the part of injecting UI that relys on thundr UI and needs maintainance, only styling depends on thudr, the returned html structure does not change
 */

function injectUI_impl() {
    /**
     * @type {HTMLDivElement}
     */
    const targetDiv = /**@type {HTMLDivElement}*/(document.evaluate("//div[@class='css-17vaizm']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue);
    var tpRoot = document.createElement("div")
    tpRoot.classList.add("css-1pum37", "tp-panel")
    tpRoot.innerHTML = `
    <style>
    /* ThundrPlus Modern Styling - Scoped to avoid site conflicts */
    .tp-panel {
      background: linear-gradient(135deg, rgba(20, 20, 30, 0.95), rgba(10, 10, 20, 0.95));
      border-radius: 16px;
      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1);
      backdrop-filter: blur(10px);
      border: 1px solid rgba(255, 255, 255, 0.1);
      color: #ffffff;
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      overflow: hidden;
      width: 640px;
      max-width: 90vw;
    }

    .tp-header {
      display: flex;
      flex-direction: row;
      justify-content: space-between;
      align-items: center;
      padding: 12px 16px 8px;
      background: linear-gradient(90deg, rgba(59, 130, 246, 0.1), rgba(147, 51, 234, 0.1));
      border-bottom: 1px solid rgba(255, 255, 255, 0.1);
    }

    .tp-nav-section {
      display: flex;
      flex-direction: row;
      align-items: center;
      gap: 12px;
    }

    .tp-nav-buttons {
      display: flex;
      flex-direction: column;
      gap: 4px;
    }

    .tp-nav-btn {
      background: rgba(255, 255, 255, 0.1);
      border: 1px solid rgba(255, 255, 255, 0.2);
      border-radius: 8px;
      font-size: 14px;
      color: #ffffff;
      cursor: pointer;
      padding: 6px 10px;
      transition: all 0.2s ease;
      min-width: 32px;
      display: flex;
      align-items: center;
      justify-content: center;
    }

    .tp-nav-btn:hover:not(:disabled) {
      background: rgba(59, 130, 246, 0.3);
      border-color: rgba(59, 130, 246, 0.5);
      transform: translateY(-1px);
      box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
    }

    .tp-nav-btn:disabled {
      opacity: 0.4;
      cursor: not-allowed;
      background: rgba(255, 255, 255, 0.05);
    }

    .tp-cursor-status {
      font-size: 14px;
      font-weight: 600;
      color: rgba(255, 255, 255, 0.8);
      background: rgba(255, 255, 255, 0.1);
      padding: 4px 8px;
      border-radius: 6px;
      min-width: 40px;
      text-align: center;
    }

    .tp-status-section {
      display: flex;
      flex-direction: column;
      align-items: flex-end;
      font-size: 12px;
      gap: 4px;
    }

    .tp-match-id {
      font-family: 'Courier New', monospace;
      background: rgba(0, 0, 0, 0.3);
      padding: 4px 8px;
      border-radius: 4px;
      font-size: 11px;
      color: rgba(255, 255, 255, 0.7);
    }

    .tp-connection-status {
      font-weight: 600;
      padding: 2px 6px;
      border-radius: 4px;
      font-size: 11px;
    }

    .tp-connection-status.connected {
      background: rgba(34, 197, 94, 0.2);
      color: #22c55e;
    }

    .tp-connection-status.disconnected {
      background: rgba(239, 68, 68, 0.2);
      color: #ef4444;
    }

    .tp-legacy-indicator {
      display: none;
      align-items: center;
      gap: 6px;
      color: #22c55e;
      font-weight: 600;
      font-size: 11px;
      background: rgba(34, 197, 94, 0.1);
      padding: 2px 6px;
      border-radius: 4px;
    }

    .tp-legacy-indicator.hasHistory {
      display: inline-flex;
    }

    .tp-donation-container {
      position: relative;
      display: inline-block;
      margin-top: 4px;
    }

    .tp-donation-trigger {
      font-size: 10px;
      color: rgba(255, 255, 255, 0.5);
      background: rgba(255, 255, 255, 0.05);
      border: 1px solid rgba(255, 255, 255, 0.1);
      border-radius: 50%;
      width: 16px;
      height: 16px;
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: help;
      transition: all 0.2s ease;
      position: relative;
    }

    .tp-donation-trigger:hover {
      color: rgba(255, 255, 255, 0.8);
      background: rgba(255, 255, 255, 0.1);
      border-color: rgba(255, 255, 255, 0.2);
    }

    .tp-donation-tooltip {
      position: absolute;
      right: 100%;
      top: 50%;
      transform: translateY(-50%);
      background: rgba(20, 20, 30, 0.95);
      border: 1px solid rgba(255, 255, 255, 0.2);
      border-radius: 6px;
      padding: 8px 12px;
      font-size: 11px;
      color: #ffffff;
      white-space: normal;
      opacity: 0;
      visibility: hidden;
      transition: all 0.2s ease;
      z-index: 1000;
      margin-right: 4px;
      min-width: 180px; 
    }

    .tp-donation-tooltip::after {
      content: "";
      position: absolute;
      left: 100%;
      top: 50%;
      transform: translateY(-50%);
      border: 4px solid transparent;
      border-left-color: rgba(255, 255, 255, 0.2);
    }

    .tp-donation-container:hover .tp-donation-tooltip,
    .tp-donation-container.active .tp-donation-tooltip {
      opacity: 1;
      visibility: visible;
      transform: translateY(-50%) translateX(-2px);
    }

    .tp-donation-link {
      color: #3b82f6;
      text-decoration: none;
      font-weight: 500;
    }

    .tp-donation-link:hover {
      text-decoration: underline;
    }

    .tp-content {
      padding: 12px 16px;
    }

    .tp-info-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
      gap: 8px;
      margin-bottom: 12px;
    }

    .tp-info-item {
      display: flex;
      flex-direction: column;
      gap: 2px;
    }

    .tp-info-label {
      font-size: 11px;
      font-weight: 600;
      color: rgba(255, 255, 255, 0.7);
      text-transform: uppercase;
      letter-spacing: 0.5px;
    }

    .tp-info-value {
      font-size: 13px;
      color: #ffffff;
      background: rgba(255, 255, 255, 0.05);
      padding: 4px 8px;
      border-radius: 4px;
      border: 1px solid rgba(255, 255, 255, 0.1);
      word-break: break-all;
    }

    .tp-info-value.has-history {
      color: #22c55e;
      font-weight: 600;
    }

    .tp-notes-section {
      margin-bottom: 12px;
    }

    .tp-note-group {
      display: flex;
      flex-direction: row;
      align-items: flex-start;
      gap: 8px;
      margin-bottom: 8px;
    }

    .tp-note-textarea {
      flex: 1;
      background: rgba(255, 255, 255, 0.05);
      border: 1px solid rgba(255, 255, 255, 0.2);
      border-radius: 6px;
      color: #ffffff;
      font-family: inherit;
      font-size: 13px;
      padding: 6px 8px;
      resize: vertical;
      min-height: 40px;
      transition: all 0.2s ease;
    }

    .tp-note-textarea:focus {
      outline: none;
      border-color: rgba(59, 130, 246, 0.5);
      box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
      background: rgba(255, 255, 255, 0.08);
    }

    .tp-note-textarea::placeholder {
      color: rgba(255, 255, 255, 0.5);
    }

    .tp-save-btn {
      background: linear-gradient(135deg, #3b82f6, #1d4ed8);
      border: none;
      border-radius: 6px;
      color: #ffffff;
      cursor: pointer;
      font-size: 13px;
      font-weight: 600;
      padding: 6px 12px;
      transition: all 0.2s ease;
      min-width: 70px;
    }

    .tp-save-btn:hover:not(:disabled) {
      background: linear-gradient(135deg, #2563eb, #1e40af);
      transform: translateY(-1px);
      box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
    }

    .tp-save-btn:disabled {
      opacity: 0.5;
      cursor: not-allowed;
      background: rgba(255, 255, 255, 0.1);
    }

    .tp-toast {
      background: rgba(34, 197, 94, 0.1);
      border: 1px solid rgba(34, 197, 94, 0.3);
      border-radius: 4px;
      color: #22c55e;
      font-size: 11px;
      font-weight: 600;
      padding: 4px 8px;
      margin-bottom: 8px;
      text-align: center;
    }

    .tp-history-table {
      width: 100%;
      border-collapse: collapse;
      margin-top: 8px;
      background: rgba(255, 255, 255, 0.05);
      border-radius: 6px;
      overflow: hidden;
      border: 1px solid rgba(255, 255, 255, 0.1);
    }

    .tp-history-table th {
      background: linear-gradient(90deg, rgba(59, 130, 246, 0.1), rgba(147, 51, 234, 0.1));
      border: 1px solid rgba(255, 255, 255, 0.1);
      padding: 6px 6px;
      font-size: 11px;
      font-weight: 600;
      color: #ffffff;
      text-transform: uppercase;
      letter-spacing: 0.5px;
    }

    .tp-history-table td {
      border: 1px solid rgba(255, 255, 255, 0.1);
      padding: 6px 6px;
      font-size: 11px;
      color: rgba(255, 255, 255, 0.9);
    }

    .tp-history-table tbody tr {
      transition: background-color 0.2s ease;
    }

    .tp-history-table tbody tr:hover {
      background: rgba(255, 255, 255, 0.05);
    }

    .tp-toggle-chat-btn {
      background: linear-gradient(135deg, #7c3aed, #a855f7);
      border: none;
      border-radius: 6px;
      color: #ffffff;
      cursor: pointer;
      font-size: 13px;
      font-weight: 600;
      padding: 8px 16px;
      margin: 8px 0;
      transition: all 0.2s ease;
      width: 100%;
    }

    .tp-toggle-chat-btn:hover {
      background: linear-gradient(135deg, #6d28d9, #9333ea);
      transform: translateY(-1px);
      box-shadow: 0 4px 12px rgba(124, 58, 237, 0.3);
    }

    .tp-chat-popup {
      position: absolute;
      display: flex;
      top: 80px;
      width: calc(100% - 32px);
      left: 16px;
      background: linear-gradient(135deg, rgba(15, 15, 25, 0.95), rgba(5, 5, 15, 0.95));
      border-radius: 8px;
      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1);
      backdrop-filter: blur(10px);
      border: 1px solid rgba(255, 255, 255, 0.1);
      max-height: 35vh;
      flex-direction: column;
      z-index: 1000;
    }

    .tp-chat-header {
      display: flex;
      justify-content: flex-end;
      padding: 8px 12px;
      border-bottom: 1px solid rgba(255, 255, 255, 0.1);
      background: linear-gradient(90deg, rgba(59, 130, 246, 0.05), rgba(147, 51, 234, 0.05));
    }

    .tp-chat-content {
      max-height: calc(100% - 50px);
      min-height: 0;
      padding: 12px;
      overflow-y: auto;
    }

    .tp-chat-content::-webkit-scrollbar {
      width: 6px;
    }

    .tp-chat-content::-webkit-scrollbar-track {
      background: rgba(255, 255, 255, 0.1);
      border-radius: 3px;
    }

    .tp-chat-content::-webkit-scrollbar-thumb {
      background: rgba(255, 255, 255, 0.3);
      border-radius: 3px;
    }

    .tp-chat-content::-webkit-scrollbar-thumb:hover {
      background: rgba(255, 255, 255, 0.5);
    }

    .tp-message {
      margin-bottom: 12px;
      padding: 8px 12px;
      border-radius: 8px;
      font-size: 14px;
      line-height: 1.4;
    }

    .tp-info-item.hidden {
        display: none;
    }

    .tp-message b {
      font-weight: 600;
      margin-right: 8px;
    }

    /* Responsive adjustments */
    @media (max-width: 768px) {
      .tp-panel {
        width: 95vw;
        max-width: none;
      }

      .tp-header {
        padding: 8px 12px 6px;
        flex-direction: column;
        align-items: flex-start;
        gap: 8px;
      }

      .tp-content {
        padding: 8px 12px;
      }

      .tp-info-grid {
        grid-template-columns: 1fr;
        gap: 6px;
      }

      .tp-note-group {
        flex-direction: column;
        gap: 6px;
      }

      .tp-chat-popup {
        width: calc(100% - 24px);
        left: 12px;
        max-height: 30vh;
      }
    }
    </style>


    <div id="pt-panel-header" class="tp-header">
      <div class="tp-nav-section">
        <div class="tp-nav-buttons">
          <button class="tp-nav-btn" id="tp-prev-btn">▲</button>
          <button class="tp-nav-btn" disabled id="tp-next-btn">▼</button>
        </div>
        <div id="tp-cursor-status" class="tp-cursor-status">No match yet</div>
      </div>
      <div class="tp-status-section">
        <div id="tp-match-id-lbl" class="tp-match-id">1254-5468-56548-54654</div>
        <div id="tp-connected-status" class="tp-connection-status disconnected">Disconnected</div>
        <div id="tp-has-legacy-history-indicator" class="tp-legacy-indicator">history</div>
        <div class="tp-donation-container">
          <div class="tp-donation-trigger" title="You like this tool? support the developer">?</div>
          <div class="tp-donation-tooltip">
            you like this tool? 
            <a href="https://ko-fi.com/yassmi" target="_blank" class="tp-donation-link">☕ donate</a>
            <br>
            <br>
            copy <a href="#" id="share-link-anchor" class="tp-donation-link">installation link</a> to share with a friend
          </div>
        </div>
      </div>
    </div>

    <div class="tp-content">
      <div class="tp-info-grid">
        <div class="tp-info-item">
          <div class="tp-info-label">IP Address</div>
          <div style="display: flex; flex-direction: column; gap: 4px;">
            <div style="display: flex; align-items: center; gap: 8px;">
              <div id="ip-span" class="tp-info-value">-</div>
              <button id="fetch-city-btn" class="tp-nav-btn" style="font-size: 10px; padding: 2px 6px;">City</button>
            </div>
            <div id="city-info" class="tp-info-value" style="font-size: 12px; color: rgba(255, 255, 255, 0.7); display: none;"></div>
          </div>
        </div>
        <div class="tp-info-item">
          <div class="tp-info-label">User ID</div>
          <div id="id-span" class="tp-info-value">-</div>
        </div>
        <div class="tp-info-item hidden">
          <div class="tp-info-label">IP Notes</div>
          <div id="ipNote-span" class="tp-info-value">-</div>
        </div>
        <div class="tp-info-item hidden">
          <div class="tp-info-label">ID Notes</div>
          <div id="notes-span" class="tp-info-value">-</div>
        </div>
        <div class="tp-info-item">
          <div class="tp-info-label">Country</div>
          <div id="info-span" class="tp-info-value">-</div>
        </div>
        <div class="tp-info-item">
          <div class="tp-info-label">Conversation Count</div>
          <div id="convoCount-span" class="tp-info-value">-</div>
        </div>
      </div>

      <div class="tp-notes-section">
        <div class="tp-note-group">
          <textarea rows="1" cols="50" placeholder="Write IP notes..." id="tp-ipnote-textarea" class="tp-note-textarea"></textarea>
          <button type="button" id="tp-save-ipnote-btn" class="tp-save-btn">Save</button>
        </div>
        <div class="tp-note-group">
          <textarea rows="1" cols="50" placeholder="Write ID notes..." id="tp-idnote-textarea" class="tp-note-textarea"></textarea>
          <button type="button" id="tp-save-idnote-btn" class="tp-save-btn">Save</button>
        </div>
      </div>


      <table id="history-table" class="tp-history-table">
        <thead>
          <tr>
            <th>Date</th>
            <th>Type</th>
            <th>Msg Count</th>
            <th>Duration</th>
            <th>Last Message</th>
          </tr>
        </thead>
        <tbody>
          <tr>
            <td>2025-04-23</td>
            <td>They skipped</td>
            <td>12</td>
            <td>5m 42s</td>
            <td>They: Thanks, got..!</td>
          </tr>
        </tbody>
      </table>

      <div id="toast-status" class="tp-toast">ready</div>

      <div style="display: flex; justify-content: center; margin-top: 10px;">
        <button id="export-data-btn" class="tp-nav-btn" style="font-size: 12px; padding: 4px 8px;">Export Data</button>
      </div>

    </div>

    <div id="tp-convo-popup" class="tp-chat-popup" style="display: none;">
      <div class="tp-chat-header">
        <button type="button" class="tp-toggle-chat-btn tp-toggle-convo-popup-btn">Hide Chat</button>
      </div>
      <div class="tp-chat-content hide-scrollbar css-1qs2dh2 tp-convo-popup">
      </div>
    </div>
    `;
    targetDiv.appendChild(tpRoot)
    return tpRoot;
}

/**
 * 
 * @returns {ConversationContent}
 */
function scrapeConversationContent() {
    /**
     * @type {ConversationContent} 
     */
    var res = []
    /**
     * @type {HTMLDivElement}
     */
    const chatRootDiv = /**@type {HTMLDivElement}*/(document.evaluate("//div[contains(@class,'css-1qs2dh2')]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue);
    if(!chatRootDiv) throw new Error("cannot find chatRootDiv, selector may need to be updated")
    var messagesDivs = Array.from(chatRootDiv.querySelectorAll(".css-wtl4b5"))

    messagesDivs.forEach(d => {
        var from =/**@type {"Stranger"|"You"}*/(d.querySelector("b")?.innerHTML)
        var content = ""
        d.childNodes.forEach(node => {
            if (node.nodeType === Node.TEXT_NODE) {
                content += node.textContent;
            }
        });
        res.push({ from: from, content: content })
    })
    //div[@class='css-1sl5lif']
    return res;
}

class Convo {
    /**
     * 
     * @param {PartnerInfo} partnerInfo
     */
    constructor(partnerInfo) {
        this.partner = partnerInfo;
        this.messages = []
    }

}

class TPUIComponent {
    /**
     * 
     * @param {Interaction} interaction 
     */
    notifyInteractionAdded(interaction) {
        this.pages.forEach(p => {
            if (p.match.partnerInfo.ip == interaction.match.partnerInfo.ip) {
                p.interactions.push(interaction)
                if (this.pages[this._current] === p) {
                    this.pushPartnerHistoryDetail(p.interactions, p.ipNote, p.idNote, p.hasLegacyHistory);
                }
            }
        })
    }
    /**
     * @typedef {{match:Match,interactions:Interaction[], ipNote:string?,idNote:string?,dirtyIpNote:string?,dirtyIdNote:string?,pageIndex:number,disconnectedArg:DisconnectedArgs?, hasLegacyHistory:boolean}} UIPage
     */
    /**
     * @type {TPUIComponent}
     */
    static mainPanel

    /**
     * 
     * @param {HTMLDivElement} root 
     */
    constructor(root) {
        console.log("constructing TPUIComponent")
        this._root = root;

        this._cursorStatusDiv = (/**@type {HTMLDivElement} */ (this._root.querySelector("#tp-cursor-status")))
        this._connectedStatusDiv = (/**@type {HTMLDivElement} */ (this._root.querySelector("#tp-connected-status")))
        this._navPrevBtn = (/**@type {HTMLButtonElement} */ (this._root.querySelector("#tp-prev-btn")))
        this._navNextBtn = (/**@type {HTMLButtonElement} */ (this._root.querySelector("#tp-next-btn")))
        this._tpMatchHeaderDiv = (/**@type {HTMLDivElement} */ (this._root.querySelector("#tp-match-id-lbl")))
        this._ipNoteTextarea = (/**@type {HTMLTextAreaElement} */ (this._root.querySelector("#tp-ipnote-textarea")))
        this._idNoteTextarea = (/**@type {HTMLTextAreaElement} */ (this._root.querySelector("#tp-idnote-textarea")))
        this._historyTable = (/**@type {HTMLTextAreaElement} */ (this._root.querySelector('#history-table')))
        this._hasLegacyHistoryIndicator = (/**@type {HTMLDivElement} */ (this._root.querySelector("#tp-has-legacy-history-indicator")))

        this._saveIpnoteBtn = (/**@type {HTMLButtonElement} */ (this._root.querySelector("#tp-save-ipnote-btn")))
        this._saveIdnoteBtn = (/**@type {HTMLButtonElement} */ (this._root.querySelector("#tp-save-idnote-btn")))
        this._toggleConvoPopupVisibilityBtns = (/**@type {HTMLButtonElement[]} */ (Array.from(this._root.querySelectorAll(".tp-toggle-convo-popup-btn"))))
        this._convoPopupDiv = (/**@type {HTMLDivElement} */ (this._root.querySelector("#tp-convo-popup")))
        this._navPrevBtn.addEventListener("click", this._onPrevClick.bind(this))
        this._navNextBtn.addEventListener("click", this._onNextClick.bind(this))
        this._navNextBtn.addEventListener('contextmenu', (ev) => {
            ev.preventDefault();
            this._onNextToEndClick();
            return false;
        }, false);
        this._saveIpnoteBtn.addEventListener("click", this._onSaveIpnoteClick.bind(this))
        this._saveIdnoteBtn.addEventListener("click", this._onSaveIdnoteClick.bind(this))
        this._toggleConvoPopupVisibilityBtns.forEach(b => {
            b.addEventListener("click", this._onToggleConvoPopupClick.bind(this))
        })
        this._ipNoteTextarea.addEventListener("input", this._onIpNoteInputChange.bind(this));
        this._idNoteTextarea.addEventListener("input", this._onIdNoteInputChange.bind(this));
        this.isAutoPaging = true;
        this._isTransparent = false;
        /**
         * @type {UIPage[]}
         */
        this.pages = []
        this._donationTrigger = (/**@type {HTMLDivElement} */ (this._root.querySelector('.tp-donation-trigger')));
        this._donationContainer = (/**@type {HTMLDivElement} */ (this._root.querySelector('.tp-donation-container')));
        this._fetchCityBtn = (/**@type {HTMLButtonElement} */ (this._root.querySelector("#fetch-city-btn")));
        this._fetchCityBtn.addEventListener("click", this._onFetchCityClick.bind(this));
        this._exportDataBtn = (/**@type {HTMLButtonElement} */ (this._root.querySelector("#export-data-btn")));
        this._exportDataBtn.addEventListener("click", this._onExportDataClick.bind(this));
        this._shareLinkAnchor = (/**@type {HTMLAnchorElement} */ (this._root.querySelector("#share-link-anchor")));
        this._shareLinkAnchor.addEventListener("click", this._onShareLinkClick.bind(this));

        this._root.querySelector("#pt-panel-header")?.addEventListener("dblclick", (ev) => {
            if (ev.srcElement !== this._root.querySelector("#pt-panel-header")) {
                return;
            }
            console.log(ev)
            this._isTransparent = !this._isTransparent;
            this._root.style.opacity = this._isTransparent ? "0.2" : "100%"
        })
        this._current = 0;
    }

    /**
     * 
     * @param {boolean} open 
     */
    _updateConvoPopupOpenStatus(open) {
        this._isConvoPopupOpen = open;
        this._convoPopupDiv.style.display = open ? "flex" : "none";
    }
    /**
     * 
     * @param {Interaction} interaction 
     */
    _populateConvoPopupWithContent(interaction) {
        var container = /**@type {HTMLDivElement} */ (this._convoPopupDiv.querySelector(".css-1qs2dh2"));
        container.innerHTML = ""
        interaction.conversationContent.forEach(m => {
            var messageDiv = document.createElement("div")
            messageDiv.classList.add("css-wtl4b5")
            messageDiv.innerHTML = (m.from?.includes("Stranger")) ? `<b style="color: var(--chakra-colors-messages-stranger);">Stranger:</b>`
                : `<b style="color: var(--chakra-colors-messages-you);">You:</b>`
            messageDiv.appendChild(document.createTextNode(m.content));

            container?.appendChild(messageDiv);


        })
    }
    _onToggleConvoPopupClick() {
        this._updateConvoPopupOpenStatus(!this._isConvoPopupOpen);
    }
    _onDonationTriggerClick() {
        this._donationContainer.classList.toggle('active');
    }
    _onFetchCityClick() {
        const ipElement = /**@type {HTMLDivElement} */ (this._root.querySelector("#ip-span"));
        if (!ipElement) return;
        const ip = ipElement.innerText.trim();
        if (!ip || ip === "-") {
            this.pushToast("No IP address to fetch city for");
            return;
        }
        const cityInfoElement = /**@type {HTMLDivElement} */ (this._root.querySelector("#city-info"));
        if (!cityInfoElement) return;
        this._fetchCityBtn.disabled = true;
        this._fetchCityBtn.innerText = "Loading...";
        cityInfoElement.innerText = "Fetching city...";
        cityInfoElement.style.display = "block";
        // @ts-ignore
        GM_xmlhttpRequest({
            method: 'GET',
            url: `https://ipapi.co/${ip}/json/`,
            onload: (/** @type {{ responseText: string; }} */ response) => {
                try {
                    const data = JSON.parse(response.responseText);
                    if (data.city) {
                        cityInfoElement.innerText = `City: ${data.city} | region: ${data.region} | country: ${data.country_name} | ISP: ${data.org}`;
                        this.pushToast("City fetched successfully");
                    } else {
                        cityInfoElement.innerText = "City: Unknown";
                        this.pushToast("Failed to fetch city");
                    }
                } catch (e) {
                    cityInfoElement.innerText = "City: Error";
                    this.pushToast("Error parsing response");
                }
            },
            onerror: (/** @type {any} */ error) => {
                cityInfoElement.innerText = "City: Error";
                console.error("Error fetching city:", error);
                this.pushToast("Error fetching city");
            },
            ontimeout: () => {
                cityInfoElement.innerText = "City: Timeout";
                this.pushToast("Request timed out");
            }
        });
        // Re-enable button after a delay or in onload
        setTimeout(() => {
            this._fetchCityBtn.disabled = false;
            this._fetchCityBtn.innerText = "City";
        }, 1000); // Adjust as needed
    }
    _onExportDataClick() {
        const data = DBLayer.DB.getAllData();
        const jsonString = JSON.stringify(data, null, 2);
        const blob = new Blob([jsonString], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        //example "2025-10-05 dump.json" (do not use iso)
        var now = new Date();
        const formattedName  = now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, '0') + "-" + String(now.getDate()).padStart(2, '0') + " dump.json";
        a.download = formattedName;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
        this.pushToast("Data exported successfully");
    }
    _onShareLinkClick(/** @type {Event} */ event) {
        event.preventDefault();
        const link = "https://greasyfork.org/en/scripts/534034-thundrplus";
        navigator.clipboard.writeText(link).then(() => {
            this.pushToast("Link copied to clipboard!");
        }).catch(err => {
            console.error("Failed to copy link: ", err);
            this.pushToast("Failed to copy link");
        });
    }
    _onSaveIpnoteClick() {
        //save to db and update dirty note and note in the page
        if (!this._getCurrentPage()) { return }
        console.log("saving ipnote", this._getCurrentPage().dirtyIpNote)
        DBLayer.DB.storeIPNote(this._getCurrentPage().match.partnerInfo.ip, this._getCurrentPage()?.dirtyIpNote || "");
        this._getCurrentPage().ipNote = this._getCurrentPage().dirtyIpNote;
        this._refreshButtonsEnabledState()
        console.log("saved ipnote", this._getCurrentPage().dirtyIpNote)
        this.pushToast("saved note")

    }
    _onSaveIdnoteClick() {
        //save to db and update dirty note and note in the page
        if (!this._getCurrentPage()) { return }
        console.log("saving idnote", this._getCurrentPage().dirtyIdNote)
        DBLayer.DB.storeIDNote(this._getCurrentPage().match.partnerInfo.id, this._getCurrentPage()?.dirtyIdNote || "");
        this._getCurrentPage().idNote = this._getCurrentPage().dirtyIdNote;
        this._refreshButtonsEnabledState()
        console.log("saved idnote", this._getCurrentPage().dirtyIdNote)
        this.pushToast("saved note")

    }
    _onIpNoteInputChange() {
        if (!this._getCurrentPage()) {
            return;
        }
        var newNote = this._ipNoteTextarea.value
        this.pages[this._current].dirtyIpNote = newNote;

        this._refreshButtonsEnabledState()
        console.log("change detected", this._getCurrentPage()?.dirtyIpNote)
    }
    _onIdNoteInputChange() {
        if (!this._getCurrentPage()) {
            return;


        }
        var newNote = this._idNoteTextarea.value
        this.pages[this._current].dirtyIdNote = newNote;

        this._refreshButtonsEnabledState()
        console.log("change detected", this._getCurrentPage()?.dirtyIdNote)
    }

    /**
     * 
     * @param {number} current 
     */
    _updateCursor(current) {
        this._current = current;
        this._refreshButtonsEnabledState()
        this._refreshDisplayedCursor()

    }
    _refreshButtonsEnabledState() {
        this._navPrevBtn.disabled = !this._canClickPrev()
        this._navNextBtn.disabled = !this._canClickNext()
        this._saveIpnoteBtn.disabled = !this._canClickSaveIpnote()
        this._saveIdnoteBtn.disabled = !this._canClickSaveIdnote()
    }
    _refreshDisplayedCursor() {
        if (this.pages.length > 0) {
            this._cursorStatusDiv.innerHTML = `${this._current + 1}/${this.pages.length}`
        }
        else {
            this._cursorStatusDiv.innerHTML = `(no matches)`
        }
    }
    _canClickPrev() {
        return (this.pages.length > 0 && this._current > 0)
    }
    _canClickNext() {
        return (this.pages.length > 0 && this._current < this.pages.length - 1)

    }
    _getCurrentPage() {
        return this.pages[this._current]
    }
    _canClickSaveIpnote() {
        if (!this._getCurrentPage()) {
            return false;
        }
        return this._getCurrentPage() && this._getCurrentPage().ipNote !== this._getCurrentPage().dirtyIpNote
    }
    _canClickSaveIdnote() {
        if (!this._getCurrentPage()) {
            return false;
        }
        return this._getCurrentPage() && this._getCurrentPage().idNote !== this._getCurrentPage().dirtyIdNote
    }
    /**
     * 
     * @param {Event} ev
     */
    _onPrevClick(ev) {
        ev.stopPropagation();
        if (!this._canClickPrev()) return;
        this.displayPageOfIndex(this._current - 1)
    }
    /**
     * 
     * @param {Event} ev
     */
    _onNextClick(ev) {
        ev.stopPropagation();
        if (!this._canClickNext()) return;
        this.displayPageOfIndex(this._current + 1)


    }
    _onNextToEndClick() {
        if (!this._canClickNext()) return;
        this.displayPageOfIndex(this.pages.length - 1)
    }
    /**
     * 
     * @param {string} date 
     * @param {*} type 
     * @param {*} msgCount 
     * @param {*} duration 
     * @param {string?} lastMsg 
     * @param {Interaction} interactionRef 
     */
    _addTableRow(date, type, msgCount, duration, lastMsg, interactionRef) {
        const table = this._historyTable.getElementsByTagName('tbody')[0];
        const row = table.insertRow();

        const cells = [date, type, msgCount, duration, lastMsg];
        for (let i = 0; i < cells.length; i++) {
            const cell = row.insertCell();
            if (i === 0) {
                //making the date clickable to open the chat popup
                cell.innerHTML = `<a href="javascript:;">${cells[i]}</a>`
                cell.querySelector("a")?.addEventListener("click", () => {
                    this._populateConvoPopupWithContent(interactionRef)
                    this._updateConvoPopupOpenStatus(true);
                })
            }
            else if (i === 1) {
                cell.innerHTML = cells[i];//for advanced convo type styling (comes as html)
            }
            else if (i === 4) {

                const div = document.createElement("div");
                div.textContent = lastMsg;
                div.title = cells[i];

                Object.assign(div.style, {
                    maxWidth: "100px",
                    whiteSpace: "normal",
                    overflow: "hidden",
                    textOverflow: "ellipsis",
                    display: "-webkit-box",
                    WebkitLineClamp: "2",
                    WebkitBoxOrient: "vertical"
                });

                cell.textContent = ""; // Clear cell's default text
                cell.appendChild(div);
            }
            else {
                cell.textContent = cells[i];
            }
            cell.style.border = "1px solid white";
            cell.style.padding = "4px";
        }
    }

    _clearTableRows() {
        const tbody = this._historyTable.getElementsByTagName('tbody')[0];
        while (tbody.firstChild) {
            tbody.removeChild(tbody.firstChild);
        }
    }

   /**
    * 
    * @param {boolean} visible 
    */
    _toggleTableVisibility(visible) {
        this._historyTable.style.display = (!!visible) ? 'table' : 'none';
    }
    /**
     * 
     * @param {boolean} isConnected 
     * @param {string} connectedInfo 
     * @param {boolean} hasHistory 
     */
    _renderTitleState(isConnected, connectedInfo, hasHistory){
      if(!isConnected){
        //@ts-ignore
        document.title = window.originalTitle;
      }
      else{
        document.title = `${connectedInfo} ${(hasHistory ? '🟢' : '⚪')}`;
      }
    }
    /**
     * 
     * @param {string} ip 
     * @param {string} mId 
     * @param {string} id
     * @param {string} sex 
     * @param {string} country 
     */
    _renderMatchDetails(ip, mId, id, sex, country) {

        (/**@type {HTMLSpanElement} */ (this._root.querySelector("#ip-span"))).innerHTML = ip;
        (/**@type {HTMLSpanElement} */ (this._root.querySelector("#id-span"))).innerHTML = id;
        (/**@type {HTMLSpanElement} */ (this._root.querySelector("#info-span"))).innerHTML = `${sex?.toUpperCase() ?? "?"} - ${country?.toUpperCase()}`;
        const cityInfoElement = /**@type {HTMLDivElement} */ (this._root.querySelector("#city-info"));
        cityInfoElement.innerText = "";
        cityInfoElement.style.display = "none";
        this._tpMatchHeaderDiv.innerText = mId
    }
    /**
     * @param {string | number} convoCount
     * @param {Interaction?} lastInteraction
     * @param {string | null} ipNote
     * @param {string | null} idNote
     * @param {boolean} hasLegacyHistory
     */
    _renderPartnerHistory(convoCount, lastInteraction, ipNote, idNote, hasLegacyHistory) {
        const convoCountSpan = /**@type {HTMLDivElement} */ (this._root.querySelector("#convoCount-span"));
        convoCountSpan.innerHTML = convoCount?.toString() ?? "never";
        convoCountSpan.classList.toggle('has-history', !!convoCount);

        this._ipNoteTextarea.value = ipNote || "";
        this._idNoteTextarea.value = idNote || "";
        this._hasLegacyHistoryIndicator.classList.toggle('hasHistory', hasLegacyHistory);
    }
    _isInLastPageOrEmptyPage() {
        return (this.pages.length === 0) || (this._current == this.pages.length - 1);
    }
    /**
     * 
     * @param {Match} match 
     * @param {Interaction[]} interactions 
     * @param {string?} ipNote 
     * @param {string?} idNote
     * @param {boolean} hasLegacyHistory
     */
    pushPage(match, interactions, ipNote, idNote, hasLegacyHistory) {
        var shouldAutoPage = this.isAutoPaging && this._isInLastPageOrEmptyPage();
        /**
         * @type {UIPage}
         */
        var newPag = { match: match, interactions: interactions, ipNote: ipNote, idNote: idNote, dirtyIpNote: null, dirtyIdNote: null, pageIndex: this.pages.length, disconnectedArg: null, hasLegacyHistory: hasLegacyHistory }

        this.pages.push(newPag)
        this._renderTitleState(true,`${newPag?.match?.partnerInfo?.sex} - ${newPag?.match?.partnerInfo?.country}`, hasLegacyHistory);

        if (shouldAutoPage) {
            this._updateCursor(this.pages.length - 1)
            this.displayPageOfIndex(this._current)
        }
        else {
            this._updateCursor(this._current)
        }
    }
    _refreshPageConnectinStatus() {
        if (!this._getCurrentPage()) {
            this._connectedStatusDiv.innerText = ""
            this._connectedStatusDiv.classList.remove('connected', 'disconnected');
            return;
        }
        const isConnected = !this._getCurrentPage().disconnectedArg;
        this._connectedStatusDiv.innerText = isConnected ? "Connected" : "Disconnected";
        this._connectedStatusDiv.classList.toggle('connected', isConnected);
        this._connectedStatusDiv.classList.toggle('disconnected', !isConnected);
    }
    /**
     * 
     * @param {string} matchId 
     * @param {DisconnectedArgs} disconnectedArgs 
     */
    _updatePageConnectionStatus(matchId, disconnectedArgs) {
        this.pages.forEach(p => {
            if (p.match.matchId === matchId) {
                p.disconnectedArg = disconnectedArgs
            }
        })
        this._refreshPageConnectinStatus()
    }
    /**
     * 
     * @param {number} ix 
     */
    displayPageOfIndex(ix) {
        if (ix > (this.pages.length - 1)) {
            console.log("requested to diplay out of range page ", ix, this.pages)
            return;
        }
        var page = this.pages[ix]
        this.clear()
        this.pushMatch(page.match)
        this.pushPartnerHistoryDetail(page.interactions, page.ipNote, page.idNote, page.hasLegacyHistory)

        if (page.dirtyIpNote !== page.ipNote) {
            this._ipNoteTextarea.value = page.dirtyIpNote || ""
        }
        if (page.dirtyIdNote !== page.idNote) {
            this._idNoteTextarea.value = page.dirtyIdNote || ""
        }
        this._updateCursor(ix)
        this._refreshPageConnectinStatus()

    }
    clear() {
        this._clearTableRows();
        this._toggleTableVisibility(false)
    }
    /**
     * 
     * @param {Match} match 
     */
    pushMatch(match) {

        this._renderMatchDetails(match.partnerInfo.ip, match.matchId, match.partnerInfo.id, match.partnerInfo.sex, match.partnerInfo.country)
    }
    /**
     * 
     * @param {Interaction[]?} interactions 
     * @param {string?} ipNote 
     * @param {string?} idNote
     * @param {boolean} hasLegacyHistory
     */
    pushPartnerHistoryDetail(interactions, ipNote, idNote, hasLegacyHistory) {
        console.log("push history ", interactions)
        this._clearTableRows()
        /**
         * 
         * @param {string} str 
         */
        function truncateString(str) {
            return str.length > 20 ? str.slice(0, 19) + '…' : str;
        }
        if (interactions === null) {
            this._renderPartnerHistory("-", null, null, null, false)
            return;
        }
        var latestInteraction = interactions.length == 0 ? null : interactions.sort((a, b) => new Date(a.match.matchedAt).getTime() - new Date(b.match.matchedAt).getTime())[0]
        this._renderPartnerHistory(interactions.length, latestInteraction, ipNote, idNote, hasLegacyHistory)
        if (interactions && interactions.length) {
            this._toggleTableVisibility(true)
            interactions.forEach(i => {
                var msgCc = i.conversationContent.length
                var dur = getDurationString(i?.match.matchedAt, i?.disconnectedAt);
                var fullMessage = i.conversationContent.length > 0 ? (i.conversationContent[i.conversationContent.length - 1].from + i.conversationContent[i.conversationContent.length - 1].content) : null
                const date = new Date(i?.match.matchedAt);
                const friendlyDateTime = date.toLocaleString('en-GB', {
                    day: '2-digit',
                    month: 'long',
                    year: 'numeric',
                    hour: '2-digit',
                    minute: '2-digit',
                    hour12: false
                });
                this._addTableRow(friendlyDateTime, getInteractionType(i), msgCc, dur, fullMessage, i)
            })
        }
        else {
            this._toggleTableVisibility(false)
        }
    }
    onCloseConvoClick() {

    }
    /**
     * 
     * @param {string} message 
     */
    pushToast(message) {
        (/**@type {HTMLSpanElement} */ (this._root.querySelector("#toast-status"))).innerText = message;
    }

}

/**
 * set up the TP UI parts and injects them in the html and have the various standard events and TP UI events integrated,
 * this contains most of the app logic 
 */
function initTPUI() {
    console.log("initTPUI called")
    // @ts-ignore
    if (window["tp_initTPUI_called"]) {
        throw new Error("initTPUI already called")
    }
    // @ts-ignore
    window["tp_initTPUI_called"] = true
    var tpRoot = injectUI_impl()
    TPUIComponent.mainPanel = new TPUIComponent(tpRoot)

    var testIp = "yassinMi"
    TPUIComponent.mainPanel.pushMatch({ matchedAt: new Date(), matchId: "yass", partnerInfo: { ip: testIp, country: "ma", sex: "M", id: "111" } })
    TPUIComponent.mainPanel.pushPartnerHistoryDetail([], null, null, false)

    window.addEventListener("message", (event) => {
        if (event.data.type === "TP_MESSAGE") {

        }
        if (event.data.type === "TP_MATCHED") {
            /**
             * @type {Match}
             */
            const m = event.data.data
            console.log("matched recieved,", m)
            const gender = m.partnerInfo.sex === 'female' ? 'F' : 'M';
            const country = m.partnerInfo.country || '??';
            const hasHistory = DBLayer.DB.getIsIdPresentInLegacyHistory(m.partnerInfo.id);
            document.title = `${gender} - ${country}${hasHistory ? '*' : ''}`;
            var interactions = DBLayer.DB.getInteractionssByID(m.partnerInfo.id);
            var idNote = DBLayer.DB.getIDNote(m.partnerInfo.id)
            var ipNote = DBLayer.DB.getIPNote(m.partnerInfo.ip)
            TPUIComponent.mainPanel.pushPage(m, interactions, ipNote, idNote, hasHistory)
            DBLayer.verifyReadyForNewMatchEntry()
            DBLayer.matchesBuffer.push({ match: m, matchRaw: null, disconnectArgs: null, scrapedConvo: null })
            DBLayer.latestKnownMatchId = m.matchId
        }
        if (event.data.type === "TP_DISCONNECTED") {
            /**
             * @type {DisconnectedArgs}
             */
            const disconnectedArgs = event.data.data;
            if(disconnectedArgs.matchId=="DBLayer.latestKnownMatchId"){
                console.warn("a disconnected event was received with matchId equal to the latest known match id")
                disconnectedArgs.matchId = DBLayer.latestKnownMatchId??"-"
            }

            pollUpdateScrapedConvoForLatestMatch()


            var matchEntry
            if (disconnectedArgs.from === null) {
                console.log("handeling diconnection from pushed")
                var latestMatchEntry = DBLayer.latestKnownMatchId === null ? null : DBLayer.getMatchFromMatchBuffer(DBLayer.latestKnownMatchId)
                if (!latestMatchEntry) {
                    throw new Error("a pushed disconnected without existing current match entry")
                }
                if (latestMatchEntry.match.partnerInfo.id !== disconnectedArgs.to) {
                    throw new Error("a pushed disconnected without existing current match entry matching the destinated partner")
                }
                matchEntry = latestMatchEntry;
            }
            else {
                console.log("handeling diconnection from user")
                matchEntry = DBLayer.getMatchFromMatchBuffer(disconnectedArgs.matchId)
                if (matchEntry === undefined) { throw new Error("cannot find match with id form disconnected args") }
            }

            if (matchEntry.disconnectArgs) {
                if (matchEntry.disconnectArgs.to !== "")
                    throw new Error("the match entry is already disconnected properly")
                else {
                    throw new Error("the match entry is already disconnected implecitly")

                }
            }
            matchEntry.disconnectArgs = disconnectedArgs;
            console.log("marked match entry as disconnected")
            TPUIComponent.mainPanel.pushToast(`disconnected from ${disconnectedArgs.matchId}`);
            /**
             * @type {"Stranger"|"You"}
             */
            var disconnectedBy;
            if (matchEntry.match.partnerInfo.id == disconnectedArgs.from) {
                disconnectedBy = "Stranger"
            }
            else if (matchEntry.match.partnerInfo.id === disconnectedArgs.to) {
                disconnectedBy = "You"

            }
            else {
                throw new Error("disconnected args from and to do not match partner id")
            }

            console.log(`the disconnected match entry had scraped convo of ${matchEntry.scrapedConvo?.length}`)
            /**
             * @type {Interaction}
             */
            var interaction = { conversationContent: matchEntry.scrapedConvo || [], disconnectedBy: disconnectedBy, match: matchEntry.match, disconnectedAt: disconnectedArgs.disconnectedAt }
            DBLayer.DB.addInteraction(interaction)
            TPUIComponent.mainPanel.notifyInteractionAdded(interaction)
            TPUIComponent.mainPanel._updatePageConnectionStatus(interaction.match.matchId, matchEntry.disconnectArgs)
            // @ts-ignore
            document.title = window.originalTitle;

        }
    });

}

(function () {
    console.log("tp script started")
    // @ts-ignore
    window.originalTitle = document.title;
    var initialized = false
    var injectedCode = hookEvents.toString();
    injectedCode = "(" + injectedCode + ")();";
    var script = document.createElement('script');
    script.textContent = injectedCode;
    
    console.log("adding event listener for TP_INIT")
    window.addEventListener("message", (event) => {
        console.log("received message event from ext", event)
        if (initialized) return;
        

        if (event.data.type === "TP_INIT") {
            console.log("received TP_INIT event, initializing")
            initialized = true


            initTPUI();
            var pollingScrap = setInterval(() => {
                pollUpdateScrapedConvoForLatestMatch()
            }, 10000);
        }
    })
    console.log("injecting script")
    document.head.appendChild(script);

})();

class AnalyticsHelper {
    
}

function pollUpdateScrapedConvoForLatestMatch() {
    try {
        if (DBLayer.latestKnownMatchId) {
            DBLayer.DB.updateScrapedConvoForMatchId(DBLayer.latestKnownMatchId, scrapeConversationContent())
        }
    }
    catch (e) {
        console.error(e);
    }

}
/**
 *
 * @param {Date | string} startDate
 * @param {Date | string} endDate
 * @returns
 */
function getDurationString(startDate, endDate) {
    let diffMs = Math.abs(new Date(endDate).getTime() - new Date(startDate).getTime());

    let seconds = Math.floor(diffMs / 1000) % 60;
    let minutes = Math.floor(diffMs / (1000 * 60)) % 60;
    let hours = Math.floor(diffMs / (1000 * 60 * 60));

    return `${hours}h ${minutes}m ${seconds}s`;
}

/**
 * Returns a colored label with interaction type and who skipped.
 * @param {Interaction} interaction 
 */
function getInteractionType(interaction) {
    const whoSkipped = interaction.disconnectedBy === "Stranger" ? "they skipped" : "you skipped";
    const msgCount = interaction.conversationContent.length;

    if (msgCount === 0) {
        return `<span style="color:crimson;">empty — ${whoSkipped}</span>`;
    }

    if (msgCount < 10) {
        const color = interaction.disconnectedBy === "Stranger" ? "orangered" : "tomato";
        return `<span style="color:${color};">short — ${whoSkipped}</span>`;
    }

    const color = interaction.disconnectedBy === "Stranger" ? "mediumseagreen" : "limegreen";
    return `<span style="color:${color};">meaningful — ${whoSkipped}</span>`;
}