Monorepo for Aesthetic.Computer aesthetic.computer

Fix chat modal close and harden lith deploy flow

+278 -137
+10 -1
lith/README.md
··· 17 17 - `CONTEXT=production` 18 18 - `DEPLOY_SECRET=...` 19 19 20 + Optional deploy keys: 21 + - `DEPLOY_BRANCH=master` or `DEPLOY_BRANCH=main` 22 + - `DEPLOY_BRANCHES=master,main` to allow multiple webhook refs 23 + 20 24 Recommended workflow: 21 25 1. Copy `.env.example` to `.env` 22 26 2. Fill in the real production values 23 27 3. Re-run `fish vault-tool.fish status` to confirm `lith/.env` is tracked 24 - 4. Deploy with `fish /workspaces/aesthetic-computer/lith/deploy.fish` 28 + 4. Push the deploy branch to GitHub. The webhook deploys pushed commits only. 29 + 5. Or deploy manually with `fish /workspaces/aesthetic-computer/lith/deploy.fish` 30 + 31 + Notes: 32 + - `lith/deploy.fish` no longer rsyncs local working-tree files into production. 33 + - Manual deploys now reset the host to the pushed git branch state, then refresh `.commit-ref`.
+41 -27
lith/deploy.fish
··· 18 18 set DEFAULT_LITH_DROPLET_NAME "ac-lith" 19 19 set TARGET_HOST $DEFAULT_LITH_HOST 20 20 set TARGET_DROPLET_NAME $DEFAULT_LITH_DROPLET_NAME 21 + set LOCAL_BRANCH (git -C $REPO_ROOT branch --show-current 2>/dev/null) 22 + set TARGET_BRANCH $LOCAL_BRANCH 21 23 22 24 if set -q LITH_HOST 23 25 set TARGET_HOST $LITH_HOST ··· 25 27 26 28 if set -q LITH_DROPLET_NAME 27 29 set TARGET_DROPLET_NAME $LITH_DROPLET_NAME 30 + end 31 + 32 + if set -q DEPLOY_BRANCH 33 + set TARGET_BRANCH $DEPLOY_BRANCH 34 + end 35 + 36 + if test -z "$TARGET_BRANCH" 37 + set TARGET_BRANCH main 28 38 end 29 39 30 40 function ssh_ok --argument host ··· 124 134 125 135 echo -e "$GREEN-> Connected to $TARGET_HOST.$NC" 126 136 127 - # Sync repo (git pull on remote) 128 - echo -e "$GREEN-> Pulling latest code...$NC" 129 - ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR && git pull origin main" 137 + # Deploy from pushed git state only. This avoids production drift from local rsync overlays. 138 + echo -e "$GREEN-> Verifying origin/$TARGET_BRANCH...$NC" 139 + git -C $REPO_ROOT fetch origin $TARGET_BRANCH --quiet 140 + set ORIGIN_HEAD (git -C $REPO_ROOT rev-parse origin/$TARGET_BRANCH) 130 141 131 - # Overlay local working tree changes so deploys include uncommitted routing/frontend edits. 132 - echo -e "$GREEN-> Syncing local lith/ and system/ working tree...$NC" 133 - rsync -az --delete \ 134 - --exclude node_modules \ 135 - --exclude .env \ 136 - --exclude .DS_Store \ 137 - "$REPO_ROOT/lith/" \ 138 - $LITH_USER@$TARGET_HOST:$REMOTE_DIR/lith/ 139 - rsync -az --delete \ 140 - --exclude node_modules \ 141 - --exclude .env \ 142 - --exclude .DS_Store \ 143 - --exclude .netlify \ 144 - --exclude .commit-ref \ 145 - "$REPO_ROOT/system/" \ 146 - $LITH_USER@$TARGET_HOST:$REMOTE_DIR/system/ 142 + if test "$LOCAL_BRANCH" = "$TARGET_BRANCH" 143 + set LOCAL_HEAD (git -C $REPO_ROOT rev-parse HEAD) 144 + if test "$LOCAL_HEAD" != "$ORIGIN_HEAD" 145 + echo -e "$RED x Local $TARGET_BRANCH is ahead of origin/$TARGET_BRANCH.$NC" 146 + echo -e "$YELLOW Push first. This deploy script no longer rsyncs uncommitted or unpushed code into production.$NC" 147 + exit 1 148 + end 149 + end 147 150 148 - # Write .commit-ref AFTER rsync so it reflects the actual deployed state 149 - echo -e "$GREEN-> Writing commit ref...$NC" 150 - ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR && git rev-parse HEAD > system/public/.commit-ref" 151 + echo -e "$GREEN-> Deploying branch $TARGET_BRANCH at $ORIGIN_HEAD...$NC" 152 + ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "\ 153 + cd $REMOTE_DIR && \ 154 + git fetch origin $TARGET_BRANCH --quiet && \ 155 + if git show-ref --verify --quiet refs/heads/$TARGET_BRANCH; then \ 156 + git checkout $TARGET_BRANCH --quiet; \ 157 + else \ 158 + git checkout -B $TARGET_BRANCH origin/$TARGET_BRANCH --quiet; \ 159 + fi && \ 160 + git reset --hard origin/$TARGET_BRANCH --quiet && \ 161 + git rev-parse HEAD > system/public/.commit-ref" 151 162 152 163 # Upload env 153 164 echo -e "$GREEN-> Uploading environment...$NC" ··· 158 169 159 170 # Install deps 160 171 echo -e "$GREEN-> Installing dependencies...$NC" 161 - ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR/lith && npm install && cd $REMOTE_DIR/system && npm install" 172 + ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "cd $REMOTE_DIR/lith && npm install --omit=dev && cd $REMOTE_DIR/system && npm install --omit=dev" 162 173 163 - # Upload Caddyfile 164 - echo -e "$GREEN-> Updating Caddy config...$NC" 165 - scp -i $SSH_KEY $SCRIPT_DIR/Caddyfile $LITH_USER@$TARGET_HOST:/etc/caddy/Caddyfile 166 - ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "systemctl reload caddy" 174 + # Install service file + Caddy config from the deployed checkout 175 + echo -e "$GREEN-> Updating service + Caddy config...$NC" 176 + ssh -i $SSH_KEY $LITH_USER@$TARGET_HOST "\ 177 + cp $REMOTE_DIR/lith/lith.service /etc/systemd/system/lith.service && \ 178 + cp $REMOTE_DIR/lith/Caddyfile /etc/caddy/Caddyfile && \ 179 + systemctl daemon-reload && \ 180 + systemctl reload caddy" 167 181 168 182 # Restart lith service 169 183 echo -e "$GREEN-> Restarting lith...$NC"
+94 -21
lith/server.mjs
··· 107 107 } 108 108 } 109 109 110 + function captureRawBody(req, _res, buf) { 111 + if (buf?.length) req.rawBody = Buffer.from(buf); 112 + } 113 + 110 114 // --- Body parsing --- 111 - app.use(express.json({ limit: "50mb" })); 112 - app.use(express.urlencoded({ extended: true, limit: "50mb" })); 113 - app.use(express.raw({ type: "*/*", limit: "50mb" })); 115 + app.use(express.json({ limit: "50mb", verify: captureRawBody })); 116 + app.use(express.urlencoded({ extended: true, limit: "50mb", verify: captureRawBody })); 117 + app.use(express.raw({ type: "*/*", limit: "50mb", verify: captureRawBody })); 114 118 115 119 // --- CORS (mirrors Netlify _headers) --- 116 120 app.use((req, res, next) => { ··· 206 210 httpMethod: req.method, 207 211 headers: req.headers, 208 212 body, 209 - rawBody: req.body, 213 + rawBody: req.rawBody ?? req.body, 210 214 queryStringParameters: req.query || {}, 211 215 path: req.path, 212 216 rawUrl: `${req.protocol}://${req.get("host")}${req.originalUrl}`, ··· 309 313 import { execFile } from "child_process"; 310 314 import { createHmac, timingSafeEqual } from "crypto"; 311 315 const DEPLOY_SECRET = process.env.DEPLOY_SECRET || ""; 316 + const DEPLOY_BRANCHES = (process.env.DEPLOY_BRANCHES || process.env.DEPLOY_BRANCH || "main,master") 317 + .split(",") 318 + .map((branch) => branch.trim()) 319 + .filter(Boolean); 320 + const DEFAULT_DEPLOY_BRANCH = DEPLOY_BRANCHES[0] || "main"; 312 321 let deployInProgress = false; 322 + let queuedDeployBranch = null; 323 + 324 + function normalizeDeployBranch(branch) { 325 + if (typeof branch !== "string") return null; 326 + const trimmed = branch.trim(); 327 + if (!trimmed) return null; 328 + if (!/^[A-Za-z0-9._/-]+$/.test(trimmed)) return null; 329 + return trimmed; 330 + } 331 + 332 + function branchFromRef(ref) { 333 + if (typeof ref !== "string") return null; 334 + const prefix = "refs/heads/"; 335 + if (!ref.startsWith(prefix)) return null; 336 + return normalizeDeployBranch(ref.slice(prefix.length)); 337 + } 338 + 339 + function requestedDeployBranch(req) { 340 + const fromRef = branchFromRef(req.body?.ref); 341 + if (fromRef) return fromRef; 342 + return ( 343 + normalizeDeployBranch(req.query.branch) || 344 + normalizeDeployBranch(req.headers["x-deploy-branch"]) || 345 + DEFAULT_DEPLOY_BRANCH 346 + ); 347 + } 313 348 314 349 function verifyDeploy(req) { 315 350 // GitHub HMAC signature (webhook secret) 316 351 const sig = req.headers["x-hub-signature-256"]; 317 352 if (sig && DEPLOY_SECRET) { 353 + const rawBody = Buffer.isBuffer(req.rawBody) 354 + ? req.rawBody 355 + : Buffer.from( 356 + typeof req.body === "string" ? req.body : JSON.stringify(req.body ?? {}), 357 + "utf8", 358 + ); 318 359 const hmac = createHmac("sha256", DEPLOY_SECRET) 319 - .update(JSON.stringify(req.body)) 360 + .update(rawBody) 320 361 .digest("hex"); 321 362 const expected = `sha256=${hmac}`; 322 363 if (sig.length === expected.length && ··· 329 370 return plain === DEPLOY_SECRET; 330 371 } 331 372 373 + function runDeploy(branch) { 374 + deployInProgress = true; 375 + console.log(`[deploy] starting branch=${branch}`); 376 + 377 + execFile( 378 + "/opt/ac/lith/webhook.sh", 379 + { 380 + timeout: 120000, 381 + env: { ...process.env, DEPLOY_BRANCH: branch }, 382 + }, 383 + (err, stdout, stderr) => { 384 + deployInProgress = false; 385 + 386 + if (stdout?.trim()) { 387 + console.log(`[deploy][${branch}] ${stdout.trim()}`); 388 + } 389 + if (stderr?.trim()) { 390 + console.error(`[deploy][${branch}] ${stderr.trim()}`); 391 + } 392 + if (err) { 393 + console.error(`[deploy] failed for ${branch}:`, err.message); 394 + } 395 + 396 + if (queuedDeployBranch) { 397 + const nextBranch = queuedDeployBranch; 398 + queuedDeployBranch = null; 399 + setImmediate(() => runDeploy(nextBranch)); 400 + } 401 + }, 402 + ); 403 + } 404 + 332 405 app.post("/lith/deploy", (req, res) => { 333 406 if (!DEPLOY_SECRET || !verifyDeploy(req)) { 334 407 return res.status(401).send("Unauthorized"); 335 408 } 336 409 337 - // Only deploy main branch pushes (GitHub sends ref in payload) 410 + const githubEvent = req.headers["x-github-event"]; 411 + if (githubEvent === "ping") { 412 + return res.send("pong"); 413 + } 414 + if (githubEvent && githubEvent !== "push") { 415 + return res.send(`Ignored GitHub event: ${githubEvent}`); 416 + } 417 + 338 418 const ref = req.body?.ref; 339 - if (ref && ref !== "refs/heads/main") { 340 - return res.send(`Ignored non-main push: ${ref}`); 419 + const branch = requestedDeployBranch(req); 420 + if (!DEPLOY_BRANCHES.includes(branch)) { 421 + const detail = ref || branch; 422 + return res.send(`Ignored non-deploy branch: ${detail}`); 341 423 } 342 424 343 425 if (deployInProgress) { 344 - return res.status(429).send("Deploy already in progress"); 426 + queuedDeployBranch = branch; 427 + return res.status(202).send(`Deploy queued for ${branch}`); 345 428 } 346 429 347 - deployInProgress = true; 348 - res.send("Deploy started"); 349 - 350 - // Run async — don't block the event loop or the HTTP response 351 - execFile("/opt/ac/lith/webhook.sh", { timeout: 120000 }, (err, stdout, stderr) => { 352 - deployInProgress = false; 353 - if (err) { 354 - console.error("[deploy] failed:", err.message, stderr); 355 - } else { 356 - console.log("[deploy]", stdout); 357 - } 358 - }); 430 + runDeploy(branch); 431 + res.status(202).send(`Deploy started for ${branch}`); 359 432 }); 360 433 361 434 // --- Routes ---
+20 -5
lith/webhook.sh
··· 15 15 16 16 REMOTE_DIR="/opt/ac" 17 17 LOG_TAG="[lith-deploy]" 18 + DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}" 18 19 19 20 log() { echo "$LOG_TAG $*"; } 20 21 22 + if ! [[ "$DEPLOY_BRANCH" =~ ^[A-Za-z0-9._/-]+$ ]]; then 23 + log "invalid DEPLOY_BRANCH: $DEPLOY_BRANCH" 24 + exit 2 25 + fi 26 + 21 27 cd "$REMOTE_DIR" 22 28 23 29 # Record HEAD before pull 24 30 OLD_HEAD=$(git rev-parse HEAD) 31 + OLD_BRANCH=$(git branch --show-current) 25 32 26 33 # Pull latest 27 - log "pulling..." 28 - git fetch origin main --quiet 29 - git reset --hard origin/main --quiet 34 + log "pulling branch $DEPLOY_BRANCH..." 35 + git fetch origin "$DEPLOY_BRANCH" --quiet 36 + 37 + if git show-ref --verify --quiet "refs/heads/$DEPLOY_BRANCH"; then 38 + git checkout "$DEPLOY_BRANCH" --quiet 39 + else 40 + git checkout -B "$DEPLOY_BRANCH" "origin/$DEPLOY_BRANCH" --quiet 41 + fi 42 + 43 + git reset --hard "origin/$DEPLOY_BRANCH" --quiet 30 44 31 45 NEW_HEAD=$(git rev-parse HEAD) 46 + NEW_BRANCH=$(git branch --show-current) 32 47 33 48 if [ "$OLD_HEAD" = "$NEW_HEAD" ]; then 34 - log "already up to date ($NEW_HEAD)" 49 + log "already up to date on $NEW_BRANCH ($NEW_HEAD)" 35 50 exit 0 36 51 fi 37 52 ··· 40 55 41 56 # Get list of changed files 42 57 CHANGED=$(git diff --name-only "$OLD_HEAD" "$NEW_HEAD") 43 - log "updated $OLD_HEAD -> $NEW_HEAD" 58 + log "updated $OLD_BRANCH/$OLD_HEAD -> $NEW_BRANCH/$NEW_HEAD" 44 59 log "changed files:" 45 60 echo "$CHANGED" | sed 's/^/ /' 46 61
+113 -83
system/public/aesthetic.computer/disks/chat.mjs
··· 209 209 let draftMessage = ""; // Store draft message text persistently 210 210 211 211 // 📺 YouTube preview system 212 - let youtubePreviewCache = new Map(); // Store loaded YouTube thumbnails 213 - let youtubeLoadQueue = new Set(); // Track which videos are being loaded 214 - let youtubeModalOpen = false; // Track if YouTube modal is open 215 - let youtubeModalVideoId = null; // Current video in modal 216 - let domApi = null; // Store dom API reference for modal 217 - let netPreload = null; // Store net.preload reference for YouTube loading 218 - let globalYoutubeThumbCache = null; 212 + let youtubePreviewCache = new Map(); // Store loaded YouTube thumbnails 213 + let youtubeLoadQueue = new Set(); // Track which videos are being loaded 214 + let youtubeModalOpen = false; // Track if YouTube modal is open 215 + let youtubeModalVideoId = null; // Current video in modal 216 + let youtubeModalCleanup = null; // Remove modal listeners when the overlay closes 217 + let domApi = null; // Store dom API reference for modal 218 + let netPreload = null; // Store net.preload reference for YouTube loading 219 + let globalYoutubeThumbCache = null; 219 220 220 221 if (typeof globalThis !== "undefined") { 221 222 if (!globalThis.__acYoutubeThumbCache) { ··· 3931 3932 } 3932 3933 } 3933 3934 3934 - // 📺 Open YouTube modal with embed iframe 3935 - // On iOS, we skip the embed modal and open YouTube directly 3936 - // because the iframe embed has audio issues and tap-outside-to-close doesn't work reliably 3937 - // (All iOS browsers use WebKit and have the same iframe restrictions) 3938 - function openYoutubeModal(videoId) { 3935 + // 📺 Open YouTube modal with embed iframe 3936 + // On iOS, we skip the embed modal and open YouTube directly 3937 + // because the iframe embed has audio issues and tap-outside-to-close doesn't work reliably 3938 + // (All iOS browsers use WebKit and have the same iframe restrictions) 3939 + function openYoutubeModal(videoId) { 3939 3940 if (!domApi) return; 3940 3941 3941 3942 // On iOS, open YouTube directly instead of embed (audio doesn't work, touch handling issues) ··· 3944 3945 return; 3945 3946 } 3946 3947 3947 - if (youtubeModalOpen) { 3948 - const overlay = typeof document !== "undefined" 3949 - ? document.getElementById("youtube-modal-overlay") 3950 - : null; 3951 - if (overlay) return; 3952 - youtubeModalOpen = false; 3953 - youtubeModalVideoId = null; 3954 - } 3955 - 3956 - youtubeModalOpen = true; 3957 - youtubeModalVideoId = videoId; 3958 - if (typeof globalThis !== "undefined") { 3959 - globalThis.acYoutubeModalState = { open: true, videoId }; 3960 - globalThis.acYoutubeModalClose = () => { 3961 - youtubeModalOpen = false; 3962 - youtubeModalVideoId = null; 3963 - if (globalThis.acYoutubeModalState) { 3964 - globalThis.acYoutubeModalState.open = false; 3965 - globalThis.acYoutubeModalState.videoId = null; 3966 - } 3967 - }; 3968 - } 3969 - 3970 - domApi.html` 3971 - <style> 3972 - #youtube-modal-overlay { 3973 - position: fixed; 3974 - top: 0; 3948 + if (youtubeModalOpen) { 3949 + const overlay = typeof document !== "undefined" 3950 + ? document.getElementById("youtube-modal-overlay") 3951 + : null; 3952 + if (overlay) return; 3953 + closeYoutubeModal(); 3954 + } 3955 + 3956 + youtubeModalOpen = true; 3957 + youtubeModalVideoId = videoId; 3958 + if (typeof globalThis !== "undefined") { 3959 + globalThis.acYoutubeModalState = { open: true, videoId }; 3960 + globalThis.acYoutubeModalClose = closeYoutubeModal; 3961 + globalThis.acCloseYoutubeModal = closeYoutubeModal; 3962 + } 3963 + 3964 + const styleTag = typeof document !== "undefined" && document.getElementById("youtube-modal-style") 3965 + ? "" 3966 + : ` 3967 + <style id="youtube-modal-style"> 3968 + #youtube-modal-overlay { 3969 + position: fixed; 3970 + top: 0; 3975 3971 left: 0; 3976 3972 right: 0; 3977 3973 bottom: 0; ··· 4031 4027 #youtube-modal-close:active { 4032 4028 transform: scale(0.95); 4033 4029 } 4034 - #youtube-modal-tap-hint { 4035 - color: rgba(255, 255, 255, 0.6); 4036 - font-size: 14px; 4037 - margin-top: 16px; 4038 - font-family: system-ui, sans-serif; 4039 - } 4040 - </style> 4041 - <div id="youtube-modal-overlay" onclick="if(event.target.id === 'youtube-modal-overlay' || event.target.id === 'youtube-modal-tap-hint') { window.acCloseYoutubeModal && window.acCloseYoutubeModal(); }"> 4042 - <div id="youtube-modal-content"> 4043 - <button id="youtube-modal-close" onclick="window.acCloseYoutubeModal && window.acCloseYoutubeModal();">&times;</button> 4044 - <iframe 4045 - src="https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0" 4046 - allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" 4030 + #youtube-modal-tap-hint { 4031 + color: rgba(255, 255, 255, 0.6); 4032 + font-size: 14px; 4033 + margin-top: 16px; 4034 + font-family: system-ui, sans-serif; 4035 + } 4036 + </style>`; 4037 + 4038 + domApi.html` 4039 + ${styleTag} 4040 + <div id="youtube-modal-overlay"> 4041 + <div id="youtube-modal-content"> 4042 + <button id="youtube-modal-close" type="button" aria-label="Close YouTube video">&times;</button> 4043 + <iframe 4044 + src="https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0" 4045 + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" 4047 4046 allowfullscreen> 4048 4047 </iframe> 4049 - </div> 4050 - <div id="youtube-modal-tap-hint">tap outside to close</div> 4051 - </div> 4052 - <script> 4053 - window.acCloseYoutubeModal = function() { 4054 - const overlay = document.getElementById('youtube-modal-overlay'); 4055 - if (overlay) overlay.remove(); 4056 - if (window.acYoutubeModalClose) window.acYoutubeModalClose(); 4057 - if (window.acYoutubeModalState) { 4058 - window.acYoutubeModalState.open = false; 4059 - window.acYoutubeModalState.videoId = null; 4060 - } 4061 - }; 4062 - document.addEventListener('keydown', function ytEscHandler(e) { 4063 - if (e.key === 'Escape') { 4064 - window.acCloseYoutubeModal(); 4065 - document.removeEventListener('keydown', ytEscHandler); 4066 - } 4067 - }); 4068 - </script> 4069 - `; 4070 - } 4071 - 4072 - // 📺 Close YouTube modal 4073 - function closeYoutubeModal() { 4074 - youtubeModalOpen = false; 4075 - youtubeModalVideoId = null; 4076 - // The DOM cleanup happens via the onclick/escape handlers in the HTML 4077 - } 4048 + </div> 4049 + <div id="youtube-modal-tap-hint">tap outside to close</div> 4050 + </div> 4051 + `; 4052 + 4053 + const overlay = typeof document !== "undefined" 4054 + ? document.getElementById("youtube-modal-overlay") 4055 + : null; 4056 + const closeButton = typeof document !== "undefined" 4057 + ? document.getElementById("youtube-modal-close") 4058 + : null; 4059 + const tapHint = typeof document !== "undefined" 4060 + ? document.getElementById("youtube-modal-tap-hint") 4061 + : null; 4062 + 4063 + if (!overlay || !closeButton) { 4064 + closeYoutubeModal(); 4065 + return; 4066 + } 4067 + 4068 + youtubeModalCleanup?.(); 4069 + 4070 + const closeFromPointer = (event) => { 4071 + event.preventDefault(); 4072 + event.stopPropagation(); 4073 + closeYoutubeModal(); 4074 + }; 4075 + 4076 + const closeFromOverlay = (event) => { 4077 + if (event.target !== overlay && event.target !== tapHint) return; 4078 + closeFromPointer(event); 4079 + }; 4080 + 4081 + overlay.addEventListener("pointerup", closeFromOverlay); 4082 + closeButton.addEventListener("pointerup", closeFromPointer); 4083 + 4084 + youtubeModalCleanup = () => { 4085 + overlay.removeEventListener("pointerup", closeFromOverlay); 4086 + closeButton.removeEventListener("pointerup", closeFromPointer); 4087 + youtubeModalCleanup = null; 4088 + }; 4089 + } 4090 + 4091 + // 📺 Close YouTube modal 4092 + function closeYoutubeModal() { 4093 + youtubeModalCleanup?.(); 4094 + youtubeModalOpen = false; 4095 + youtubeModalVideoId = null; 4096 + if (typeof document !== "undefined") { 4097 + document.getElementById("youtube-modal-overlay")?.remove(); 4098 + } 4099 + if (typeof globalThis !== "undefined") { 4100 + globalThis.acYoutubeModalClose = closeYoutubeModal; 4101 + globalThis.acCloseYoutubeModal = closeYoutubeModal; 4102 + if (globalThis.acYoutubeModalState) { 4103 + globalThis.acYoutubeModalState.open = false; 4104 + globalThis.acYoutubeModalState.videoId = null; 4105 + } 4106 + } 4107 + } 4078 4108 4079 4109 function computeMessagesHeight({ text, screen, typeface }, chat, defaultTypefaceName, defaultRowHeight) { 4080 4110 let height = 0; ··· 4838 4868 4839 4869 // Volume slider removed for slim design - could add keyboard shortcuts later 4840 4870 r8dioVolSliderBounds = null; 4841 - } 4871 + }