1window.userPhotos = [] 2 3window.htmlViewerCache = {}; 4 5// Intercept /s/ links clicked within an iframe: 6// close the modal, send the link to open.php, and perform the resolved action. 7// Note: this duplicates the open.php call pattern from tellServerAClickResultedInAnOpen⦠8// and the push notification handler in shared.js, but can't easily consolidate because 9// those callers differ in context (page-load click attribution vs push vs iframe click) 10// and each needs its own pre/post handling (e.g. hiding the viewer modal here). 11function attachShortLinkInterceptor(iframe){ 12 iframe.contentDocument.addEventListener('click', function(e){ 13 var link = e.target.closest('a'); 14 if(!link) return; 15 var href = link.getAttribute('href') || ''; 16 // Links tagged by server-side rendering with openInExternalBrowser=true 17 // (e.g. assignment links in the week ahead) should open in a new tab 18 // rather than navigating the iframe. 19 if(href.indexOf('openInExternalBrowser=true') !== -1){ 20 if(!window.isPWA){ 21 e.preventDefault(); 22 window.open(href, '_blank'); 23 return; 24 }else{ 25 if(typeof openURLOutsideWrapper == 'function'){ 26 openURLOutsideWrapper(link.href); 27 return; 28 } 29 } 30 } 31 if(href.match(/\.coursicle\.com\/s\//)){ 32 e.preventDefault(); 33 $.ajax({ 34 url: "/shared/link/open.php", 35 type: "POST", 36 dataType: 'json', 37 data: { uuid: store.get('uuid'), deepLinkURL: href, fromPush: 1 }, 38 success: function(response){ 39 // Actions that reuse the same serverDefinedHTMLViewerModal â don't hide it, 40 // the new content just swaps into the existing iframe. 41 var sameModalActions = ['openWeekAhead', 'openWidgets']; 42 var nextAction = null; 43 if(response && response.appState){ 44 if(response.appState.type == "dynamic" && response.appState.action){ 45 nextAction = response.appState.action; 46 // perform the action first so the new modal opens before the old one closes, 47 // preventing the overlay/shadow from fading out and back in 48 performDeepLinkAction(response.appState.action, response.appState); 49 }else if(response.appState.openURL){ 50 nextAction = 'openURL'; 51 performDeepLinkAction("openURL", response.appState.openURL); 52 } 53 } 54 if(sameModalActions.indexOf(nextAction) === -1){ 55 hideBaseModal('#serverDefinedHTMLViewerModal'); 56 } 57 } 58 }); 59 } 60 }); 61} 62 63function openContentInModal(url) { 64 var iframe = document.getElementById('serverDefinedHTMLViewerIframe'); 65 // Week Ahead gets a slightly taller modal than the widget guide; same element 66 // serves both, so the variant rides on a class. Set before the cache branch below, 67 // which returns early. 68 document.getElementById('serverDefinedHTMLViewerModal').classList.toggle('weekAheadContent', url.indexOf('myWeekAhead.php') !== -1); 69 var spinner = document.getElementById('serverDefinedHTMLViewerSpinner'); 70 var isDark = document.documentElement.classList.contains('darkModeActive'); 71 var isThemed = document.documentElement.classList.contains('themeSampleActive'); 72 var bg = (isDark || isThemed) ? (getComputedStyle(document.documentElement).getPropertyValue("--background-color-dark").trim() || themeColor('--el-color')) : themeColor('--white'); 73 iframe.style.background = bg; 74 75 var cached = window.htmlViewerCache[url]; 76 if (cached && (Date.now() - cached.time < 3600000)) { 77 iframe.onload = null; 78 iframe.classList.add('loaded'); 79 spinner.classList.add('hidden'); 80 iframe.srcdoc = cached.html; 81 showBaseModal('#serverDefinedHTMLViewerModal'); 82 // attach /s/ link interceptor once srcdoc content loads 83 iframe.addEventListener('load', function onCachedLoad(){ 84 iframe.removeEventListener('load', onCachedLoad); 85 try { attachShortLinkInterceptor(iframe); } catch(e) {} 86 }); 87 return; 88 } 89 90 iframe.classList.remove('loaded'); 91 spinner.classList.remove('hidden'); 92 iframe.src = url; 93 showBaseModal('#serverDefinedHTMLViewerModal'); 94 95 var openedAt = Date.now(); 96 iframe.onload = function(){ 97 var remaining = Math.max(0, 200 - (Date.now() - openedAt)); 98 setTimeout(function(){ 99 iframe.classList.add('loaded'); 100 spinner.classList.add('hidden'); 101 }, remaining); 102 try { 103 var html = iframe.contentDocument.documentElement.outerHTML; 104 if (html && iframe.contentDocument.body.innerHTML.length > 0) { 105 window.htmlViewerCache[url] = { html: '<!DOCTYPE html>' + html, time: Date.now() }; 106 } 107 attachShortLinkInterceptor(iframe); 108 } catch(e) {} 109 iframe.dataset.cachedUrl = url; 110 }; 111} 112 113document.addEventListener('DOMContentLoaded', function(){ 114 $(document).on('serverDefinedHTMLViewerModalBaseModalHidden', function(){ 115 var iframe = document.getElementById('serverDefinedHTMLViewerIframe'); 116 iframe.onload = null; 117 iframe.src = ''; 118 iframe.removeAttribute('srcdoc'); 119 iframe.classList.remove('loaded'); 120 document.getElementById('serverDefinedHTMLViewerSpinner').classList.remove('hidden'); 121 }); 122}); 123 124// Usually we don't want to refreshUsernameBig, as it resets the cursor for the user if they're actively editing it, 125// hence the optional parameter 126 127function refreshMenuUI() { 128 129 // 130 // Small container on the main screen 131 // 132
133 $("#chatUserProfileModalContent").data("coursicleID", (store.get('userData') ? store.get('userData').coursicleID : 0)) 134 $("#chatProfileSettings").show() 135 136 var defaultUsername = "Me"; 137 138 // Disable the menu when incognito 139 if (store.get("incognito")) return; 140 141 var userData = store.get('userData'); 142 143 if (!userData || !store.get('uuid')) { 144 $("#username").html(defaultUsername); 145 return; 146 } 147 148 // refresh photo 149 var path = `/shared/getImage.php?type=userPhoto&file=${userData.userPhotoUrl}&size=small`; 150 $("#userPicture").attr("src", path); 151 $("#chatProfilePicture").attr("src", path); 152 153 // display name preferred, then chat username, then default 154 var nameToDisplay = ""; 155 if(userData.username && userData.username != "Me"){ 156 nameToDisplay = userData.username; 157 }else{ 158 nameToDisplay = "Me"; 159 } 160 161 $("#username").html(escapeHTML(nameToDisplay)); 162 163 // 164 // Hidden modal with options 165 // 166 167 $("#chatProfileName").text(nameToDisplay) 168 $("#chatProfileYear").text(store.get('userData').year || "First-year"); 169 $("#chatProfileMajor").text(store.get('userData').major || "Undecided"); 170 171 $("#nameSetting").find(".settingsValue").text(nameToDisplay); 172 $("#schoolSetting").find(".settingsValue").text(store.get("primarySchoolDisplayName") || "Unknown"); 173 $("#yearSetting").find(".settingsValue").text(store.get('userData').year || ""); 174 $("#majorSetting").find(".settingsValue").text(store.get('userData').major || ""); 175 176 // The account email. getUserData UNSETS this key when there is no account rather than 177 // sending null (shared.php ~1072, because iOS crashes on a null in userData), so absence 178 // is already the signal for "no account email" â no extra flag needed for it. 179 var accountEmail = store.get('userData').accountEmail; 180 if(accountEmail){ 181 $("#emailSetting").find(".settingsValue").text(accountEmail); 182 $("#emailSetting").show(); 183 }else{ 184 $("#emailSetting").hide(); 185 } 186 187 // TODO [push] (miguel 2026-09-19): delete this block once the Android wrapper carrying the 188 // accountDeleted handler is live, so the row comes back for app users. 189 // 190 // Web is shipping a day or two ahead of the wrapper. The app is a TWA around this page, so the 191 // row appears there the moment web deploys -- but the wrapper keeps its own uuid in 192 // SharedPreferences and replays it as a launch param on every cold start. Without the handler 193 // that clears it, a deletion is undone on the next launch: the fresh account the user just made 194 // is dropped and they land back on the erased one, under the same uuid they asked us to destroy. 195 // 196 // pwaWrapperData rather than window.isPWA: isPWA comes from $userHasAndroid, a user-agent check, 197 // so it is true in any Android browser. Only the wrapper writes pwaWrapperData. 198 // 199 // Delete Account only. Reset Data does not touch identity, so it works on any wrapper and stays. 200 if(store.get("pwaWrapperData")){ 201 $("#deleteAccountSetting").hide(); 202 } 203 204 if(isUserLoggedIn()){ 205 $('#loginSetting .settingsLabel').text('Logout') 206 $('#loginSetting .settingEmojiDefault').text('ð') 207 $('#loginSetting .settingEmojiSvg').replaceWith($('#settingLogoutIcon')[0].outerHTML) 208 //logout hide on pwa 4 now. 209 if(window.isPWA){ 210 $('#loginSetting').hide() 211 }else{ 212 $('#loginSetting').show() 213 } 214 215 }else{ 216 $('#loginSetting .settingsLabel').text('Login') 217 218 if(isPWA){ 219 $('#loginSetting .settingEmojiDefault').text('ð±') 220 $('#loginSetting .settingEmojiSvg').replaceWith($('#settingPhoneIcon')[0].outerHTML) 221 }else{ 222 $('#loginSetting .settingEmojiDefault').text('ð§âð»') 223 $('#loginSetting .settingEmojiSvg').replaceWith($('#settingMonitorIcon')[0].outerHTML) 224 } 225 } 226 227 228 // colors the year, shows the selected socials, paints the notification setting, etc. 229 fillInProfileInformation(); 230 231 // paint tasks on calendar toggle 232 $('#paintTasksToggle').prop('checked', userData.paintTasksOnCalendar ?? true) 233 234} 235 236function getListOfUserPhotoChoices() { 237 // stop if we already have them 238 if(window.userPhotos.length != 0){return;} 239 $.ajax({ 240 url: "/shared/loginless/backend/getAccountPhotos.php?format=json", 241 type: "GET", 242 dataType: 'text', 243 success: function(response){ 244 response = JSON.parse(response); 245 if (response.success) { 246 window.userPhotos = JSON.parse(response.filenames); 247 } 248 } 249 }); 250} 251 252function populateUserPhotoChoices() { 253 254 $("#userPhotoOptionsGrid").empty(); 255 256 257 for (var i = 0; i < window.userPhotos.length; i++) { 258
259 // Give the image an ID so we know which spinner to remove when it loads 260 var imgID = `imgID${i}`; 261 var path = `/shared/getImage.php?type=userPhoto&file=${window.userPhotos[i]}&size=small`; 262 263 // Force the loading of the image, since we can't do onload on a background image 264 var imgElement = $(`<img class='coursicleUserPhotoOption' loading="lazy" id=${imgID}>`); 265 imgElement.attr("src", path); 266 267 var imgContainer = $('<div>'); 268 imgContainer.append(imgElement); 269 270 $("#userPhotoOptionsGrid").append(imgContainer); 271 272 273 } 274} 275 276var activeSubmenu = null; 277var arrayOfStackedSubmenus = [] 278 279function waitForChatProfileChangeNameTransition() { 280 // we don't want to autofocus here on pwa because keyboard automatically pops up 281 if (!window.isPWA) { 282 // wait for submenu to slide in, then focus the input field. 283 // remove listener before user gets the chance to slide the submenu back out. 284 $("#chatProfileChangeName").on("transitionend", function () { 285 $('#nameInput').focus(); $("#chatProfileChangeName").off("transitionend") 286 }) 287 } 288} 289function setupMenuUI(siteType) { 290 291 $(document).on('click', '#profileSetting', function(){ 292 if(window.isPWA){ 293 $('#chatProfileProfile').removeClass('menu submenu') 294 renderSlidableModal('#chatProfileProfile'); 295 }else{ 296 submenuSlideIn($("#chatProfileProfile")); 297 } 298 }); 299 300 $("#nameSetting").click(function() { 301 waitForChatProfileChangeNameTransition() 302 submenuSlideIn($("#chatProfileChangeName")); 303 }); 304 305 // if enter is pressed, save the name 306 $("#nameInput").on("keydown", function(e) { 307 if (e.which == 13) { 308 $('#saveNameButton').click() 309 } 310 }) 311 312 $("#chatUserProfileBackButton").click(function() { 313 submenuSlideOut(activeSubmenu); 314 }); 315 316 // when the sync-now button in the user menu is clicked 317 $('#loginSetting').click(function(){ 318 if (isUserLoggedIn()){ 319 //DANGER DANGER DANGER 320 store.set('uuid', null) 321 store.clearAll() 322 getUserData() 323 } 324 else { 325 spawnAccountModal('login'); 326 } 327 }); 328 329 330 331 $(document).on('click', '#weekAheadSetting', function(){ 332 var uuid = store.get("uuid"); 333 var appearance = document.documentElement.classList.contains('darkModeActive') ? 'dark' : 'light'; 334 var client = window.isPWA ? 'android' : 'web'; 335 openContentInModal("/shared/email/myWeekAhead.php?uuid=" + encodeURIComponent(uuid) + "&appearance=" + appearance + "&client=" + client); 336 }); 337 $(document).on('click', '#widgetSetting', function(){ 338 var uuid = store.get("uuid"); 339 var client = window.isPWA ? 'android' : 'web'; 340 var appearance = document.documentElement.classList.contains('darkModeActive') ? 'dark' : 'light'; 341 openContentInModal("/shared/widgets/guide.php?client=" + client + "&uuid=" + encodeURIComponent(uuid) + "&appearance=" + appearance); 342 }); 343 $("#googleCalendarSetting").click(function(){ 344 launchGoogleAuth('googleCalendar'); 345 }) 346 347 $('#appearanceSetting').click(function(){ 348 openThemeSelectorModal() 349 }) 350 351 352 $('#schoolSetting').click(function(){ 353 // slide in the submenu 354 submenuSlideIn($("#chatProfileChangeSchool")); 355 }); 356 357 $(document).on('click', '#moreSettingsSetting', function(){ 358 // prepare for the possibility that they click the linked accounts row. 359 getUserData(store.get('uuid')); 360 if(window.isPWA){ 361 $('#chatProfileMoreSettings').removeClass('menu submenu') 362 renderSlidableModal('#chatProfileMoreSettings'); 363 }else{ 364 submenuSlideIn($("#chatProfileMoreSettings")); 365 } 366 }); 367
368 $(document).on('click', '#linkedAccountsSetting', function(){ 369 var accounts = store.get('userData').linkedAccounts || []; 370 renderLinkedAccounts(accounts); 371 if(window.isPWA){ 372 $('#chatProfileLinkedAccounts').removeClass('menu submenu') 373 renderSlidableModal('#chatProfileLinkedAccounts', true); 374 }else{ 375 submenuSlideIn($("#chatProfileLinkedAccounts")); 376 } 377 }); 378 379 $(document).on('mouseenter', '.linkedAccountRow', function() { 380 if(window.isPWA) return; 381 $(this).addClass('linkedAccountDeleteXShown'); 382 }).on('mouseleave', '.linkedAccountRow', function() { 383 if(window.isPWA) return; 384 if ($('.unlinkConfirmationOptions').length == 0) { 385 $(this).removeClass('linkedAccountDeleteXShown'); 386 } 387 }); 388 389 // PWA: tap the row to open unlink dialogue 390 $(document).on('click', '.linkedAccountRow', function(e) { 391 if(!window.isPWA) return; 392 if($(e.target).hasClass('linkedAccountDeleteX')) return; 393 var accountType = $(this).attr('data-account-type'); 394 window.unlinkAccountTarget = accountType; 395 renderSlidableModal('#unlinkAccountDialogue'); 396 $('#unlinkAccountDialogue').closest('.slidableModal').css('background-color', 'transparent'); 397 }); 398 399 // Web: click the X to show tippy
400 $(document).on('click', '.linkedAccountDeleteX', function(e) { 401 if(window.isPWA) return; 402 if ($('.unlinkConfirmationOptions').length > 0) return; 403 var row = $(this).closest('.linkedAccountRow'); 404 var accountType = row.attr('data-account-type'); 405 showUnlinkConfirmationTippy(row[0], accountType); 406 }); 407 408 // PWA dialogue buttons 409 $(document).on('click', '#unlinkDeleteDataBtn', function() { 410 performUnlink(window.unlinkAccountTarget, true); 411 hideSlidableModal(); 412 }); 413 $(document).on('click', '#unlinkOnlyBtn', function() { 414 performUnlink(window.unlinkAccountTarget, false); 415 hideSlidableModal(); 416 }); 417 // Web tippy buttons 418 $(document).on('click', '.unlinkDeleteData, .unlinkOnly', function() { 419 var accountType = $(this).attr('data-account'); 420 var deleteData = $(this).hasClass('unlinkDeleteData'); 421 tippy.hideAll(); 422 performUnlink(accountType, deleteData); 423 }); 424 425 function performUnlink(accountType, deleteData) { 426 $('.linkedAccountRow[data-account-type="' + accountType + '"]').fadeOut(150, function(){ 427 $(this).remove(); 428 if($('#linkedAccountsList .linkedAccountRow').length === 0){ 429 $('#linkedAccountsList').append('<div class="linkedAccountsEmpty">No linked accounts</div>'); 430 } 431 }); 432 433 $.ajax({ 434 url: "/shared/loginless/backend/unlinkAccount.php", 435 type: "POST", 436 dataType: "json", 437 data: { 438 uuid: store.get('uuid'), 439 source: accountType, 440 deleteData: deleteData.toString() 441 }, 442 success: function() { 443 // server already deleted the events and tasks, just refresh local state 444 getUserData(store.get('uuid')); 445 } 446 }); 447 } 448 449 // The two destructive settings rows. On desktop each one slides its own screen in and asks the 450 // second question on the button pressed there; the PWA keeps its slidable dialogues. Severity 451 // differs: Reset clears content and unlinks but the account survives; Delete ends it, and is 452 // the only one typed out. 453 454 // The two destructive rows open one screen each, and neither has a second step: the typed gate 455 // is on the screen itself and the button under it fires. The gate is rebuilt from 456 // deletionGateMarkup() on every open, which is also how it RESETS -- coming back after the back 457 // arrow starts empty and locked again. No email step and no undo behind either, which is why 458 // both are TYPED. See the gate below. 459 function openDestructiveScreen(screen, gate, kind){ 460 $(gate).html(deletionGateMarkup(typedGates[kind])); 461 lockDeletionConfirmation($(screen).find('.destructiveGateConfirm'), true); 462 if(window.isPWA){ 463 $(screen).removeClass('menu submenu'); 464 renderSlidableModal(screen, true); 465 }else{ 466 submenuSlideIn($(screen)); 467 } 468 } 469 $(document).on('click', '#deleteAccountSetting', function(){ 470 openDestructiveScreen('#chatProfileDeleteAccount', '#deleteAccountGate', 'delete'); 471 }); 472 $(document).on('click', '#resetEverythingSetting', function(){ 473 openDestructiveScreen('#chatProfileResetEverything', '#resetEverythingGate', 'reset'); 474 }); 475 476 // Re-checked here: these handlers fire the request, so they must not trust the button's state. 477 $(document).on('click', '#deleteAccountSubmitBtn', function(){ 478 if(!gatePhraseMatches(this)) return; 479 markDeletionGateSubmitting(this); 480 performAccountDeletion(); 481 }); 482 $(document).on('click', '#resetEverythingSubmitBtn', function(){ 483 if(!gatePhraseMatches(this)) return; 484 markDeletionGateSubmitting(this); 485 performScheduleReset(); 486 }); 487 488 // Enter is the button under the field. It re-checks the phrase, so Enter on a locked gate does nothing. 489 $(document).on('keydown', '.deletionGateInput', function(e){ 490 if(e.which !== 13) return; 491 $(this).closest(destructiveGateScope).find('.destructiveGateConfirm').click(); 492 }); 493 494 // Delegated: the gate's markup does not exist until the screen is opened. 495 $(document).on('input', '.deletionGateInput', function(){ 496 var gate = $(this).closest(destructiveGateScope); 497 lockDeletionConfirmation(gate.find('.destructiveGateConfirm'), 498 !matchesDeletionPhrase($(this).val(), $(this).attr('data-phrase'))); 499 }); 500 501 // Clears schedules they OWN plus the events, occurrences, tasks and reminders inside them. The 502 // account and this device's uuid are untouched. It always disconnects Google Calendar / ICS / LMS 503 // too, by deleting our own rows, not revoking (Joe, PR #43); they can reconnect. 504 function performScheduleReset(){ 505 506 // Both surfaces can land two clicks. Reset is idempotent so a double-fire was harmless, but it 507 // doubled the request and wrote a second, empty line into accountDeleti
507on.log every time. 508 if(window.scheduleResetInFlight) return; 509 window.scheduleResetInFlight = true; 510 511 $.ajax({ 512 url: "/shared/deletion/resetSchedulesAndTasks.php", 513 type: "POST", 514 dataType: "json", 515 data: { 516 uuid: store.get('uuid') 517 }, 518 success: function(response){ 519 if(response && response.success){ 520 // the server is canonical; pull the now-empty state rather than guessing locally 521 getUserData(store.get('uuid'), null, null, null, function(){ 522 if(typeof(location) !== "undefined") location.reload(); 523 }); 524 }else{ 525 window.scheduleResetInFlight = false; 526 resetDeletionGateSubmit(); 527 alertUserOfDestructiveActionFailure(response); 528 } 529 }, 530 error: function(){ 531 window.scheduleResetInFlight = false; 532 resetDeletionGateSubmit(); 533 alertUserOfDestructiveActionFailure(null); 534 } 535 }); 536 } 537 538 // Erases the account: nukeMe.php blanks the coursicleUsers row and sweeps every table naming this 539 // coursicleID. No undo and no email step â the two confirmations above are the whole gate. 540 // Deliberately NOT routed through the account modal (Joe, PR #43): on success the app redirects. 541 function performAccountDeletion(){ 542 543 // Both surfaces can land two clicks, and unlike the reset this must not be sent twice. 544 if(window.accountDeletionInFlight) return; 545 window.accountDeletionInFlight = true; 546 547 $.ajax({ 548 url: "/shared/deletion/nukeMe.php", 549 type: "POST", 550 dataType: "json", 551 data: { 552 uuid: store.get('uuid') 553 }, 554 success: function(response){ 555 if(response && response.success){ 556 // The row is blanked, not deleted, so this uuid still RESOLVES â left alone the device 557 // keeps the identity of the account just erased. Clearing it makes getSetUserData mint 558 // a fresh coursicleID. Unconditional, whether or not they have another device (PR #43). 559 store.set('uuid', null); 560 store.clearAll(); 561 562 // store.clearAll() only clears the one storage store.js selected. The uuid COOKIE is 563 // written straight to document.cookie by loginless.js, so store never sees it and it 564 // would outlive the account â the server reads that cookie to decide off the cID. 565 document.cookie = "uuid=;path=/;max-age=0"; 566 567 // The Android wrapper keeps its own uuid in SharedPreferences and replays it as a launch 568 // param, so clearing storage never reaches it; only the wrapper can clear its own prefs. 569 // It relaunches with FLAG_ACTIVITY_CLEAR_TASK, tearing down whatever is on screen â 570 // which is why this goes before the redirect rather than after it. 571 if(window.postMessageReady == true){ 572 window.porter.postMessage(JSON.stringify({ type: "[pwa] accountDeleted" })); 573 } 574 575 // School selection rather than a reload: a reload lands on /<school>/, which reads as 576 // though nothing happened, and the school lived in the storage we just cleared. 577 // ?pwa=true is load-bearing â app/index.php reads $isPWA off that param alone. 578 location.href = window.isPWA ? '/app/?pwa=true' : '/'; 579 }else{ 580 // nuke's refusals are already phrased for the user (cancel your subscription first) 581 window.accountDeletionInFlight = false; 582 resetDeletionGateSubmit(); 583 alertUserOfDestructiveActionFailure(response); 584 } 585 }, 586 error: function(){ 587 window.accountDeletionInFlight = false; 588 resetDeletionGateSubmit(); 589 alertUserOfDestructiveActionFailure(null); 590 } 591 }); 592 } 593 594 // The server refuses more often than you would expect (a live subscription, a uuid it cannot 595 // resolve, a prior run, wrong environment), and silence would read as "it worked". 596 function alertUserOfDestructiveActionFailure(response){ 597 var message = (response && response.message) 598 ? response.message 599 : "Something went wrong and nothing was changed. Please try again."; 600 clearAndSetAccountModalStatusScreen({ 601 title: "Couldn't Do That", 602 subtitle: message, 603 shouldDisplay: "x", 604 action: null, 605 subtext: null, 606 subaction: null 607 }); 608 showBaseModal('#accountModal', true); 609 } 610 611 $('#yearSetting').click(function(){ 612 // slide in the submenu 613 submenuSlideIn($("#chatProfileChangeYear")); 614 }); 615 616 $('#pictureSetting').click(function(){ 617 // slide in the submenu 618 submenuSlideIn($("#chatProfileCustomPhoto")); 619 }); 620 621 $('#chatProfilePicture, #changeChatProfilePictureNotice').hover(function() { 622 if ($('#chatUserProfileModalContent').data('coursicleID') === store.get('userData').coursicleID) { 623 $('#chatProfilePicture').css('filter', 'brightness(67%)') 624 $('#changeChatProfilePictureNotice').show() 625 } 626 }, function() { 627 $('#chatProfilePicture').css('filter', 'none') 628 $('#changeChatProfilePictureNotice').hide() 629 }) 630 631 $("#changeChatProfilePictureNotice").click(function(){
632 if($("#chatUserProfileModalContent").data("coursicleID") == store.get('userData').coursicleID) { 633 $('#profile-photo-upload-status').text('') 634 submenuSlideIn($("#chatProfileCustomPhoto")); 635 } 636 }); 637 638 $("#chatProfileName").click(function(){ 639 if($("#chatUserProfileModalContent").data("coursicleID") == store.get('userData').coursicleID) { 640 waitForChatProfileChangeNameTransition() 641 submenuSlideIn($("#chatProfileChangeName")); 642 } 643 }); 644 645 $("#chatProfileYear").click(function(){ 646 if($("#chatUserProfileModalContent").data("coursicleID") == store.get('userData').coursicleID) { 647 submenuSlideIn($("#chatProfileChangeYear")); 648 } 649 }); 650 651 $("#chatProfileMajor").click(function(){ 652 if($("#chatUserProfileModalContent").data("coursicleID") == store.get('userData').coursicleID) { 653 submenuSlideIn($("#chatProfileChangeMajor")); 654 } 655 }); 656 657 $("#coursiclePhotosDesired").click(function(){ 658 $("#pictureModalSearchInput").val(""); 659 populateUserPhotoChoices(); 660 submenuSlideIn($("#chatProfileChangePicture")); 661 }); 662 663 $('#majorSetting').click(function(){ 664 // slide in the submenu 665 $("#majorModalSearchInput").val(""); 666 $(".selectMajorItem").show(); 667 submenuSlideIn($("#chatProfileChangeMajor")); 668 }); 669 670 $('#supportSetting').click(function(){ 671 showBaseModal('#faqModal'); 672 }); 673 674 // FAQ modal logic 675 var faqsLoaded = false; 676 677 $(document).on('faqModalShown', function() { 678 if (!faqsLoaded) { 679 loadFAQs(); 680 } 681 }); 682 683 function loadFAQs() { 684 685 $.ajax({ 686 url: '/app/backend/getFAQ.php', 687 data: { 688 client: window.isPWA ? 'android' : 'web' 689 }, 690 dataType: 'json', 691 success: function(faqs) { 692 $('#faqModalLoading').hide(); 693 $('#faqAccordionContainer').empty(); 694 695 if (!faqs || faqs.length === 0) { 696 $('#faqModalError').show(); 697 return; 698 } 699 700 faqs.forEach(function(faq) { 701 var item = $('<div class="faqAccordionItem"></div>'); 702 var question = $('<div class="faqAccordionQuestion"></div>') 703 .append($('<span></span>').text(faq.question)) 704 .append('<i class="fa-solid fa-plus faqAccordionArrow"></i>');
705 var answer = $('<div class="faqAccordionAnswer"></div>') 706 .append($('<div class="faqAnswerInner"></div>').html($('<span>').text(faq.answer || '').html().replace(/\n/g, '<br>'))); 707 708 item.append(question).append(answer); 709 $('#faqAccordionContainer').append(item); 710 }); 711 712 faqsLoaded = true; 713 }, 714 error: function() { 715 $('#faqModalLoading').hide(); 716 $('#faqModalError').show(); 717 } 718 }); 719 } 720 721 $(document).on('click', '.faqAccordionQuestion', function() { 722 var item = $(this).closest('.faqAccordionItem'); 723 var answer = item.find('.faqAccordionAnswer'); 724 725 if (item.hasClass('open')) { 726 answer.css('max-height', '0'); 727 item.removeClass('open'); 728 } else { 729 $('.faqAccordionItem.open').each(function() { 730 $(this).find('.faqAccordionAnswer').css('max-height', '0'); 731 $(this).removeClass('open'); 732 }); 733 item.addClass('open'); 734 answer.css('max-height', answer[0].scrollHeight + 'px'); 735 } 736 }); 737 738 $(document).on('click', '#faqContactUsLink', function(e) { 739 e.preventDefault(); 740 showBaseModal('#universalContactModal'); 741 }); 742 743 $("#userPhotoOptionsGrid").click(function(e) { 744 if ($(e.target).is('img')) { 745 // Take the last part of the img src 746 var newPictureUrl = $(e.target).attr('src').split('file=')[1].split("&")[0]; 747 748 // update locally, remotely, and update ui 749 setUserDataJsonString(username=null, userPhotoUrl=newPictureUrl, year=null, major=null, notificationPreferences=null); 750 submenuSlideOut(activeSubmenu); 751 submenuSlideOut(activeSubmenu); 752 } 753 }); 754 755 setupMenuToggle(); 756 setupClickListeners(); 757 758} 759 760// shared with chatDetailView 761function openMuteModal(){ 762 $('#muteModal').removeClass("chatSpecific").removeClass("global"); 763 var context = "global"; 764 if($("#notificationsModal").hasClass("chatSpecific")){ 765 context = "chatSpecific"; 766 } 767 $('#muteModal').addClass(context); 768 $('.modal').modal('hide') 769 $('#muteNotificationOptions').show() 770 $('.muteNotificationOptionContainer').addClass('notif') 771 $('.muteNotificationOptionContainer').removeClass('selected') 772 $(`label[for=chatNotifOption1Hour`).addClass('selected') 773 if (window.modalStack && window.modalStack.includes("modal#notificationsModal")){ 774 hideSlidableModal(false) 775 } 776 //We get modal on modal situations here which are annoying to dismiss, so we're going to dismiss this 777 //one and not give it a chance to be annoying. 778 if (window.modalStack && window.modalStack.includes("modal#accountChatNotifications")){ 779 hideSlidableModal(false) 780 } 781 window.isPWA ? renderSlidableModal("#muteModal",false): $('#muteModal').modal('show') 782} 783 784//only should be used if verification modal visible 785function requestCrop() { 786 $("#verify-subhead").text("") 787 $("#verify-your-email").text("Crop Photo") 788 $('#verify-button-wrapper').hide() 789 $("#verify-photo-wrapper").hide() 790 $('#userPhotoCropPreview').show() 791} 792 793function setupClickListeners() { 794 795 $("#saveNameButton").on('click', function(e) { 796 797 // If they are being clever and get around the backspace limitation, 798 // reset the name with 'Me' if they violate the constraints. 799 // also in all/js/chat/shared.js 800 if ($("#nameInput").val().length < 2) { 801 alert("Your name is too short") 802 return 803 } else if ($("#nameInput").val().toLowerCase().split(" ").includes("user")) { 804 alert("Your name may not contain the word \"User\"") 805 return 806 } else if ($("#nameInput").val().toLowerCase().includes("coursicle")) { 807 alert("Your name may not contain the word \"Coursicle\"") 808 return 809 } 810 811 // update locally, remotely, and update ui 812 setUserDataJsonString( 813 username=$("#nameInput").val(), 814 userPhotoUrl=null, 815 year=null, 816 major=null, 817 notificationPreferences=null 818 ); 819 820 closeUserMenu(); 821 }); 822 823 $(document).on('click', '#uploadPhotoBtn', function() { 824 $('#profile-image-input').click() 825 }) 826 827 $(document).on('click', '#cropPhotoBtn, #cropPhotoVerifBtn', async function() { 828 829 const spinnerHTML = `<div class="ispinner ispinner-small"> 830 <div class="ispinner-blade"></div> 831 <div class="ispinner-blade"></div> 832 <div class="ispinner-blade"></div> 833 <div class="ispinner-blade"></div> 834 <div class="ispinner-blade"></div> 835 <div class="ispinner-blade"></div> 836 <div class="ispinner-blade"></div> 837 <div class="ispinner-blade"></div> 838 </div>` 839 840 $("#cropPhotoBtn, #cropPhotoVerifBtn").html(spinnerHTML); 841 842 // $("#profile-photo-upload-status").slideDown(150) 843 // $('#profile-photo-upload-status').text('Uploading...') 844 $('#verification-profile-photo-upload-status').remove() 845 $('#verify-photo-wrapper').after($('#profile-photo-upload-status').clone().prop('id', "verification-profile-photo-upload-status")) 846 $("#verification-profile-photo-upload-status").hide(); 847 848 //https://github.com/fengyuanchen/cropperjs/blob/main/docs/examples/upload-cropped-image-to-server.html 849 //cropperjs example converts cropper object to blob and posts it as formData. While it would be nice to use this directly, uploadImage.php expects a file. 850 //here, we instead convert canvas to url, and url to file. This is how it is done in compression so nothing new here. 851 var image = null 852 var file = await window.cropper.crop().getCroppedCanvas() 853 const dataURL = file.toDataURL('image/jpeg', 1) 854 let uploadFilename = window.userProfileFilename ? window.userProfileFilename : "croppedImageUpload.jpg" 855 const newImageFile = await convertDataURLToFile(dataURL, uploadFilename, 'image/jpeg') 856 const formData = new FormData() 857 formData.append("file", newImageFile) 858 formData.append("fileName", uploadFilename) 859 formData.append("type", "userPhoto") 860 await customDelay(800); 861 862 $.ajax({ 863 url: '/shared/uploadImage.php', 864 type: 'POST', 865 dataType: 'json', 866 data: formData, 867 processData: false, 868 contentType: false, 869 success(res) { 870 if(res.error == "true"){ 871 resetPhotoUploadState() 872 displayUploadImageError(res.explanation); 873 return 874 } 875 $('#profile-image-input')[0].value = null 876 setUserDataJsonString(username=null, userPhotoUrl=res.url, year=null, major=null, notificationPreferences=null); 877 878 //the processing image text was persisting 879 $('#profile-photo-upload-status').text('') 880 881 $('#verification-profile-photo-upload-status').remove() 882 $("#cropPhotoBtn").text("Uploaded"); 883 setTimeout(function(){ 884 $("#cropPhotoBtn").text("Crop"); 885 }, 1000) 886 887 888 updateMessageInputPermisson(window.verificationAction) 889 // advance to next page 890 requestUserInformation(window.verificationAction)
891 closeUserMenu() 892 if(!window.isPWA){ 893 submenuSlideOut($('#chatProfileCustomPhoto')) 894 $('#chatUserProfile .modal-content').css('height','100vh') 895 } 896 if(window.isPWA && ($("#chatUserProfileContainer").is(":visible"))){ 897 hideSlidableModal(true,$('#chatProfileCustomPhotoCrop')) 898 } 899 }}) 900 }); 901 902 function resetPhotoUploadState(){ 903 if (!window.isPWA){ 904 //verification case: generate fresh photo request to reset crop screen/state. 905 if (!$('#chatUserProfile').is(":visible")){ 906 requestPhoto() 907 } 908 //if web profile case, slide back to upload menu 909 submenuSlideOut($('#chatProfileCustomPhotoCrop')) 910 $('#chatUserProfileModalContent').css("height", "293px") 911 }else{ 912 //verification case: generate fresh photo request to reset crop screen/state. 913 if ($('#verification-content').is(":visible")){ 914 requestPhoto() 915 } 916 //if PWA profile case, hide the modal to recover upload modal 917 hideSlidableModal() 918 } 919 $('#cropPhotoBtn, #cropPhotoVerifBtn').html('Crop') 920 } 921 922 function displayUploadImageError(reason){ 923 $('#profile-image-input')[0].value = null 924 $('#profile-photo-upload-status, #verification-profile-photo-upload-status').text(reason) 925 $('#profile-photo-upload-status, #verification-profile-photo-upload-status').slideDown(200); 926 $("#chatUserProfileModalContent").height("270px"); 927 $("#uploadPhotoBtn, #verify-photo-wrapper").html("Upload"); 928 } 929 930 $(document).on('change', '#profile-image-input', async function() { 931 932 const spinnerHTML = `<div class="ispinner ispinner-small"> 933 <div class="ispinner-blade"></div> 934 <div class="ispinner-blade"></div> 935 <div class="ispinner-blade"></div> 936 <div class="ispinner-blade"></div> 937 <div class="ispinner-blade"></div> 938 <div class="ispinner-blade"></div> 939 <div class="ispinner-blade"></div> 940 <div class="ispinner-blade"></div> 941 </div>` 942 943 $("#uploadPhotoBtn, #verify-photo-wrapper").html(spinnerHTML); 944 945 // $("#profile-photo-upload-status").slideDown(150) 946 // $('#profile-photo-upload-status').text('Uploading...') 947 $('#verification-profile-photo-upload-status').remove() 948 $('#verify-photo-wrapper').after($('#profile-photo-upload-status').clone().prop('id', "verification-profile-photo-upload-status")) 949 $("#verification-profile-photo-upload-status").hide(); 950 951 // having multiple croppers defined causes all sorts of issues, have to clear any existing ones 952 if (typeof window.cropper !== 'undefined'){ 953 window.cropper.destroy() 954 } 955 await customDelay(300); 956 957 958 959 //$('#profile-image-input')[0].value = null 960 //setUserDataJsonString(username=null, userPhotoUrl=res.url, year=null, major=null, notificationPreferences=null); 961 962 //the processing image text was persisting 963 $('#profile-photo-upload-status').text(''); 964 965 966 967 let userData = store.get('userData') 968 //get the file from the html input. 969 var inputtedImg = $('#profile-image-input')[0].files[0] 970 //remember the user-submitted filename for later for uploadPhoto request. 971 window.userProfileFilename = inputtedImg.name 972 inputtedImg = await compressImage(inputtedImg) 973 if (inputtedImg.size > 2048576) { // 2 MB 974 //This checks the image before it is cropped. It is *possible* that a cropped image could be over 2 mb, see cropper.js docs. 975 displayUploadImageError('File too large, please limit file size to 2 MB'); 976 return 977 } 978 979 980 //the goal here is to load the image client-side first; put them in #userPhotoCropArea and #userVerifPhotoCropArea for use by cropper.js. 981 //https://stackoverflow.com/questions/3814231/loading-an-image-to-a-img-from-input-file 982 //https://stackoverflow.com/questions/5802580/html-input-type-file-get-the-image-before-submitting-the-form 983 const reader = new FileReader() 984 //paraphrasing Mozilla docs, this listener executes after readAsDataURL (below) finishes. 985 reader.onloadend = function() { 986 //these css stylings need to happen here. Otherwise, parts of the cropper appear under the crop button. 987 $('#userPhotoCropArea, #userVerifPhotoCropArea').attr('src',reader.result) 988 //if not pwa, crop screen can remain scrollable for extra long photos. Otherwise, set a max height and avoid showing scrollbar. 989 if (window.isPWA){ 990 $('#userPhotoCropArea, #userVerifPhotoCropArea').css('overflow','hidden').css('max-height','50vh') 991 } 992 reader.abort() 993 //depending on profile view or verification view, retrieve the image from the dom and create a cropper object with it. 994 const image = $("#chatUserProfileContainer").is(":visible") ? document.getElementById('userPhotoCropArea') : document.getElementById('userVerifPhotoCropArea') 995 $('#profile-image-input')[0].value = null 996 window.cropper = new Cropper(image, {aspectRatio: 1 / 1, viewMode: 3, guides: false, center: false, dragMode: "crop", crop(event){}}) 997 998 //remove old error messages. 999 $('#verification-profile-photo-upload-status').remove() 1000 $("#uploadPhotoBtn").text("Uploaded"); 1001 //reset the upload text after a short time 1002 setTimeout(function(){ 1003 $("#uploadPhotoBtn").text("Upload"); 1004 }, 1000) 1005 1006 //pwa non-verification case 1007 if($("#chatUserProfileContainer").is(":visible") && window.isPWA){ 1008 renderSlidableModal($('#chatProfileCustomPhotoCrop')) 1009 //TODO: figure out a more permanent way to correct CropArea's height according to image size when it loads. Same for web non-verification case. Temporarily in setTimeout. 1010 setTimeout(function(){ 1011 $('#chatProfileCustomPhotoCrop').height(($('.CropArea').height()+60)) 1012 },100) 1013 } 1014 //web non-verification case 1015 else if ($("#chatUserProfileContainer").is(":visible") && !window.isPWA){ 1016 submenuSlideIn($('#chatProfileCustomPhotoCrop')) 1017 setTimeout(function(){
1018 $('#chatUserProfile .modal-content').height(($('.CropArea').height()+60)) 1019 },100) 1020 } 1021 //verification case 1022 else if (!$("#chatUserProfileContainer").is(":visible")){ 1023 //as a part of the verification flow, we can't just submenuSlideIn. 1024 requestCrop() 1025 } 1026 1027 } 1028 1029 reader.readAsDataURL(inputtedImg) 1030 1031 }); 1032 1033 1034 1035 function paintGlobalNotificationSettings() { 1036 $('#classStatusNotifToggle').attr('checked', getNotificationPreferences().statusChanges) 1037 $('#classUpdatesNotifToggle').attr('checked', getNotificationPreferences().classUpdates) 1038 $('.notifModSection').show() 1039 $('#muteModalTitle').text('Mute all chats for') 1040 $('.notifModSection.withSlider, .notifModSectionTitle').show() 1041 $(`.chatNotificationOptionContainer`).removeClass('selected') 1042 $(`label[for=chatNotifOption${getNotificationPreferences().chat.default}]`).addClass('selected') 1043 $('.modal').modal('hide') 1044 } 1045 1046 window.paintMuteUserSettings = function(clickedElement) { 1047 // add entry for muted user until 1048 let profileViewedCoursicleID = $('#muteUserBtnContainer').data("coursicleID") 1049 if (profileViewedCoursicleID != Number(getCoursicleID())){ 1050 $('.modal').modal('hide') 1051 $('#muteNotificationOptions').show() 1052 $('.muteNotificationOptionContainer').removeClass('selected') 1053 $('.muteNotificationOptionContainer').addClass('user') 1054 $(`label[for=chatNotifOption1Hour`).addClass('selected') 1055 $('#muteModalTitle').text("Mute " + clickedElement.data("username") + " for") 1056 $('#muteModal').data("coursicleID", clickedElement.data("coursicleID") ) 1057 if (window.isPWA) { 1058 if (window.modalStack.includes("modal#notificationsModal")){ 1059 hideSlidableModal(false) 1060 } 1061 } 1062 window.isPWA ? renderSlidableModal('#muteModal',false) : $('#muteModal').modal('show') 1063 } 1064 else { 1065 alert("You cannot mute yourself.") 1066 } 1067 } 1068 1069 $('label[for="chatNotifOptionNone"]').click(function () { 1070 openMuteModal(); 1071 }) 1072 1073 $(document).on('click', '#notificationsSetting', function () { 1074 if(["unset", "disabled"].includes(getSystemLevelNotificationSetting())){ 1075 requestNotificationPermissions("tappedNotificationSetting"); 1076 return; 1077 } 1078 paintNotificationsSubmenu(); 1079 if(window.isPWA){ 1080 $('#accountNotifications').removeClass('menu submenu') 1081 renderSlidableModal('#accountNotifications'); 1082 }else{ 1083 submenuSlideIn($("#accountNotifications")); 1084 } 1085 }) 1086 1087 function paintNotificationsSubmenu(){ 1088 var prefs = getNotificationPreferences(); 1089 $('#remindersEnabledToggle').prop('checked', getDefaultRemindersEnabled()); 1090 $('#globalClassStatusNotifToggle').prop('checked', !!prefs.statusChanges); 1091 $('#globalClassUpdatesNotifToggle').prop('checked', !!prefs.classUpdates); 1092 var chatDefault = (prefs.chat && prefs.chat.default) || 'All'; 1093 var labelMap = {All: 'All Messages', Summary: 'Summary', None: 'None'}; 1094 $('#chatNotifCurrentLabel').text(labelMap[chatDefault] || 'All Messages'); 1095 $('.chatNotifOptionRow').removeClass('selected'); 1096 $('.chatNotifOptionRow[data-value="' + chatDefault + '"]').addClass('selected'); 1097 } 1098 1099 $(document).on('click', '#remindersEnabledToggle', function(){ 1100 saveRemindersPreference({enabled: this.checked}); 1101 }) 1102 1103 $(document).on('click', '#globalClassStatusNotifToggle', function(){ 1104 let preferences = getNotificationPreferences() 1105 preferences.statusChanges = this.checked 1106 setUserDataJsonString(username=null, userPhotoUrl=null, year=null, major=null, notificationPreferences=preferences) 1107 }) 1108 1109 $(document).on('click', '#globalClassUpdatesNotifToggle', function(){ 1110 let preferences = getNotificationPreferences() 1111 preferences.classUpdates = this.checked 1112 setUserDataJsonString(username=null, userPhotoUrl=null, year=null, major=null, notificationPreferences=preferences) 1113 }) 1114 1115 $(document).on('click', '#chatNotificationsRow', function(){ 1116 paintNotificationsSubmenu(); 1117 if(window.isPWA){ 1118 $('#accountChatNotifications').removeClass('menu submenu') 1119 renderSlidableModal('#accountChatNotifications'); 1120 }else{ 1121 submenuSlideIn($("#accountChatNotifications")); 1122 } 1123 }) 1124 1125 $(document).on('click', '.chatNotifOptionRow', function(){ 1126 var value = $(this).data('value'); 1127 let preferences = getNotificationPreferences() 1128 if(!preferences.chat) preferences.chat = {}; 1129 preferences.chat.default = value; 1130 setUserDataJsonString(username=null, userPhotoUrl=null, year=null, major=null, notificationPreferences=preferences) 1131 $('.chatNotifOptionRow').removeClass('selected'); 1132 $(this).addClass('selected'); 1133 var labelMap = {All: 'All Messages', Summary: 'Summary', None: 'None'}; 1134 $('#chatNotifCurrentLabel').text(labelMap[value] || 'All Messages'); 1135 1136 if(value === 'None'){ 1137 $('#muteModal').data('source', 'accountChatNotifications'); 1138 openMuteModal(); 1139 } 1140 }) 1141 1142 // When the mute modal is saved from the account chat notifications flow, 1143 // also slide back from the chat notifications submenu to the main notifications submenu. 1144 $(document).on('click', '#muteModal .saveNotificationSettingsBtn', function(){ 1145 if($('#muteModal').data('source') !== 'accountChatNotifications') return; 1146 $('#muteModal').removeData('source'); 1147 // web-only: openMuteModal already pops the chat notifications submenu on PWA 1148 if(!window.isPWA && typeof submenuSlideOut === 'function'){ 1149 submenuSlideOut($("#accountChatNotifications")); 1150 } 1151 }) 1152 1153 $(document).on('click', '#muteUserBtnContainer', function() { 1154 if(["unset", "disabled"].includes(getSystemLevelNotificationSetting())){ 1155 if(!window.isPWA){$('#profileModal').modal('hide')} 1156 requestNotificationPermissions("tappedNotificationSetting"); 1157 return; 1158 } 1159 const coursicleId = $(this).data('coursicleID') 1160 if (isUserMuted(coursicleId)) { 1161 unmuteUser(coursicleId) 1162 $('#muteUserBtnContainer .activeChatOptionBtn').empty() 1163 $('#muteUserBtnContainer .activeChatOptionBtn').append(`<i class='fa-solid fa-bell'></i>`) 1164 $('#muteUserBtnContainer .activeChatOptionBtnLabel').text('Mute') 1165 } else { 1166 paintMuteUserSettings($(this)) 1167 } 1168 }) 1169 1170 function unmuteUser(coursicleId) { 1171 const id = Number(coursicleId) 1172 const prefs = getNotificationPreferences() 1173 delete prefs.chat.users[id] 1174 const data = store.get('userData') 1175 data.notificationPreferences = prefs 1176 store.set('userData', data) 1177 } 1178 1179 $('#classStatusNotifToggle').click(function() { 1180 let preferences = getNotificationPreferences() 1181 preferences.statusChanges = this.checked 1182 setUserDataJsonString(username=null, userPhotoUrl=null, year=null, major=null, notificationPreferences=preferences) 1183 }) 1184 1185 $('#classUpdatesNotifToggle').click(function() { 1186 let preferences = getNotificationPreferences() 1187 preferences.classUpdates = this.checked 1188 setUserDataJsonString(username=null, userPhotoUrl=null, year=null, major=null, notificationPreferences=preferences) 1189 }) 1190 1191 // Default Reminders setting 1192 $(document).on('click', '#defaultRemindersSetting, #changeDefaultsRow', function(){
1193 if(displayWallIfNeededAndReturnBlockingStatus('customReminders')) return; 1194 var defaults = getDefaultReminders(); 1195 1196 var eventHtml = ''; 1197 for(var i = 0; i < defaults.event.length; i++){ 1198 eventHtml += createReminderRowHTML(defaults.event[i]); 1199 } 1200 $('#defaultEventRemindersList').html(eventHtml); 1201 1202 var taskHtml = ''; 1203 for(var i = 0; i < defaults.task.length; i++){ 1204 taskHtml += createReminderRowHTML(defaults.task[i]); 1205 } 1206 $('#defaultTaskRemindersList').html(taskHtml); 1207 1208 updateDefaultRemindersRowControls(); 1209 if(window.isPWA){ 1210 $('#accountDefaultReminders').removeClass('menu submenu') 1211 renderSlidableModal('#accountDefaultReminders'); 1212 }else{ 1213 submenuSlideIn($("#accountDefaultReminders")); 1214 } 1215 }) 1216 1217 function updateDefaultRemindersRowControls(){ 1218 $('.defaultRemindersAddBtn').each(function(){ 1219 var context = $(this).data('context'); 1220 var listSelector = context === 'event' ? '#defaultEventRemindersList' : '#defaultTaskRemindersList'; 1221 var count = $(listSelector + ' .customReminderRow').length; 1222 if(count >
1222= MAX_REMINDERS_PER_ITEM){ 1223 $(this).addClass('disabled'); 1224 $(this).text(`Maximum ${MAX_REMINDERS_PER_ITEM} reminders per ${context}`) 1225 } else { 1226 $(this).removeClass('disabled'); 1227 $(this).text('+ Add another reminder') 1228 } 1229 // hide delete buttons when only one reminder remains 1230 if(count <= 1){ 1231 $(listSelector + ' .customReminderDeleteBtn').hide(); 1232 } else { 1233 $(listSelector + ' .customReminderDeleteBtn').show(); 1234 } 1235 }); 1236 } 1237 1238 $(document).on('click', '.defaultRemindersAddBtn', function(){ 1239 var context = $(this).data('context'); 1240 var listSelector = context === 'event' ? '#defaultEventRemindersList' : '#defaultTaskRemindersList'; 1241 if($(listSelector + ' .customReminderRow').length >= MAX_REMINDERS_PER_ITEM) return; 1242 $(listSelector).append(createReminderRowHTML({type: 'push', value: 600})); 1243 updateDefaultRemindersRowControls(); 1244 }); 1245 1246 $(document).on('click', '#accountDefaultReminders .customReminderDeleteBtn', function(){ 1247 $(this).closest('.customReminderRow').remove(); 1248 updateDefaultRemindersRowControls(); 1249 }); 1250 1251 $(document).on('click', '#saveDefaultRemindersBtn', function(){ 1252 $('#setAsDefaultRemindersModal').data('context', 'all'); 1253 updateApplyDefaultsModalCopy('all'); 1254 showBaseModal('#setAsDefaultRemindersModal'); 1255 }); 1256 1257 $('#paintTasksToggle').click(function() { 1258 let userData = getUserDataJsonString() 1259 userData.paintTasksOnCalendar = this.checked 1260 store.set('userData', userData) 1261 setUserData(uuid=store.get("uuid"), deviceID=null, token=null, school=null, userDataJsonString=JSON.stringify(userData)) 1262 window.repaintCalendar() 1263 }) 1264 1265 $("#majorModalSearchInput").keyup(function () { 1266 let chatProfileMajorContainer = $('#chatProfileChangeMajorContainer') 1267 let input = $(this).val().toUpperCase() 1268 chatProfileMajorContainer.find(".roundedRectSelectionItem").each(function(){ 1269 if($(this).find(".selectionItemText").text().toUpperCase().startsWith(input)){ 1270 $(this).addClass('roundedRectSelectionItemShown') 1271 $(this).show(); 1272 }else{ 1273 $(this).removeClass('roundedRectSelectionItemShown') 1274 $(this).hide(); 1275 } 1276 }); 1277 1278 // if there are no results 1279 if (chatProfileMajorContainer.find(".roundedRectSelectionItemShown").length == 0) { 1280 $('#chatProfileChangeMajor .modalSearchNoResults').show(); 1281 chatProfileMajorContainer.hide() 1282 } else { 1283 $('#chatProfileChangeMajor .modalSearchNoResults').hide(); 1284 chatProfileMajorContainer.show() 1285 1286 } 1287 }); 1288 1289 $("#pictureModalSearchInput").keyup(function (){ 1290 let container = $(this).parent().parent(); 1291 let input = $(this).val().toUpperCase(); 1292 $(container).find(".coursicleUserPhotoOption").each(function(){ 1293 if($(this).attr('src').toUpperCase().includes(input)){ 1294 $(this).show(); 1295 }else{ 1296 $(this).hide(); 1297 } 1298 }); 1299 // if there are no results 1300 if ($(container).find(".coursicleUserPhotoOption:visible").length == 0) { 1301 $(container).find(".modalSearchNoResults").show(); 1302 } else { 1303 $(container).find(".modalSearchNoResults").hide(); 1304 } 1305 }); 1306} 1307 1308 1309function setupMenuToggle() { 1310 $("#loggedInContainer").click(function() { 1311 // Disable the menu in incognito 1312 if (store.get("incognito")) return; 1313 openUserMenu(); 1314 }) 1315} 1316 1317var linkedAccountIcons = { 1318 "googleCalendar": '<img src="/shared/img/googleCalendar.svg">', 1319 "canvas": '<img src="/shared/tasks/img/logos/canvas.svg">', 1320 "blackboard": '<img src="/shared/tasks/img/logos/blackboard.svg">', 1321 "sakai": '<img src="/shared/tasks/img/logos/sakai.svg">', 1322 "brightspace": '<img src="/shared/tasks/img/logos/brightspace.svg">', 1323 "moodle": '<img src="/shared/tasks/img/logos/moodle.svg">', 1324 "googleClassroom": '<img src="/shared/tasks/img/logos/googleclassroom.svg">', 1325 get other() { return $('#settingLinkIcon').length ? $('#settingLinkIcon')[0].outerHTML : 'ð'; } // getter so jQuery lookup runs when accessed, not at parse time 1326}; 1327 1328function renderLinkedAccounts(accounts) { 1329 var container = $("#linkedAccountsList"); 1330 container.empty(); 1331 if(!accounts || accounts.length === 0){ 1332 container.append('<div class="linkedAccountsEmpty">No linked accounts</div>'); 1333 return; 1334 } 1335 accounts.forEach(function(account) { 1336 var type = account.type; 1337 var icon = linkedAccountIcons[type] || linkedAccountIcons["other"]; 1338 var displayName = account.displayName || type; 1339 var row = $('<div class="settingsSubRow linkedAccountRow"></div>'); 1340 row.attr('data-account-type', type); 1341 row.append('<span class="settingEmoji linkedAccountEmoji">' + icon + '</span>'); 1342 var labelWrap = $('<div class="linkedAccountLabelWrap"></div>'); 1343 labelWrap.append('<div class="settingsLabel">' + displayName + '</div>'); 1344 var lastSync = account.lastScanned || account.lastImportRequest; 1345 if(lastSync){
1346 var syncDate = new Date(lastSync * 1000).toLocaleString(); 1347 labelWrap.append('<div class="linkedAccountLastSync">Updated ' + prettyDate(syncDate) + '</div>'); 1348 } 1349 row.append(labelWrap); 1350 row.append('<i class="fa-solid fa-xmark linkedAccountDeleteX"></i>'); 1351 container.append(row); 1352 }); 1353} 1354 1355function showUnlinkConfirmationTippy(target, accountName) { 1356 1357 let unlinkTippyInstance = tippy(target, { 1358 content() { 1359 return '<div class="unlinkConfirmationOptions"><div class="unlinkConfirm unlinkOnly" data-account="' + accountName + '">Unlink and keep data</div><div class="unlinkConfirm unlinkDeleteData" data-account="' + accountName + '">Unlink and delete data</div></div>'; 1360 }, 1361 allowHTML: true, 1362 theme: 'unlinkAccountConfirmation', 1363 trigger: 'manual', 1364 interactive: true, 1365 hideOnClick: true, 1366 placement: 'right', 1367 arrow: false, 1368 appendTo: document.body, 1369 onHide() { 1370 $('.linkedAccountRow').removeClass('linkedAccountDeleteXShown'); 1371 } 1372 }); 1373 unlinkTippyInstance.show(); 1374} 1375 1376// ---- THE TYPED CONFIRMATION GATE ON ACCOUNT DELETION -------------------------------------------- 1377// Written once here, used by both surfaces, and in the same words on iOS (presentDeletionTypingGate). 1378// It REPLACES the final click rather than adding a third step: a second click lands where the first 1379// did and proves nothing, whereas nobody types a sentence by accident. 1380 1381// What has to be typed before each irreversible action will fire, with the sentence that says what 1382// it takes. Named once and interpolated into the prompt below: a gate asking for something it will 1383// not accept is unreportable and looks like a broken site. 1384// Each phrase is the same words as the button under it, capital included: the match is exact and 1385// the field turns autocapitalise off, so the first letter is theirs to type. 1386var typedGates = { 1387 'delete': { 1388 phrase: 'Delete my account', 1389 sentence: 'This cannot be undone.' 1390 }, 1391 // asked on the reset screen itself (#chatProfileResetEverything), under the list of what goes 1392 'reset': { 1393 phrase: 'Reset my data', 1394 sentence: 'This cannot be undone.' 1395 } 1396}; 1397 1398var deletionConfirmationPhrase = typedGates['delete'].phrase; 1399 1400// Where a gate's field and its confirm live together: the two destructive screens. Every lookup 1401// below walks up to one of these from the element it was handed. 1402var destructiveGateScope = '#chatProfileDeleteAccount, #chatProfileResetEverything'; 1403 1404// Trimmed but case-SENSITIVE, matching iOS. Safe only because the field below turns autocapitalise 1405// off â that attribute is load-bearing, not cosmetic. Surrounding whitespace is invisible so refusing 1406// it only punishes a paste; interior whitespace still fails ("delete my account" is a different one). 1407function matchesDeletionPhrase(typed, phrase){ 1408 return typeof typed === 'string' && typed.trim() === (phrase || deletionConfirmationPhrase); 1409} 1410 1411// The expected phrase travels on the field rather than in a variable the handlers have to guess at, 1412// so a second gate needs no second code path. 1413function gatePhraseMatches(fromButton){ 1414 var input = $(fromButton).closest(destructiveGateScope).find('.deletionGateInput'); 1415 return input.length > 0 && matchesDeletionPhrase(input.val(), input.attr('data-phrase')); 1416} 1417 1418// Prompt and field built in one place so both screens ask the same question, from the constants 1419// above. The three input attributes are not styling: the match is case-sensitive, so a keyboard 1420// that capitalises or "corrects" the phrase would make the gate unpassable. The confirm is the 1421// screen's own button under the field, so nothing is drawn inside it. 1422function deletionGateMarkup(gate){ 1423 gate = gate || typedGates['delete']; 1424 // the phrase is a chip (see .deletionGatePrompt b in menu.css), so it reads as the thing to copy 1425 return '<div class="deletionGatePrompt">To continue, type <b>' + gate.phrase + '</b> below.' 1426 + (gate.sentence ? ' ' + gate.sentence : '') 1427 + '</div>' 1428 + '<div class="deletionGateField">' 1429 + '<input class="deletionGateInput" type="text" data-phrase="' + gate.phrase 1430 + '" placeholder="' + gate.phrase 1431 + '" autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false">' 1432 + '</div>'; 1433} 1434 1435// Submitting: the button says so (its data-busy label) and stops taking clicks. The screen stays 1436// where it is -- on success the page reloads or redirects out from under it, and on failure 1437// resetDeletionGateSubmit puts the resting label back to be pressed again. 1438function markDeletionGateSubmitting(confirmButton){ 1439 var btn = $(confirmButton).closest('.destructiveGateConfirm'); 1440 if(btn.length === 0) return; 1441 btn.addClass('destructiveSubmitting').data('restingLabel', btn.text()).text(btn.attr('data-busy')); 1442} 1443 1444function resetDeletionGateSubmit(){ 1445 $('.destructiveGateConfirm.destructiveSubmitting').each(function(){ 1446 $(this).removeClass('destructiveSubmitting').text($(this).data('restingLabel')); 1447 }); 1448} 1449 1450// Locked is the starting and the resting state; only matchesDeletionPhrase lifts it. The class 1451// carries pointer-events: none, and aria-disabled says the same thing to a screen reader. 1452function lockDeletionConfirmation(confirmButton, locked){ 1453 confirmButton.toggleClass('destructiveGateLocked', locked) 1454 .attr('aria-disabled', locked ? 'true' : 'false'); 1455} 1456 1457function submenuSlideIn(submenu) { 1458 //$("#chatUserProfileModalContent").css("height", submenu.attr("data-height")); 1459 submenu.css("transform", "translateX(0px)"); 1460 1461 $("#chatUserProfileContainer").css("transform", "translateX(-130%)"); 1462 if(activeSubmenu){ 1463 activeSubmenu.css("transform", "translateX(-130%)"); 1464 arrayOfStackedSubmenus.push(activeSubmenu); 1465 } 1466 1467 $("#chatUserProfileBackButton").fadeIn(600); 1468 activeSubmenu = submenu; 1469} 1470 1471function submenuSlideOut(submenu) { 1472 1473 // no matter what, slide the current submenu out 1474 submenu.css("transform", "translateX(343px)"); 1475 1476 if(arrayOfStackedSubmenus.length>0){ 1477 // we need to pull back the most recently de-activated submenu 1478 mostRecentMenu = arrayOfStackedSubmenus.pop(); 1479 //$('#chatUserProfileModalContent').css("height",mostRecentMenu.attr("data-height")); 1480 mostRecentMenu.css("transform", "translateX(0px)"); 1481 activeSubmenu = mostRecentMenu; 1482 1483 }else{ 1484 // bring back the default menu 1485 //$("#chatUserProfileModalContent").css("height", $("#chatUserProfileContainer").attr("data-height")); 1486 $("#chatUserProfileContainer").css("transform", "translateX(0%)") 1487 $("#chatUserProfileBackButton").fadeOut(300); 1488 activeSubmenu = null; 1489 } 1490} 1491
1492function closeUserMenu() { 1493 if(activeSubmenu) submenuSlideOut(activeSubmenu); 1494 activeSubmenu = null; 1495 $("#chatUserProfileBackButton").hide(); 1496} 1497 1498function openUserMenu(){ 1499 refreshMenuUI(); 1500 getListOfUserPhotoChoices(); 1501 openInLeftRailView($('#chatUserProfile')) 1502} 1503 1504// colors the year, shows the selected socials, paints the notification setting, etc. 1505function fillInProfileInformation(){ 1506 1507 // first-year, sophomore, etc. 1508 colorUserProfileYears(); 1509 colorUserProfileMajor(); 1510 1511 // the code below adds an indicator to the notification row if notifications are not explicitly enabled. 1512 if(getSystemLevelNotificationSetting() != "enabled"){ 1513 $('#notificationsSetting').find(".settingsValue").attr('id', "notificationsEnabled") 1514 // TODO later: we're having trouble with Android properly reporting the notification state so 1515 // in order to not alarm people we're just not going to do this in PWA case 1516 if(!window.isPWA){ 1517 $('#notificationsSetting').find(".settingsValue").text('Off') 1518 } 1519 } 1520} 1521 1522 1523function colorUserProfileYears() { 1524 var tag = $('#chatProfileYear') 1525 tag.removeClass() 1526 if (tag.text().startsWith("First-year")) { 1527 $('#chatProfileYear').addClass('first-year') 1528 } else if (tag.text().startsWith("Sophomore")) { 1529 $('#chatProfileYear').addClass('sophomore') 1530 } else if (tag.text().startsWith("Junior")) { 1531 $('#chatProfileYear').addClass('junior') 1532 } else if (tag.text().startsWith("Senior")) { 1533 $('#chatProfileYear').addClass('senior') 1534 } else if (tag.text().startsWith("Graduate")) { 1535 $('#chatProfileYear').addClass('graduate') 1536 } else if (tag.text().startsWith("Advisor")) { 1537 $('#chatProfileYear').addClass('advisor') 1538 } else if (tag.text().startsWith("Professor")) { 1539 $('#chatProfileYear').addClass('professor') 1540 } else if (tag.text().startsWith("Team")) { 1541 $('#chatProfileYear').addClass('team') 1542 } 1543} 1544 1545function colorUserProfileMajor() { 1546 var tag = $('#chatProfileMajor') 1547 if (!tag.length) return 1548 1549 var majorName = tag.text().trim() 1550 if (!majorName) return 1551 1552 // Remove any existing majorColor class 1553 tag.removeClass(function(index, className) { 1554 return className.split(' ').filter(cls => cls.startsWith('majorColor')).join(' ') 1555 }) 1556 1557 // Find the major in the selector and get its color class 1558 $('.selectMajorItem').each(function() { 1559 var itemText = $(this).find('.selectionItemText').text().trim() 1560 if (itemText === majorName) { 1561 // Extract majorColorN class from the selector item 1562 var classes = $(this).attr('class').split(/\s+/) 1563 for (var i = 0; i < classes.length; i++) { 1564 if (classes[i].match(/^majorColor\d+$/)) {
1565 tag.addClass(classes[i]) 1566 return false // break out of .each() 1567 } 1568 } 1569 } 1570 }) 1571} 1572 1573// utility function for delaying things without wrapping in a set timeout 1574const customDelay = ms => new Promise(res => setTimeout(res, ms)); 1575 1576 1577 1578//selecting options in the pricing modal. 1579document.querySelectorAll('.newPremiumPriceOption').forEach(option => { 1580 option.addEventListener('click', () => { 1581 // Remove the selected class from all options 1582 document.querySelectorAll('.newPremiumPriceOption').forEach(el => { 1583 el.classList.remove('newPremiumPriceOptionSelected'); 1584 }); 1585 // Add the selected class to the clicked option 1586 option.classList.add('newPremiumPriceOptionSelected'); 1587 }); 1588}); 1589 1590// ============================================================================= 1591// Theme Selector Modal 1592// ============================================================================= 1593 1594var themeSelectorState = { 1595 pendingTheme: null, 1596 pendingFX: null, 1597 originalTheme: null, 1598 originalFX: null, 1599 selectedMode: 'light', // 'light', 'dark', or 'system' 1600 originalMode: 'light', 1601 previewRevocationTimeout: null, 1602 previewCountdownInterval: null, 1603 previewSecondsRemaining: 0, 1604 // The revoke to run when the preview lapses, held whether or not the clock is 1605 // currently ticking â it stops while the picker is open (see armPreviewRevocation), 1606 // so the timeout handle alone no longer tells you a preview is outstanding. 1607 pendingRevokeFn: null, 1608 submitted: false 1609}; 1610 1611function getAvailableThemes() { 1612 return window.globalViewData?.theme?.availableThemes || []; 1613} 1614 1615// Look up theme display name from meta.name, fallback to formatted ID 1616window.getThemeDisplayName = function(themeId) { 1617 const themes = getAvailableThemes(); 1618 const theme = themes.find(t => t.id === themeId); 1619 if (theme && theme.name) return theme.name; 1620 // Fallback: format ID manually 1621 return themeId.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); 1622}; 1623 1624/** 1625 * Get the display HTML for the appearance setting row. 1626 * Format: "Theme Name <greyed sun/moon/auto icon>" 1627 * @param {string} themeId - The theme ID 1628 * @param {boolean} isDark - Whether dark mode is active 1629 * @param {boolean} isSystem - Whether using system appearance mode (optional) 1630 * @returns {string} - Formatted appearance HTML 1631 */ 1632window.getAppearanceDisplayText = function(themeId, isDark, isSystem) { 1633 const themeName = window.getThemeDisplayName(themeId); 1634 let iconClass; 1635 if (isSystem) { 1636 iconClass = 'fa-circle-half-stroke'; // Auto/system icon 1637 } else { 1638 iconClass = isDark ? 'fa-moon' : 'fa-sun'; 1639 } 1640 return themeName + ' <i class="fa-solid ' + iconClass + ' appearanceVariantIcon"></i>'; 1641}; 1642 1643function getThemeSelectorPremiumThemeIds() { 1644 return getAvailableThemes().filter(t => t.isPremium).map(t => t.id); 1645} 1646 1647function getThemeSelectorPreviewTimeout() { 1648 return (window.globalViewData?.theme?.previewTimeoutSeconds || 0) * 1000; 1649} 1650 1651function isThemeSelectorThemePremium(themeId) { 1652 // "custom" is premium, but it is deliberately absent from availableThemes (that array 1653 // draws the grid, and a synthetic entry would be a phantom card) â so an includes() check 1654 // alone called it free. That reached the submit button as "Save" instead of "Preview" for 1655 // someone previewing their own theme, and Save runs the has-premium branch of 1656 // handleThemeSelectorConfirm: it cleared the revocation and dropped the countdown toast, 1657 // leaving a preview that no longer expired on screen but still reverted on reload. 1658 // Mirrors isThemePremium() in shared/themes/themeVars.php, which has the same special case. 1659 if (themeId === 'custom') return true; 1660 return getThemeSelectorPremiumThemeIds().includes(themeId); 1661} 1662 1663/** 1664 * Get the current visual variant based on selected mode. 1665 * For 'system' mode, returns the OS preference. Otherwise returns the mode itself. 1666 * @returns {string} - 'dark' or 'light' 1667 */ 1668function getCurrentVariantForMode() { 1669 const mode = themeSelectorState.selectedMode; 1670 if (mode === 'system') { 1671 const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 1672 return systemPrefersDark ? 'dark' : 'light'; 1673 } 1674 return mode === 'dark' ? 'dark' : 'light'; 1675} 1676 1677function buildThemeSelectorRowHTML(theme, isPremium) { 1678 // Get the variant to display based on selected mode 1679 const variant = getCurrentVariantForMode(); 1680 const variantData = theme[variant]; 1681 const hasPremium = typeof userHasPremiumPlanInUserData === 'function' && userHasPremiumPlanInUserData(); 1682 // hiding lock icon - we think people aren't previewing themes because of it 1683 const lockHtml = ''; 1684 1685 return ` 1686 <div class="themeSelectorRow" data-theme-id="${theme.id}" 1687 style="background-color: ${variantData.background}; border-color: ${variantData.border}; color: ${variantData.text || variantData.border};"> 1688 <div class="themeName">${theme.name}</div> 1689 <div class="themeLoading"><svg class="themeLoadingSpinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 2a10 10 0 0 1 10 10" /></svg></div> 1690 ${lockHtml} 1691 </div> 1692 `; 1693} 1694 1695/** 1696 * Trim items in a flex container to fit within 2 rows, replacing excess with "+X more" button. 1697 * @param {HTMLElement} container - The flex container with all items already rendered 1698 * @param {string} itemSelector - CSS selector for the items 1699 * @param {string} moreButtonHTML - HTML for the "+X more" button (will have count injected) 1700 * @param {number} gap - Row gap in pixels 1701 */ 1702function trimToRows(container, itemSelector, moreButtonHTML, maxRows = 2, gap = 8) { 1703 const items = Array.from(container.querySelectorAll(itemSelector)); 1704 if (items.length === 0) return; 1705 1706 const firstItem = items[0]; 1707 const itemHeight = firstItem.offsetHeight; 1708 const maxHeight = (itemHeight * maxRows) + (gap * (maxRows - 1)); 1709 1710 // Check if we're already within the allowed rows 1711 if (container.scrollHeight <= maxHeight + 2) { // +2 for tolerance 1712 return; 1713 } 1714 1715 // Create a temporary "+X more" button for measurement (using placeholder count) 1716 const btnWrapper = document.createElement('div'); 1717 btnWrapper.innerHTML = moreButtonHTML.replace('{count}', '99'); 1718 const measureBtn = btnWrapper.firstElementChild; 1719 1720 // Remove items from the end and check if we fit WITH the button 1721 let removed = 0; 1722 for (let i = items.length - 1; i >= 0; i--) { 1723 items[i].remove(); 1724 removed++; 1725 1726 // Temporarily add the button to measure 1727 container.appendChild(measureBtn); 1728 const fitsWithButton = container.scrollHeight <= maxHeight + 2; 1729 measureBtn.remove(); 1730 1731 if (i === 0 || fitsWithButton) { 1732 break; 1733 } 1734 } 1735 1736 // Add the "+X more" button with the actual count 1737 if (removed > 0) { 1738 const finalBtnWrapper = document.createElement('div'); 1739 finalBtnWrapper.innerHTML = moreButtonHTML.replace('{count}', removed); 1740 container.appendChild(finalBtnWrapper.firstElementChild); 1741 } 1742} 1743 1744function renderThemeSelectorList() { 1745 const body = document.getElementById('themeSelectorBody'); 1746 if (!body) return; 1747 1748 // Catalog order, always. Hoisting the selected theme to the front used to keep 1749 // it visible, but it also meant every pick reshuffled the grid under the 1750 // cursor, so comparing two themes moved both of them. 1751 const themes = [...getAvailableThemes()]; 1752 1753 // Render all items 1754 let html = '<div class="themeSelectorGrid">';
1755 themes.forEach(theme => { 1756 html += buildThemeSelectorRowHTML(theme, theme.isPremium); 1757 }); 1758 html += '</div>'; 1759 body.innerHTML = html; 1760 1761 // Trim to 2 rows if needed 1762 const grid = body.querySelector('.themeSelectorGrid'); 1763 trimToRows( 1764 grid, 1765 '.themeSelectorRow', 1766 '<div class="themeSelectorMore" id="themeSelectorMoreBtn">+{count} more</div>', 1767 3, 1768 10 1769 ); 1770 1771} 1772 1773function renderAllThemesSubpanel() { 1774 const container = document.getElementById('allThemesSubpanelContent'); 1775 if (!container) return; 1776 1777 const themes = getAvailableThemes(); 1778 let html = '<div class="themeSelectorGrid">'; 1779 themes.forEach(theme => { 1780 html += buildThemeSelectorRowHTML(theme, theme.isPremium); 1781 }); 1782 html += '</div>'; 1783 container.innerHTML = html; 1784} 1785 1786// ============================================================================= 1787// Base Modal Subpanel System (reusable) 1788// ============================================================================= 1789 1790/** 1791 * Slide in a subpanel within a base modal. 1792 * @param {string|HTMLElement} modal - The modal element or selector (must have .baseModalWithSubpanel class) 1793 * @param {string|HTMLElement} subpanel - The subpanel element or selector within the modal 1794 */ 1795function getAvailableFXOptions() { 1796 return window.globalViewData?.theme?.availableFX || [ 1797 { id: 'off', name: 'Off', isPremium: false } 1798 ]; 1799} 1800 1801function getThemeSelectorPremiumFXIds() { 1802 const fxOptions = getAvailableFXOptions(); 1803 return fxOptions.filter(fx => fx.isPremium).map(fx => fx.id); 1804} 1805 1806function isThemeSelectorFXPremium(fxId) { 1807 return getThemeSelectorPremiumFXIds().includes(fxId); 1808} 1809 1810// FX order is defined by getAvailableFXOptions() - same for all themes 1811function getOrderedFXOptions() { 1812 return getAvailableFXOptions(); 1813} 1814 1815function buildFXOptionHTML(fx) { 1816 const variant = getCurrentVariantForMode(); 1817 const hasPremium = typeof userHasPremiumPlanInUserData === 'function' && userHasPremiumPlanInUserData(); 1818 // hiding lock icon - we think people aren't previewing effects because of it 1819 const lockHtml = ''; 1820 const variantData = fx[variant] || {}; 1821 const bgColor = variantData.background || ''; 1822 const borderColor = variantData.border || ''; 1823 const textColor = variantData.text || variantData.border || ''; 1824 1825 // A live field for every effect that has one, drawn by the real renderer at tile 1826 // size rather than played back from fxWebp/<id>-<variant>.webp. 1827 // 1828 // The recorded art it replaced was two cuts per effect, because the tile's ground 1829 // flips with the mode and so do the effects' own colours â and it was still wrong 1830 // on any theme but the one it was captured against, went stale silently, and cost 1831 // a manual capture run per change. A field themes itself and is never out of date. 1832 // 1833 // The canvas is only markup here; startFXTiles() below brings it to life once it 1834 // has been laid out, since the engine needs a measurable box. 1835 const fieldHtml = fx.id !== 'off' 1836 ? `<canvas class="fxTileCanvas" data-fx-tile="${fx.id}"></canvas>` 1837 : ''; 1838 1839 return ` 1840 <div class="fxOption" data-fx="${fx.id}" 1841 style="background-color: ${bgColor}; border-color: ${borderColor}; color: ${textColor};"> 1842 ${fieldHtml} 1843 <span class="fxName">${fx.name}</span> 1844 ${lockHtml} 1845 </div> 1846 `; 1847} 1848 1849function renderFXOptions() { 1850 const container = document.getElementById('themeSelectorFXOptions'); 1851 if (!container) return; 1852 1853 // Catalog order, always â see renderThemeSelectorList for why nothing is hoisted. 1854 const fxOptions = getOrderedFXOptions(); 1855 1856 // Render all items 1857 let html = ''; 1858 fxOptions.forEach(fx => { 1859 html += buildFXOptionHTML(fx); 1860 }); 1861 container.innerHTML = html; 1862 1863 // Trim to 2 rows if needed 1864 trimToRows( 1865 container, 1866 '.fxOption', 1867 '<div class="fxSelectorMore" id="fxSelectorMoreBtn">+{count} more</div>', 1868 2, 1869 10 1870 ); 1871 1872 // After trimToRows, not before: it deletes the tiles that overflowed two rows. 1873 scheduleFXTiles(); 1874} 1875 1876function renderAllFXSubpanel() { 1877 const container = document.getElementById('allFXSubpanelContent'); 1878 if (!container) return; 1879 1880 const fxOptions = getOrderedFXOptions(); 1881 let html = ''; 1882 fxOptions.forEach(fx => { 1883 html += buildFXOptionHTML(fx); 1884 }); 1885 1886 container.innerHTML = html; 1887 scheduleFXTiles(); 1888} 1889 1890// ============================================================================= 1891// Live FX tiles 1892// ============================================================================= 1893// 1894// Every tile owns a field, and a field owns a requestAnimationFrame loop, so one that 1895// is abandoned runs forever holding a canvas that has left the document. Both 1896// renderers above replace innerHTML wholesale and trimToRows then deletes whatever 1897// overflowed two rows, so the only safe move is to stop every field and start again
1898// from the canvases actually in the DOM. 1899 1900var _fxTileHandles = []; 1901var _fxTileStartPending = false; 1902var _fxTileSizeObserver = null; 1903var _fxTileSizeTimer = null; 1904 1905function stopFXTiles() { 1906 _fxTileHandles.forEach(function (handle) { if (handle) handle.stop(); }); 1907 _fxTileHandles = []; 1908} 1909 1910function startFXTiles() { 1911 // shared.js owns the engine; the menu can render before it has loaded. 1912 if (typeof window.coursicleFXTile !== 'function') return; 1913 stopFXTiles(); 1914 const canvases = Array.from(document.querySelectorAll('canvas.fxTileCanvas')); 1915 canvases.forEach(function (canvas) { 1916 const handle = window.coursicleFXTile(canvas, canvas.dataset.fxTile); 1917 if (handle) _fxTileHandles.push(handle); 1918 }); 1919 _watchFXTileSizes(canvases); 1920} 1921 1922/** 1923 * Rebuild any field whose tile has changed size. 1924 * 1925 * The engine bakes the canvas dimensions into the run â `cw`/`ch` are captured once 1926 * and the tick closes over them â so a tile measured at the wrong size stays wrong. 1927 * That is not a corner case here: the picker measures its tiles the moment they are 1928 * rendered, while the modal is still sliding open, so the first measurement is 1929 * routinely a fraction of the final width. The same applies when the window resizes 1930 * or the right rail collapses. 1931 * 1932 * Guarded against its own restarts: `coursicleFXTile` sets the canvas's width/height 1933 * attributes, which changes the backing store rather than the CSS box, but the guard 1934 * means even that could not loop. 1935 */ 1936function _watchFXTileSizes(canvases) { 1937 if (typeof ResizeObserver === 'undefined') return; 1938 if (!_fxTileSizeObserver) { 1939 _fxTileSizeObserver = new ResizeObserver(function () { 1940 const stale = _fxTileHandles.some(function (h) { 1941 const built = h.canvas && h.canvas._fxBuiltCSS; 1942 if (!built) return false; 1943 const now = h.canvas.getBoundingClientRect(); 1944 return Math.abs(now.width - built.w) > 2 || Math.abs(now.height - built.h) > 2; 1945 }); 1946 if (!stale) return; 1947 clearTimeout(_fxTileSizeTimer); 1948 _fxTileSizeTimer = setTimeout(startFXTiles, 100); 1949 }); 1950 } 1951 _fxTileSizeObserver.disconnect(); 1952 canvases.forEach(function (canvas) { _fxTileSizeObserver.observe(canvas); }); 1953} 1954 1955/** 1956 * Start after the current task rather than inside it, because the compact grid and the 1957 * subpanel render back to back and coalescing turns two full restarts into one. 1958 * 1959 * A timeout rather than requestAnimationFrame, which is the obvious choice and the 1960 * wrong one: Chrome runs no animation frames at all for a tab that is not visible, so 1961 * a picker opened in a background tab would sit there with dead canvases until it was 1962 * looked at. (The same trap costs the capture harness its clock and parks a lab tile 1963 * laid out at zero width.) Nothing here needs a frame anyway â getBoundingClientRect 1964 * forces layout on its own, which is the only reason to wait. 1965 */ 1966function scheduleFXTiles() { 1967 if (_fxTileStartPending) return; 1968 _fxTileStartPending = true; 1969 setTimeout(function () { 1970 _fxTileStartPending = false; 1971 startFXTiles(); 1972 }, 0); 1973} 1974 1975function updateFXSelectedState() { 1976 document.querySelectorAll('.fxOption').forEach(option => { 1977 const matches = option.dataset.fx === themeSelectorState.pendingFX; 1978 option.classList.toggle('selected', matches); 1979 }); 1980} 1981 1982/** 1983 * Update FX option colors in place for the current variant (without re-rendering/reordering). 1984 */ 1985function updateFXVariantColors() { 1986 const variant = getCurrentVariantForMode(); 1987 const fxOptions = getAvailableFXOptions(); 1988 1989 document.querySelectorAll('.fxOption').forEach(el => { 1990 const fxId = el.dataset.fx; 1991 const fx = fxOptions.find(f => f.id === fxId); 1992 if (!fx) return; 1993 1994 const variantData = fx[variant] || {}; 1995 el.style.backgroundColor = variantData.background || ''; 1996 el.style.borderColor = variantData.border || ''; 1997 el.style.color = variantData.text || variantData.border || ''; 1998 }); 1999} 2000 2001/** 2002 * Update theme row colors in place for the current variant (without re-rendering/reordering). 2003 */
2004function updateThemeVariantColors() { 2005 const variant = getCurrentVariantForMode(); 2006 const themes = getAvailableThemes(); 2007 2008 document.querySelectorAll('.themeSelectorRow').forEach(el => { 2009 const themeId = el.dataset.themeId; 2010 const theme = themes.find(t => t.id === themeId); 2011 if (!theme) return; 2012 2013 const variantData = theme[variant]; 2014 if (!variantData) return; 2015 2016 el.style.backgroundColor = variantData.background; 2017 el.style.borderColor = variantData.border; 2018 el.style.color = variantData.text || variantData.border; 2019 }); 2020} 2021 2022function updateThemeSelectorButtonText() { 2023 if (!themeSelectorState.pendingTheme) return; 2024 2025 const hasPremium = typeof userHasPremiumPlanInUserData === 'function' && userHasPremiumPlanInUserData(); 2026 let buttonText = 'Save'; 2027 2028 if (!hasPremium) { 2029 const isThemePremium = isThemeSelectorThemePremium(themeSelectorState.pendingTheme); 2030 const isFXPremium = isThemeSelectorFXPremium(themeSelectorState.pendingFX); 2031 if (isThemePremium || isFXPremium) { 2032 buttonText = 'Preview'; 2033 } 2034 } 2035 2036 // Update all submit buttons within the theme selector modal 2037 document.querySelectorAll('#themeSelectorModal .baseModalSubmitButton').forEach(btn => { 2038 btn.textContent = buttonText; 2039 }); 2040} 2041 2042function updateThemeSelectorSelectedState() { 2043 document.querySelectorAll('.themeSelectorRow').forEach(row => { 2044 const matches = row.dataset.themeId === themeSelectorState.pendingTheme; 2045 row.classList.toggle('selected', matches); 2046 }); 2047 updateThemeSelectorButtonText(); 2048} 2049 2050function getThemeDefinition(themeId) { 2051 return getAvailableThemes().find(t => t.id === themeId); 2052} 2053 2054/** 2055 * Apply a theme with the current mode setting. 2056 * Always fetches fresh theme vars from server to avoid stale cache issues. 2057 * @param {string} theme - Theme ID 2058 * @param {function} onReady - Callback when theme vars are loaded and applied 2059 */ 2060function applyThemeSelectorTheme(theme, onReady) { 2061 const variant = getCurrentVariantForMode(); 2062 const wantsDark = variant === 'dark'; 2063 2064 // FX is independent of theme - preserve user's current selection 2065 const fxValue = themeSelectorState.pendingFX; 2066 2067 // Determine appearance mode to save 2068 const appearanceMode = themeSelectorState.selectedMode; 2069 2070 // Always update localStorage for syncing 2071 localStorage.setItem('customTheme', theme); 2072 localStorage.setItem('theme', appearanceMode); 2073 localStorage.setItem('customFX', fxValue); 2074 2075 // Update userData with theme, appearance, FX 2076 let userData = store.get('userData') || {}; 2077 if (!userData.appearancePreferences) { 2078 userData.appearancePreferences = {}; 2079 } 2080 userData.appearancePreferences.customTheme = theme; 2081 userData.appearancePreferences.appearance = appearanceMode; 2082 userData.appearancePreferences.customFX = fxValue; 2083 // Only set preview flag if selecting premium content 2084 if (isThemeSelectorThemePremium(theme) || isThemeSelectorFXPremium(fxValue)) { 2085 userData.appearancePreferences.themePreviewing = true; 2086 } 2087 store.set('userData', userData); 2088 2089 // Wipe cached server-rendered modal HTML (Week Ahead, Widget Guide, etc.). 2090 // These pages bake in theme-specific colors (background, text, icon fills) 2091 // and classic light mode uses emoji icons instead of SVGs, so stale cached 2092 // HTML would show the wrong appearance after a theme or dark/light change. 2093 window.htmlViewerCache = {}; 2094 2095 // Handle preview timer for premium content 2096 const hasPremium = typeof userHasPremiumPlanInUserData === 'function' && userHasPremiumPlanInUserData(); 2097 if (!hasPremium) { 2098 const themePremium = isThemeSelectorThemePremium(theme); 2099 const fxPremium = isThemeSelectorFXPremium(fxValue); 2100 if (themePremium) { 2101 // Owed from here; the clock waits until the picker closes 2102 armPreviewRevocation(revokeToOriginalTheme); 2103 } else if (fxPremium) { 2104 armPreviewRevocation(revokeToOriginalFX); 2105 } else { 2106 // Free content selected - nothing left to revoke 2107 clearPreviewRevocation(); 2108 } 2109 } 2110 2111 // Apply FX immediately (FX doesn't depend on theme vars) 2112 coursicleUpdateFX(fxValue); 2113 updateFXSelectedState(); 2114 2115 // Always fetch fresh theme vars from server (don't use stale cached values) 2116 // Update dark mode class directly without triggering coursicleUpdateTheme yet 2117 window.darkMode = wantsDark; 2118 if (wantsDark) { 2119 document.documentElement.classList.add('darkModeActive'); 2120 } else { 2121 document.documentElement.classList.remove('darkModeActive'); 2122 } 2123 2124 // Update dark mode toggle slider to match 2125 const darkModeToggle = document.getElementById('darkModeToggleSwitch'); 2126 if (darkModeToggle) { 2127 darkModeToggle.checked = wantsDark; 2128 } 2129 2130 // The FX previews come in a light and a dark cut, so a mode flip has to re-render 2131 // them or the tiles keep the previous mode's art on the new ground. 2132 renderFXOptions(); 2133 renderAllFXSubpanel(); 2134 updateFXSelectedState(); 2135 2136 // Sync theme selection to server (also fetches fresh vars internally) 2137 // Update selector states after server responds in case promo coupling changed values 2138 syncCustomThemeToServer(function() { 2139 updateThemeSelectorSelectedState(); 2140 updateFXSelectedState(); 2141 if (onReady) onReady(); 2142 }); 2143} 2144 2145// ============================================================================= 2146// Theme Selector Helper Functions 2147// ============================================================================= 2148 2149/** 2150 * Save the pending theme/FX selection as the new original state. 2151 * Used when confirming a selection (for free content or premium users). 2152 * Does NOT sync to server - call savePermanentlyAndSync() after this. 2153 * @param {boolean} saveTheme - Whether to save the pending theme as original 2154 * @param {boolean} saveFX - Whether to save the pending FX as original 2155 */ 2156function savePendingAsOriginal(saveTheme, saveFX) { 2157 if (saveTheme) { 2158 themeSelectorState.originalTheme = themeSelectorState.pendingTheme; 2159 themeSelectorState.originalMode = themeSelectorState.selectedMode; 2160 } 2161 if (saveFX) { 2162 themeSelectorState.originalFX = themeSelectorState.pendingFX; 2163 } 2164} 2165 2166/** 2167 * Sync theme/FX to server with the preview flag set. 2168 * The server will set themePreviewExpires based on this flag, 2169 * allowing the user to preview premium content for a limited time. 2170 */ 2171function syncWithPreviewFlag() { 2172 let userData = store.get('userData') || {}; 2173 if (!userData.appearancePreferences) { 2174 userData.appearancePreferences = {}; 2175 } 2176 userData.appearancePreferences.themePreviewing = true; 2177 store.set('userData', userData); 2178 syncCustomThemeToServer(); 2179} 2180 2181/** 2182 * Save theme/FX permanently (no preview) and sync to server. 2183 * Clears any existing preview timestamp and refreshes global view data. 2184 * Used for: premium users, or when selecting free content. 2185 *
2186 * Note: lastAuthorizedTheme is tracked server-side in validatePremiumPreview(). 2187 * When user syncs a free theme (or has premium), server stores it as lastAuthorizedTheme. 2188 * This ensures we have a safe fallback when premium preview expires. 2189 */ 2190function savePermanentlyAndSync(callback) { 2191 clearThemePreviewTimestamp(); 2192 syncCustomThemeToServer(callback); 2193} 2194 2195/** 2196 * Show the premium preview toast with countdown timer. 2197 * @param {number} seconds - Number of seconds for the preview 2198 */ 2199function showPremiumPreviewToast(seconds) { 2200 const toast = document.getElementById('premiumPreviewToast'); 2201 const secondsEl = document.getElementById('premiumPreviewToastSeconds'); 2202 const labelEl = document.getElementById('premiumPreviewToastSecondsLabel'); 2203 if (!toast || !secondsEl) return; 2204 2205 themeSelectorState.previewSecondsRemaining = seconds; 2206 secondsEl.textContent = seconds; 2207 if (labelEl) labelEl.textContent = seconds === 1 ? 'second' : 'seconds'; 2208 toast.classList.add("show"); 2209 if (window.isPWA) { 2210 toast.classList.toggle("aboveBannerAd", window.currentTab === "schedule" && document.getElementById("calendarBannerNote")); 2211 if (typeof adjustPWAMenuButtonHeights === "function") { adjustPWAMenuButtonHeights(); } 2212 } 2213 if (themeSelectorState.previewCountdownInterval) { 2214 clearInterval(themeSelectorState.previewCountdownInterval); 2215 } 2216 2217 // Start countdown timer 2218 themeSelectorState.previewCountdownInterval = setInterval(function() { 2219 themeSelectorState.previewSecondsRemaining--; 2220 if (themeSelectorState.previewSecondsRemaining <= 0) { 2221 clearInterval(themeSelectorState.previewCountdownInterval); 2222 themeSelectorState.previewCountdownInterval = null; 2223 secondsEl.textContent = '0'; 2224 if (labelEl) labelEl.textContent = 'seconds'; 2225 } else { 2226 secondsEl.textContent = themeSelectorState.previewSecondsRemaining; 2227 if (labelEl) labelEl.textContent = themeSelectorState.previewSecondsRemaining === 1 ? 'second' : 'seconds'; 2228 } 2229 }, 1000); 2230} 2231 2232/** 2233 * Hide the premium preview toast and stop its countdown. 2234 * 2235 * There used to be a keepCountdown flag for the case where the picker covered 2236 * the toast â the seconds went on ticking out of sight. Nothing needs that now: 2237 * opening the picker stops the clock outright rather than hiding its face. 2238 */ 2239function hidePremiumPreviewToast() { 2240 const toast = document.getElementById('premiumPreviewToast'); 2241 if (toast) { 2242 toast.classList.remove('show'); 2243 } 2244 2245 if (themeSelectorState.previewCountdownInterval) { 2246 clearInterval(themeSelectorState.previewCountdownInterval); 2247 themeSelectorState.previewCountdownInterval = null; 2248 } 2249 themeSelectorState.previewSecondsRemaining = 0; 2250 // Move add button back to normal position 2251 if (window.isPWA && typeof adjustPWAMenuButtonHeights === "function") { 2252 adjustPWAMenuButtonHeights(); 2253 } 2254} 2255 2256/** 2257 * Is the theme picker on screen? PWA and web dismiss differently, so the two 2258 * need separate checks: baseModalShow isn't cleaned up when a slideable modal 2259 * goes away. The "Make your Own" lab is a subpanel of this modal, so editing a 2260 * custom theme counts as the picker being open too. 2261 */ 2262function isThemeSelectorModalOpen() { 2263 return window.isPWA 2264 ? $('#themeSelectorModal').closest('.slideable-modal-visible').length > 0 2265 : !!document.getElementById('themeSelectorModal')?.classList.contains('baseModalShow'); 2266} 2267 2268/** 2269 * Record that a premium preview is owed, and start its clock if the picker is 2270 * already closed. 2271 * 2272 * The countdown is time spent *looking at* the preview, not time spent picking 2273 * it. It used to start the moment you tapped a premium theme and keep running 2274 * underneath the open modal, so choosing a color for half a minute meant the 2275 * page reverted while you were still choosing â and reopening the picker to 2276 * adjust something never bought you any more time. Now the clock only runs 2277 * while the picker is closed, and closing it always gives you the full window. 2278 * 2279 * @param {function} revokeFn - How to revert (revokeToOriginalTheme or revokeToOriginalFX) 2280 */ 2281function armPreviewRevocation(revokeFn) { 2282 themeSelectorState.pendingRevokeFn = revokeFn; 2283 if (isThemeSelectorModalOpen()) { 2284 suspendPreviewRevocation(); 2285 } else { 2286 startPreviewRevocationTimer(revokeFn); 2287 } 2288} 2289 2290/** 2291 * Stop the clock but keep owing the preview â what happens when the picker 2292 * opens. The selection stays applied; it simply isn't counting down. 2293 */ 2294function suspendPreviewRevocation() { 2295 if (themeSelectorState.previewRevocationTimeout) { 2296 clearTimeout(themeSelectorState.previewRevocationTimeout); 2297 themeSelectorState.previewRevocationTimeout = null; 2298 } 2299 hidePremiumPreviewToast(); 2300} 2301 2302/** Stop the clock and drop the preview entirely (free content selected, or it already lapsed). */ 2303function clearPreviewRevocation() { 2304 suspendPreviewRevocation(); 2305 themeSelectorState.pendingRevokeFn = null; 2306} 2307 2308/** 2309 * Start the revocation clock and show the countdown toast. Only ever called 2310 * with the picker closed â armPreviewRevocation and the modal-close handler are 2311 * the two ways in â so it always runs the full window rather than a remainder. 2312 * @param {function} revokeFn - Function to call to revert (revokeToOriginalTheme or revokeToOriginalFX) 2313 */ 2314function startPreviewRevocationTimer(revokeFn) { 2315 // Clear any existing timeout to prevent stacking 2316 if (themeSelectorState.previewRevocationTimeout) { 2317 clearTimeout(themeSelectorState.previewRevocationTimeout); 2318 } 2319 2320 themeSelectorState.pendingRevokeFn = revokeFn; 2321 2322 const timeoutMs = getThemeSelectorPreviewTimeout(); 2323 const seconds = Math.ceil(timeoutMs / 1000); 2324 showPremiumPreviewToast(seconds); 2325 2326 themeSelectorState.previewRevocationTimeout = setTimeout(function() { 2327 hidePremiumPreviewToast(); 2328 revokeFn(); 2329 themeSelectorState.previewRevocationTimeout = null; 2330 themeSelectorState.pendingRevokeFn = null; 2331 // Open premium when preview expires (if not already open and theme selector not open) 2332 let premiumAlreadyOpen = window.isPWA 2333 ? (window.currentTab === 'premium' || $('#newPremiumMod
2333al').closest('.slideable-modal-visible').length > 0) 2334 : ($('#newPremiumModal.baseModalShow').length > 0); 2335 if (!premiumAlreadyOpen && !isThemeSelectorModalOpen()) { 2336 if (typeof displayWallIfNeededAndReturnBlockingStatus === 'function') { 2337 displayWallIfNeededAndReturnBlockingStatus('themePreview') 2338 } 2339 } 2340 }, timeoutMs); 2341 2342 // The window has just started running, so this is the moment the user actually 2343 // begins watching. Ocean's shark is normally 25s away â too late to be seen 2344 // inside the window â so a preview brings its first pass in to 5s from here. 2345 // No-op for every other effect, and never reached outside a preview. 2346 if (typeof window.coursicleFXPreviewClockStarted === 'function') { 2347 window.coursicleFXPreviewClockStarted(); 2348 } 2349} 2350 2351// ============================================================================= 2352// Theme Selector Revocation Functions 2353// ============================================================================= 2354 2355/** 2356 * Revert to the original theme, mode, and FX. 2357 * Called when premium preview expires or user cancels. 2358 * Applies instant visual changes, then lets server validate via getUserData. 2359 */ 2360function revokeToOriginalTheme() { 2361 // Keep current appearance mode (light/dark/system) - it's free for all users 2362 // Only revert theme and FX 2363 const currentMode = themeSelectorState.selectedMode; 2364 2365 // Determine visual variant for current mode 2366 let wantsDark; 2367 if (currentMode === 'system') { 2368 const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 2369 wantsDark = systemPrefersDark; 2370 } else { 2371 wantsDark = currentMode === 'dark'; 2372 } 2373 2374 // Apply visual changes for instant paint - theme and FX only 2375 // Note: we intentionally don't update localStorage here so applyServerThemePreferences 2376 // will repaint with fresh server data (in case theme colors were updated server-side) 2377 window.coursicleUpdateTheme(themeSelectorState.originalTheme); 2378 coursicleUpdateFX(themeSelectorState.originalFX); 2379 2380 // Update the settings row label 2381 const appearanceSetting = document.querySelector('#appearanceSetting .settingsValue'); 2382 if (appearanceSetting) { 2383 appearanceSetting.innerHTML = window.getAppearanceDisplayText(themeSelectorState.originalTheme, wantsDark, currentMode === 'system'); 2384 } 2385 2386 // Get new view data from server, then re-render modal if open 2387 // Let server validate and reconcile (don't push via setUserData) 2388 getUserData(store.get('uuid'), null, null, null, function() { 2389 getGlobalViewDataFromServer(function() { 2390 window.refreshThemeSelectorModalState(); 2391 }); 2392 }); 2393} 2394 2395/** 2396 * Revert only the FX to original, keeping the current theme. 2397 * Used when user selected a free theme with premium FX. 2398 * Applies instant visual change, then lets server validate via getUserData. 2399 */ 2400function revokeToOriginalFX() { 2401 // Apply visual change for instant paint 2402 // Note: we intentionally don't update localStorage here so applyServerThemePreferences 2403 // will repaint with fresh server data (in case FX was updated server-side) 2404 coursicleUpdateFX(themeSelectorState.originalFX); 2405 2406 // Let server validate and reconcile 2407 getUserData(store.get('uuid'), null, null, null, function() { 2408 getGlobalViewDataFromServer(function() { 2409 window.refreshThemeSelectorModalState(); 2410 }); 2411 }); 2412} 2413 2414function clearThemePreviewTimestamp() { 2415 // Clear preview timestamp when saving permanently 2416 let userData = store.get('userData') || {}; 2417 if (userData.appearancePreferences) { 2418 delete userData.appearancePreferences.themePreviewExpires; 2419 } 2420 store.set('userData', userData); 2421} 2422 2423function updateModeSelectedState() { 2424 document.querySelectorAll('.modeOption').forEach(option => { 2425 const matches = option.dataset.mode === themeSelectorState.selectedMode; 2426 option.classList.toggle('selected', matches); 2427 }); 2428} 2429 2430/** 2431 * The appearance mode the modal should render with. Usually the stored mode, 2432 * but an effect's prefersDark can flip the page without touching storage 2433 * (one-time nudge, deliberately unsaved) â when the stored mode's darkness 2434 * disagrees with what's actually on screen (window.darkMode), render to match 2435 * the screen. Every open/refresh path MUST use this instead of reading 2436 * getAppearanceMode() directly, or tiles show the wrong variant after an 2437 * FX-forced switch. 2438 */ 2439function getEffectiveAppearanceMode() { 2440 const stored = window.getAppearanceMode(); 2441 const sysDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; 2442 const storedIsDark = stored === 'dark' || (stored === 'system' && sysDark); 2443 if (storedIsDark === !!window.darkMode) return stored; 2444 return window.darkMode ? 'dark' : 'light'; 2445} 2446 2447/** 2448 * Refresh the theme selector modal state from store values.
2449 * Called when external changes (e.g., purchase completion) update prefs. 2450 * Only updates if the modal is currently visible. 2451 */ 2452window.refreshThemeSelectorModalState = function() { 2453 const modal = document.getElementById('themeSelectorModal'); 2454 if (!modal || !modal.classList.contains('baseModalShow')) return; 2455 2456 // Update internal state from localStorage 2457 themeSelectorState.pendingTheme = localStorage.getItem('customTheme') || 'classic'; 2458 themeSelectorState.pendingFX = localStorage.getItem('customFX') || 'off'; 2459 themeSelectorState.selectedMode = getEffectiveAppearanceMode(); 2460 themeSelectorState.originalTheme = themeSelectorState.pendingTheme; 2461 themeSelectorState.originalFX = themeSelectorState.pendingFX; 2462 themeSelectorState.originalMode = themeSelectorState.selectedMode; 2463 2464 // Re-render and update UI 2465 renderThemeSelectorList(); 2466 renderFXOptions(); 2467 updateModeSelectedState(); 2468 updateThemeSelectorSelectedState(); 2469 updateFXSelectedState(); 2470}; 2471 2472function openThemeSelectorModal() { 2473 // Reset subpanel state 2474 baseModalSubpanelSlideOut('#themeSelectorModal'); 2475 2476 // Check if there's an active preview - if so, preserve the original state. 2477 // Reads the owed revoke rather than the timeout handle: the clock is stopped 2478 // while this modal is open, so the handle is null for the whole of it. 2479 const hasActivePreview = themeSelectorState.pendingRevokeFn !== null; 2480 2481 // Capture current state (only if no active preview) - so that when preview ends we go to unpreviewed state and previews dont stack 2482 if (!hasActivePreview) { 2483 themeSelectorState.originalTheme = localStorage.getItem('customTheme') || 'classic'; 2484 themeSelectorState.originalFX = localStorage.getItem('customFX') || 'off'; 2485 } 2486 2487 // Get current appearance mode (reconciled with the on-screen state in 2488 // case an effect flipped dark mode without persisting it) 2489 const appearanceMode = getEffectiveAppearanceMode(); 2490 if (!hasActivePreview) { 2491 themeSelectorState.originalMode = appearanceMode; 2492 } 2493 themeSelectorState.selectedMode = appearanceMode; 2494 2495 // Set pending to current (from localStorage, which may be preview state) 2496 themeSelectorState.pendingTheme = localStorage.getItem('customTheme') || 'classic'; 2497 themeSelectorState.pendingFX = localStorage.getItem('customFX') || 'off'; 2498 2499 // Render lists (depends on selectedMode for variant display) 2500 renderThemeSelectorList(); 2501 renderFXOptions(); 2502 2503 updateModeSelectedState(); 2504 updateThemeSelectorSelectedState(); 2505 updateFXSelectedState(); 2506 2507 // Stop the clock while the picker is up. Whatever is owed stays owed and 2508 // gets a fresh window when this closes. 2509 suspendPreviewRevocation(); 2510 2511 // Show the modal (fullPWAHeight=false for shorter modal on mobile) 2512 showBaseModal('#themeSelectorModal', false, null, false); 2513} 2514 2515// Keep the open theme modal in sync when an effect's light/dark preference 2516// flips the page appearance (the flip bypasses the modal's mode control, so 2517// the theme/FX variant swatches would otherwise show the stale variant) 2518window.syncThemeSelectorAppearanceUI = function() { 2519 const modal = document.getElementById('themeSelectorModal'); 2520 if (!modal || !modal.classList.contains('baseModalShow')) return; 2521 themeSelectorState.selectedMode = getEffectiveAppearanceMode(); 2522 updateModeSelectedState(); 2523 updateThemeVariantColors(); 2524 updateFXVariantColors(); 2525}; 2526 2527// Mode option click handler 2528document.addEventListener('click', function(e) { 2529 const modeOption = e.target.closest('.modeOption'); 2530 if (!modeOption) return; 2531 2532 const mode = modeOption.dataset.mode; 2533 if (!mode || mode === themeSelectorState.selectedMode) return; 2534 2535 themeSelectorState.selectedMode = mode; 2536 updateModeSelectedState(); 2537 2538 // Update colors in place (without re-rendering/reordering) 2539 updateThemeVariantColors(); 2540 updateFXVariantColors(); 2541 2542 // Apply the current theme with new mode 2543 if (themeSelectorState.pendingTheme) { 2544 applyThemeSelectorTheme(themeSelectorState.pendingTheme, function() { 2545 updateThemeSelectorSelectedState(); 2546 }); 2547 } 2548}); 2549 2550// Click on theme row to select and apply preview 2551document.addEventListener('click', function(e) {
2552 const row = e.target.closest('.themeSelectorRow'); 2553 if (!row) return; 2554 2555 const theme = row.dataset.themeId; 2556 if (!theme) return; 2557 2558 // Ignore clicks on already-selected theme 2559 if (theme === themeSelectorState.pendingTheme) return; 2560 2561 themeSelectorState.pendingTheme = theme; 2562 2563 // Update FX colors in place (reordering only happens on modal open/back button) 2564 updateFXVariantColors(); 2565 2566 // Clear loading state from all rows and update selected immediately 2567 document.querySelectorAll('.themeSelectorRow').forEach(r => { 2568 r.classList.remove('loading'); 2569 }); 2570 updateThemeSelectorSelectedState(); 2571 2572 // Always show spinner, apply theme only after spinner finishes 2573 row.classList.add('loading'); 2574 var loadingStartTime = Date.now(); 2575 var networkDone = false; 2576 var minTimeDone = false; 2577 2578 function finishLoading() { 2579 if (networkDone && minTimeDone) { 2580 row.classList.remove('loading'); 2581 // Theme already applied by applyServerThemePreferences 2582 } 2583 } 2584 2585 applyThemeSelectorTheme(theme, function() { 2586 // Theme vars are ready (and applied by applyServerThemePreferences) 2587 // Wait for minimum spinner time before removing loading state 2588 networkDone = true; 2589 var elapsed = Date.now() - loadingStartTime; 2590 var remaining = 750 - elapsed; 2591 if (remaining > 0) { 2592 setTimeout(function() { 2593 minTimeDone = true; 2594 finishLoading(); 2595 }, remaining); 2596 } else { 2597 minTimeDone = true; 2598 finishLoading(); 2599 } 2600 }); 2601}); 2602 2603// Click on FX option to select and apply preview 2604document.addEventListener('click', function(e) { 2605 const option = e.target.closest('.fxOption'); 2606 if (!option) return; 2607 2608 const fx = option.dataset.fx; 2609 if (!fx) return; 2610 2611 // Ignore clicks on already-selected FX 2612 if (fx === themeSelectorState.pendingFX) return; 2613 2614 themeSelectorState.pendingFX = fx; 2615 localStorage.setItem('customFX', fx); 2616 2617 // Update userData with new FX and sync to server 2618 let userData = store.get('userData') || {}; 2619 if (!userData.appearancePreferences) { 2620 userData.appearancePreferences = {}; 2621 } 2622 userData.appearancePreferences.customFX = fx; 2623 // Only set preview flag if selecting premium FX 2624 const fxPremium = isThemeSelectorFXPremium(fx); 2625 if (fxPremium) { 2626 userData.appearancePreferences.themePreviewing = true; 2627 } 2628 store.set('userData', userData); 2629 2630 // Handle preview timer for premium FX 2631 const hasPremium = typeof userHasPremiumPlanInUserData === 'function' && userHasPremiumPlanInUserData(); 2632 if (!hasPremium && fxPremium) { 2633 // Owed from here; the clock waits until the picker closes 2634 armPreviewRevocation(revokeToOriginalFX); 2635 } else if (!fxPremium) { 2636 // Free FX selected - drop the preview only if the theme is free too 2637 const themePremium = isThemeSelectorThemePremium(themeSelectorState.pendingTheme); 2638 if (!themePremium && themeSelectorState.pendingRevokeFn) { 2639 clearPreviewRevocation(); 2640 } 2641 } 2642 2643 // Apply FX immediately for responsiveness 2644 coursicleUpdateFX(fx); 2645 updateFXSelectedState(); 2646 updateThemeSelectorButtonText(); 2647 2648 2649 // Sync to server - callback updates selector UI in case server changed values (promo coupling) 2650 syncCustomThemeToServer(function() { 2651 updateThemeSelectorSelectedState(); 2652 updateFXSelectedState(); 2653 updateThemeSelectorButtonText(); 2654 }); 2655}); 2656 2657/** 2658 * Handle theme/FX selection confirmation (submit button click). 2659 * Since we now sync on every click, this just updates UI and finalizes state. 2660 * - Premium user or free content: Save as permanent (clear preview flag) 2661 * - Non-premium with premium content: Timer already running, just update UI 2662 */ 2663function handleThemeSelectorConfirm() { 2664 if (!themeSelectorState.pendingTheme) return; 2665 2666 const hasPremium = typeof userHasPremiumPlanInUserData === 'function' && userHasPremiumPlanInUserData(); 2667 const themePremium = isThemeSelectorThemePremium(themeSelectorState.pendingTheme); 2668 const fxPremium = isThemeSelectorFXPremium(themeSelectorState.pendingFX); 2669 2670 // Determine visual state for the settings row 2671 const variant = getCurrentVariantForMode(); 2672 const wantsDark = variant === 'dark'; 2673 const isSystemMode = themeSelectorState.selectedMode === 'system'; 2674 2675 // Update settings row text to show current selection
2676 const appearanceSetting = document.querySelector('#appearanceSetting .settingsValue'); 2677 if (appearanceSetting) { 2678 appearanceSetting.innerHTML = window.getAppearanceDisplayText( 2679 themeSelectorState.pendingTheme, 2680 wantsDark, 2681 isSystemMode 2682 ); 2683 } 2684 2685 // Premium user or free content: save permanently (clears preview flag). 2686 // Nothing is owed any more â drop any revoke left over from an earlier 2687 // selection, or from premium being bought while this modal was open. 2688 if (hasPremium || (!themePremium && !fxPremium)) { 2689 clearPreviewRevocation(); 2690 savePendingAsOriginal(true, true); 2691 savePermanentlyAndSync(); 2692 } 2693 // Non-premium with premium content: timer already running, just update original state 2694 else { 2695 if (themePremium) { 2696 // Premium theme - don't save as original (will revert when timer expires) 2697 } else if (fxPremium) { 2698 // Free theme + premium FX - save theme as original 2699 savePendingAsOriginal(true, false); 2700 } 2701 } 2702} 2703 2704// Submit button click handler (main and subpanel buttons) 2705document.addEventListener('click', function(e) { 2706 const btn = e.target.closest('#themeSelectorModal .baseModalSubmitButton'); 2707 if (btn) { 2708 themeSelectorState.submitted = true; 2709 handleThemeSelectorConfirm(); 2710 hideBaseModal('#themeSelectorModal'); 2711 } 2712}); 2713 2714/** 2715 * Handle the picker closing, however it closed. 2716 * 2717 * This is where a preview's clock actually starts. Bound to the modal-hidden 2718 * event so every route out lands here â the Preview button, the back arrow, 2719 * a click on the backdrop, a PWA swipe down. Previously the button and the 2720 * backdrop behaved differently, which is why a preview could expire behind an 2721 * open modal for someone who dismissed it the other way. 2722 */ 2723function handleThemeSelectorModalClose() { 2724 // The modal is hidden, not destroyed, so its canvases stay in the document â and 2725 // a field left running is a requestAnimationFrame loop drawing a dozen effects 2726 // nobody can see, for the rest of the session. 2727 stopFXTiles(); 2728 2729 if (themeSelectorState.pendingRevokeFn) { 2730 // Re-arm the server's half of the window as the client's starts. 2731 // 2732 // The server stamps themePreviewExpires off themePreviewing (see 2733 // validatePremiumPreview), and the client used to raise that flag when you 2734 // *picked* a theme. Stopping only the client's clock therefore fixed half 2735 // the problem: sit in the picker past the window and the server quietly 2736 // expired the preview, so the next getUserData reverted the stored theme 2737 // while the page still showed it and a countdown was still running. Both 2738 // clocks have to start at the same moment, which is this one. 2739 // 2740 // Re-asserting the pending selection first also repairs a revert the 2741 // server may already have written underneath the open picker. 2742 if (themeSelectorState.pendingTheme) { 2743 localStorage.setItem('customTheme', themeSelectorState.pendingTheme); 2744 } 2745 if (themeSelectorState.pendingFX) { 2746 localStorage.setItem('customFX', themeSelectorState.pendingFX); 2747 } 2748 let userData = store.get('userData') || {}; 2749 if (!userData.appearancePreferences) { 2750 userData.appearancePreferences = {}; 2751 } 2752 userData.appearancePreferences.themePreviewing = true; 2753 store.set('userData', userData); 2754 syncCustomThemeToServer(); 2755 2756 startPreviewRevocationTimer(themeSelectorState.pendingRevokeFn); 2757 } 2758 2759 // Reset submitted flag 2760 themeSelectorState.submitted = false; 2761} 2762 2763// Click on "+X more" to open all themes subpanel 2764document.addEventListener('click', function(e) { 2765 if (e.target.id === 'themeSelectorMoreBtn' || e.target.closest('#themeSelectorMoreBtn')) { 2766 renderAllThemesSubpanel(); 2767 updateThemeSelectorSelectedState(); 2768 baseModalSubpanelSlideIn('#themeSelectorModal', '#allThemesSubpanel'); 2769 } 2770}); 2771 2772// Click on "+X more" to open all FX subpanel 2773document.addEventListener('click', function(e) { 2774 if (e.target.id === 'fxSelectorMoreBtn' || e.target.closest('#fxSelectorMoreBtn')) { 2775 renderAllFXSubpanel(); 2776 updateFXSelectedState(); 2777 baseModalSubpanelSlideIn('#themeSelectorModal', '#allFXSubpanel'); 2778 } 2779}); 2780 2781// Click on subpanel back button to close 2782document.addEventListener('click', function(e) { 2783 const backBtn = e.target.closest('.baseModalSubpanelBack'); 2784 if (!backBtn) return; 2785 2786 const modal = backBtn.closest('.baseModalWithSubpanel'); 2787 if (modal) { 2788 // Re-render lists with selected items at front before sliding out 2789 if (modal.id === 'themeSelectorModal') { 2790 const subpanel = backBtn.closest('.baseModalSubpanel'); 2791 if (subpanel && subpanel.id === 'allThemesSubpanel') { 2792 renderThemeSelectorList(); 2793 updateThemeSelectorSelectedState(); 2794 renderFXOptions(); 2795 updateFXSelectedState(); 2796 } else if (subpanel && subpanel.id === 'allFXSubpanel') { 2797 renderFXOptions(); 2798 updateFXSelectedState(); 2799 } 2800 } 2801 baseModalSubpanelSlideOut(modal); 2802 } 2803}); 2804 2805// Bind modal close events when jQuery is ready (web and PWA use different event names) 2806document.addEventListener('DOMContentLoaded', function() { 2807 $(document).on('themeSelectorModalBaseModalHidden', handleThemeSelectorModalClose); // web 2808 $(document).on('themeSelectorModal.modalHidden', handleThemeSelectorModalClose); // PWA 2809}); 2810 2811// Clicks on the preview toast. "Get Premium" goes to the paywall; anywhere else on 2812// the toast reopens the picker, since the toast is the only thing on screen pointing 2813// back at the theme you're previewing. 2814document.addEventListener('click', function(e) { 2815 if (e.target.id === 'premiumPreviewToastButton' || e.target.closest('#premiumPreviewToastButton')) { 2816 // Hide the toast but keep revocation timer running 2817 hidePremiumPreviewToast(); 2818 // Open the premium modal
2819 if (typeof spawnPremiumModal === 'function') { 2820 displayWallIfNeededAndReturnBlockingStatus('themePreview') 2821 } 2822 return; 2823 } 2824 if (e.target.id === 'premiumPreviewToast' || e.target.closest('#premiumPreviewToast')) { 2825 // Reopen the picker. It stops the clock itself (suspendPreviewRevocation), 2826 // which also takes the toast down, and closing it starts a fresh window. 2827 openThemeSelectorModal(); 2828 } 2829});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.