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