PageSourceSearch

https://www.coursicle.com/shared/loginless/ui/menu.js?new54

js coursicle.com collected 2026-10-01 10:17:24 UTC 108,674 bytes, 2,812 lines download raw bytes

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.