mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-13 15:14:40 +00:00
Merge pull request #447 from StroepWafel/Popout-TOTM
super clean popout for TOTM + PFP caching
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
|
||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||
|
||||
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||
@@ -16,6 +17,7 @@ export type CloudUser = {
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
pfpUrl?: string;
|
||||
pfpHash?: string | null;
|
||||
admin_level?: number;
|
||||
};
|
||||
|
||||
@@ -201,6 +203,8 @@ class CloudAuthService {
|
||||
}
|
||||
|
||||
public async logout(): Promise<void> {
|
||||
const userId = this._state.user?.id;
|
||||
if (userId) await clearCloudPfpCache(userId);
|
||||
await browser.storage.local.remove([
|
||||
STORAGE_KEYS.accessToken,
|
||||
STORAGE_KEYS.refreshToken,
|
||||
|
||||
@@ -5,11 +5,13 @@ import { closePopup } from "./PopupManager";
|
||||
import { getApiBase } from "../DevApiBase";
|
||||
import { openThemeStoreWithHighlight } from "../openThemeStoreWithHighlight";
|
||||
import { cloudAuth } from "../CloudAuth";
|
||||
import type { Theme } from "@/interface/types/Theme";
|
||||
import {
|
||||
buildModalHeroSlides,
|
||||
normalizeStoreTheme,
|
||||
} from "@/interface/utils/themeStoreFlavours";
|
||||
import { attachPopupMediaFullscreen } from "./attachPopupMediaFullscreen";
|
||||
|
||||
/**
|
||||
* Server response shape from `/api/theme-of-the-month/current`.
|
||||
* Hero image is resolved client-side via the theme store API when `theme_id` is set.
|
||||
*/
|
||||
export interface ThemeOfTheMonthEntry {
|
||||
id: string;
|
||||
month: string;
|
||||
@@ -22,12 +24,6 @@ export interface ThemeOfTheMonthEntry {
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current month's Theme of the Month entry from the API.
|
||||
* Returns `null` when no entry is configured for this month, or when the
|
||||
* request fails (we never want a network problem to block other startup
|
||||
* popups).
|
||||
*/
|
||||
export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | null> {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/api/theme-of-the-month/current`, {
|
||||
@@ -45,7 +41,6 @@ export async function fetchThemeOfTheMonth(): Promise<ThemeOfTheMonthEntry | nul
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the current month's entry should appear in the startup queue. */
|
||||
export function shouldShowThemeOfTheMonth(entry: ThemeOfTheMonthEntry | null): boolean {
|
||||
if (!entry || settingsState.themeOfTheMonthDisabled) return false;
|
||||
return settingsState.themeOfTheMonthDismissedMonth !== entry.month;
|
||||
@@ -67,7 +62,6 @@ function formatMonthLabel(month: string): string {
|
||||
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
|
||||
}
|
||||
|
||||
/** Same priority as the theme store: marquee, then cover/banner. */
|
||||
function heroUrlFromStoreTheme(theme: {
|
||||
marqueeImage?: string | null;
|
||||
coverImage?: string | null;
|
||||
@@ -76,41 +70,484 @@ function heroUrlFromStoreTheme(theme: {
|
||||
return url || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads hero image for a store theme via the background script (same path as
|
||||
* {@link ThemeSelector} / theme store detail fetches).
|
||||
*/
|
||||
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
|
||||
export async function fetchThemeStoreTheme(themeId: string): Promise<Theme | null> {
|
||||
try {
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
const res = (await browser.runtime.sendMessage({
|
||||
type: "fetchThemeDetails",
|
||||
themeId,
|
||||
token: token ?? undefined,
|
||||
})) as { success?: boolean; data?: { theme?: { marqueeImage?: string; coverImage?: string } } };
|
||||
})) as { success?: boolean; data?: { theme?: Record<string, unknown> } };
|
||||
|
||||
if (!res?.success || !res?.data?.theme) return null;
|
||||
return heroUrlFromStoreTheme(res.data.theme);
|
||||
return normalizeStoreTheme(res.data.theme);
|
||||
} catch (err) {
|
||||
console.warn("[ThemeOfTheMonth] Failed to fetch theme store image:", err);
|
||||
console.warn("[ThemeOfTheMonth] Failed to fetch theme store details:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Linked theme store image, else optional admin-uploaded cover. */
|
||||
async function resolvePopupHeroImageUrl(entry: ThemeOfTheMonthEntry): Promise<string | null> {
|
||||
const themeId = entry.theme_id ?? entry.theme?.id;
|
||||
if (themeId) {
|
||||
const fromStore = await fetchThemeStoreHeroImage(themeId);
|
||||
if (fromStore) return fromStore;
|
||||
}
|
||||
const fallback = entry.cover_image?.trim();
|
||||
return fallback || null;
|
||||
export async function fetchThemeStoreHeroImage(themeId: string): Promise<string | null> {
|
||||
const theme = await fetchThemeStoreTheme(themeId);
|
||||
return theme ? heroUrlFromStoreTheme(theme) : null;
|
||||
}
|
||||
|
||||
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
|
||||
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
|
||||
type PopupGallerySlide = { imageUrl: string; caption: string };
|
||||
|
||||
function buildPopupGallerySlides(
|
||||
entry: ThemeOfTheMonthEntry,
|
||||
storeTheme: Theme | null,
|
||||
heroUrl: string | null,
|
||||
): PopupGallerySlide[] {
|
||||
if (storeTheme) {
|
||||
return buildModalHeroSlides(storeTheme).filter((s) => s.imageUrl.trim());
|
||||
}
|
||||
if (heroUrl) {
|
||||
return [{ imageUrl: heroUrl, caption: entry.title }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Store theme identity on the hero — not the TOTM notice copy in the body. */
|
||||
function renderHeroEmbossHtml(storeTheme: Theme, entry: ThemeOfTheMonthEntry): string {
|
||||
const name = (storeTheme.name || entry.title).trim();
|
||||
const author = storeTheme.author?.trim() ?? "";
|
||||
const storeDescription = storeTheme.description?.trim() ?? "";
|
||||
const entryDesc = entry.description.trim();
|
||||
const showDescription =
|
||||
storeDescription.length > 0 && storeDescription !== entryDesc;
|
||||
const flavourCount = storeTheme.flavours?.length ?? 0;
|
||||
const flavourLine =
|
||||
flavourCount > 0
|
||||
? `${flavourCount} colour variant${flavourCount === 1 ? "" : "s"}`
|
||||
: "";
|
||||
|
||||
if (!name && !author && !showDescription && !flavourLine) return "";
|
||||
|
||||
return `
|
||||
<div class="themeOfTheMonthCardHeroEmboss">
|
||||
<div class="themeOfTheMonthCardHeroEmbossScrim" aria-hidden="true"></div>
|
||||
<div class="themeOfTheMonthCardHeroEmbossContent">
|
||||
<h3 class="themeOfTheMonthCardHeroEmbossTitle">${escapeHTML(name)}</h3>
|
||||
${author ? `<p class="themeOfTheMonthCardHeroEmbossAuthor">By ${escapeHTML(author)}</p>` : ""}
|
||||
${
|
||||
showDescription
|
||||
? `<p class="themeOfTheMonthCardHeroEmbossDescription">${escapeHTML(storeDescription).replace(/\n/g, "<br />")}</p>`
|
||||
: ""
|
||||
}
|
||||
${flavourLine ? `<p class="themeOfTheMonthCardHeroEmbossVariants">${escapeHTML(flavourLine)}</p>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGallerySlidesHtml(slides: PopupGallerySlide[]): string {
|
||||
if (slides.length === 0) return "";
|
||||
const slidesHtml = slides
|
||||
.map(
|
||||
(s, i) => `
|
||||
<figure class="themeOfTheMonthCardGallerySlide" data-slide="${i}">
|
||||
<img src="${escapeHTML(s.imageUrl)}" alt="${escapeHTML(s.caption)}" loading="lazy" />
|
||||
<figcaption>${escapeHTML(s.caption)}</figcaption>
|
||||
</figure>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
const nav =
|
||||
slides.length > 1
|
||||
? `
|
||||
<button type="button" class="themeOfTheMonthCardGalleryPrev" aria-label="Previous image">‹</button>
|
||||
<button type="button" class="themeOfTheMonthCardGalleryNext" aria-label="Next image">›</button>
|
||||
<div class="themeOfTheMonthCardGalleryDots" role="tablist" aria-label="Theme previews">
|
||||
${slides
|
||||
.map(
|
||||
(_, i) =>
|
||||
`<button type="button" class="themeOfTheMonthCardGalleryDot${i === 0 ? " themeOfTheMonthCardGalleryDotActive" : ""}" data-slide="${i}" role="tab" aria-label="Image ${i + 1} of ${slides.length}" aria-selected="${i === 0 ? "true" : "false"}"></button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
return `
|
||||
<div class="themeOfTheMonthCardGallery">
|
||||
<div class="themeOfTheMonthCardGalleryTrack">${slidesHtml}</div>
|
||||
${nav}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const POPOUT_EXPAND_SVG = /* svg */ `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-7 7"/><path d="M3 21l7-7"/></svg>`;
|
||||
const POPOUT_COLLAPSE_SVG = /* svg */ `<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 14h6v6"/><path d="M20 10h-6V4"/><path d="M14 10l7-7"/><path d="M3 21l7-7"/></svg>`;
|
||||
|
||||
const TOTM_MARGIN_PX = 18;
|
||||
const TOTM_EXPANDED_SHELL_MAX_PX = 560;
|
||||
const TOTM_EASE = "cubic-bezier(0.76, 0, 0.24, 1)";
|
||||
const TOTM_MORPH_MS = 550;
|
||||
const TOTM_LAYOUT_SWAP_MS = TOTM_MORPH_MS / 2;
|
||||
|
||||
let themeOfTheMonthAnimGen = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dimension helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function themeOfTheMonthCollapsedWidth(): number {
|
||||
return Math.min(360, window.innerWidth - TOTM_MARGIN_PX * 2);
|
||||
}
|
||||
|
||||
function themeOfTheMonthExpandedWidth(): number {
|
||||
return Math.min(520, window.innerWidth - 32);
|
||||
}
|
||||
|
||||
function themeOfTheMonthMaxCardHeight(): number {
|
||||
return window.innerHeight - TOTM_MARGIN_PX * 2;
|
||||
}
|
||||
|
||||
/** Fixed expanded card height — stable morph target; footer pinned inside via CSS. */
|
||||
function themeOfTheMonthExpandedShellHeight(): number {
|
||||
return Math.min(TOTM_EXPANDED_SHELL_MAX_PX, themeOfTheMonthMaxCardHeight());
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure-transform positioning
|
||||
//
|
||||
// The card sits at position: fixed; top: 0; left: 0 at all times.
|
||||
// All movement is expressed as translate(x, y) so CSS transitions drive
|
||||
// the full path — no top/left changes mid-animation that would cause snapping.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the translate values that place the card at the correct position.
|
||||
* Collapsed → bottom-right corner. Expanded → viewport centre.
|
||||
* Both states are expressed purely as transform offsets from (0, 0).
|
||||
*/
|
||||
function computeCardTranslate(
|
||||
cardWidth: number,
|
||||
cardHeight: number,
|
||||
expanded: boolean,
|
||||
): { x: number; y: number } {
|
||||
if (expanded) {
|
||||
const x = Math.round(
|
||||
Math.max(
|
||||
TOTM_MARGIN_PX,
|
||||
Math.min(
|
||||
(window.innerWidth - cardWidth) / 2,
|
||||
window.innerWidth - TOTM_MARGIN_PX - cardWidth,
|
||||
),
|
||||
),
|
||||
);
|
||||
const y = Math.round(
|
||||
Math.max(
|
||||
TOTM_MARGIN_PX,
|
||||
Math.min(
|
||||
(window.innerHeight - cardHeight) / 2,
|
||||
window.innerHeight - TOTM_MARGIN_PX - cardHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
return { x, y };
|
||||
} else {
|
||||
const x = Math.round(
|
||||
Math.max(
|
||||
TOTM_MARGIN_PX,
|
||||
Math.min(
|
||||
window.innerWidth - cardWidth - TOTM_MARGIN_PX,
|
||||
window.innerWidth - TOTM_MARGIN_PX - cardWidth,
|
||||
),
|
||||
),
|
||||
);
|
||||
const y = Math.round(
|
||||
Math.max(
|
||||
TOTM_MARGIN_PX,
|
||||
window.innerHeight - cardHeight - TOTM_MARGIN_PX,
|
||||
),
|
||||
);
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply card dimensions + border-radius, then set transform so the card
|
||||
* lands at the right position.
|
||||
*
|
||||
* `targetHeight` must be passed explicitly — never read scrollHeight here,
|
||||
* because content may be hidden/shown mid-animation and scrollHeight would
|
||||
* give the wrong value, causing the snap-to-full-height bug.
|
||||
*/
|
||||
function applyThemeOfTheMonthCardPosition(
|
||||
card: HTMLElement,
|
||||
expanded: boolean,
|
||||
animate: boolean,
|
||||
targetHeight?: number,
|
||||
): void {
|
||||
const width = expanded
|
||||
? themeOfTheMonthExpandedWidth()
|
||||
: themeOfTheMonthCollapsedWidth();
|
||||
|
||||
card.style.width = `${width}px`;
|
||||
card.style.maxHeight = expanded ? `${themeOfTheMonthMaxCardHeight()}px` : "";
|
||||
card.style.borderRadius = expanded ? "22px" : "20px";
|
||||
|
||||
const h = targetHeight ?? card.offsetHeight;
|
||||
const { x, y } = computeCardTranslate(width, h, expanded);
|
||||
|
||||
const canAnimate =
|
||||
animate &&
|
||||
settingsState.animations &&
|
||||
card.classList.contains("themeOfTheMonthCardMorphReady");
|
||||
|
||||
if (canAnimate) {
|
||||
card.style.transition = [
|
||||
`transform ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
|
||||
`width ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
|
||||
`height ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
|
||||
`border-radius ${TOTM_MORPH_MS}ms ${TOTM_EASE}`,
|
||||
].join(", ");
|
||||
} else {
|
||||
card.style.transition = "none";
|
||||
}
|
||||
|
||||
// Force a reflow so the browser registers the pre-transition state.
|
||||
void card.offsetHeight;
|
||||
|
||||
if (targetHeight !== undefined) card.style.height = `${targetHeight}px`;
|
||||
card.style.transform = `translate(${x}px, ${y}px)`;
|
||||
|
||||
if (!canAnimate) {
|
||||
requestAnimationFrame(() => {
|
||||
if (card.isConnected) card.style.transition = "";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Height helpers — keep card height explicit during animation so transforms
|
||||
// can be calculated correctly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expand / collapse animations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function applyExpandedCardShell(card: HTMLElement): number {
|
||||
const h = themeOfTheMonthExpandedShellHeight();
|
||||
card.classList.add("themeOfTheMonthCardExpandedShell");
|
||||
card.style.height = `${h}px`;
|
||||
card.style.maxHeight = `${themeOfTheMonthMaxCardHeight()}px`;
|
||||
card.style.overflow = "hidden";
|
||||
return h;
|
||||
}
|
||||
|
||||
function clearExpandedCardShell(card: HTMLElement): void {
|
||||
card.classList.remove("themeOfTheMonthCardExpandedShell");
|
||||
card.style.height = "";
|
||||
card.style.maxHeight = "";
|
||||
card.style.overflow = "";
|
||||
}
|
||||
|
||||
function applyExpandedLayout(card: HTMLElement, descriptionHtml: string): void {
|
||||
const desc = card.querySelector<HTMLElement>(".themeOfTheMonthCardDescription");
|
||||
const expandedPanel = card.querySelector<HTMLElement>(".themeOfTheMonthCardExpandedPanel");
|
||||
|
||||
card.classList.add("themeOfTheMonthCardExpanded", "themeOfTheMonthCardShowGallery");
|
||||
expandedPanel?.removeAttribute("hidden");
|
||||
if (expandedPanel) {
|
||||
expandedPanel.style.opacity = "";
|
||||
expandedPanel.style.transition = "";
|
||||
}
|
||||
if (desc) {
|
||||
desc.innerHTML = descriptionHtml;
|
||||
desc.classList.add("themeOfTheMonthCardDescriptionExpanded");
|
||||
desc.classList.remove("themeOfTheMonthCardDescriptionClipped");
|
||||
}
|
||||
}
|
||||
|
||||
function applyCollapsedLayout(card: HTMLElement, descriptionHtml: string): void {
|
||||
const desc = card.querySelector<HTMLElement>(".themeOfTheMonthCardDescription");
|
||||
const expandedPanel = card.querySelector<HTMLElement>(".themeOfTheMonthCardExpandedPanel");
|
||||
const body = card.querySelector<HTMLElement>(".themeOfTheMonthCardBody");
|
||||
card.classList.remove(
|
||||
"themeOfTheMonthCardExpanded",
|
||||
"themeOfTheMonthCardShowGallery",
|
||||
"themeOfTheMonthCardExpandedShell",
|
||||
);
|
||||
clearExpandedCardShell(card);
|
||||
expandedPanel?.setAttribute("hidden", "");
|
||||
if (expandedPanel) {
|
||||
expandedPanel.style.opacity = "";
|
||||
expandedPanel.style.transition = "";
|
||||
}
|
||||
if (body) {
|
||||
body.style.opacity = "";
|
||||
body.style.transition = "";
|
||||
}
|
||||
if (desc) {
|
||||
desc.innerHTML = descriptionHtml;
|
||||
desc.classList.remove("themeOfTheMonthCardDescriptionExpanded");
|
||||
desc.classList.add("themeOfTheMonthCardDescriptionClipped");
|
||||
}
|
||||
}
|
||||
|
||||
function clearCardInlineSizeForMeasure(card: HTMLElement): void {
|
||||
card.style.height = "";
|
||||
card.style.maxHeight = "";
|
||||
card.style.overflow = "";
|
||||
}
|
||||
|
||||
function measureCollapsedTargetHeight(card: HTMLElement, descriptionHtml: string): number {
|
||||
applyCollapsedLayout(card, descriptionHtml);
|
||||
card.style.width = `${themeOfTheMonthCollapsedWidth()}px`;
|
||||
clearCardInlineSizeForMeasure(card);
|
||||
void card.offsetHeight;
|
||||
return Math.min(card.scrollHeight, themeOfTheMonthMaxCardHeight());
|
||||
}
|
||||
|
||||
async function runThemeOfTheMonthExpand(
|
||||
card: HTMLElement,
|
||||
backdrop: HTMLElement | null,
|
||||
descriptionHtml: string,
|
||||
): Promise<void> {
|
||||
const gen = ++themeOfTheMonthAnimGen;
|
||||
|
||||
const fromH = card.offsetHeight;
|
||||
const toH = themeOfTheMonthExpandedShellHeight();
|
||||
|
||||
// Morph starts in mini layout; swap to expanded layout halfway through the move.
|
||||
applyCollapsedLayout(card, descriptionHtml);
|
||||
card.style.width = `${themeOfTheMonthCollapsedWidth()}px`;
|
||||
|
||||
card.classList.add("themeOfTheMonthCardExpanding");
|
||||
card.style.height = `${fromH}px`;
|
||||
card.style.overflow = "hidden";
|
||||
|
||||
if (backdrop) {
|
||||
backdrop.hidden = false;
|
||||
backdrop.setAttribute("aria-hidden", "false");
|
||||
requestAnimationFrame(() => backdrop.classList.add("themeOfTheMonthBackdropVisible"));
|
||||
}
|
||||
|
||||
applyThemeOfTheMonthCardPosition(card, true, true, toH);
|
||||
|
||||
await sleep(TOTM_LAYOUT_SWAP_MS);
|
||||
if (gen !== themeOfTheMonthAnimGen) return;
|
||||
applyExpandedLayout(card, descriptionHtml);
|
||||
applyExpandedCardShell(card);
|
||||
|
||||
await sleep(TOTM_LAYOUT_SWAP_MS);
|
||||
if (gen !== themeOfTheMonthAnimGen) return;
|
||||
|
||||
card.classList.remove("themeOfTheMonthCardExpanding");
|
||||
card.style.transition = "";
|
||||
const finalH = themeOfTheMonthExpandedShellHeight();
|
||||
card.style.height = `${finalH}px`;
|
||||
applyThemeOfTheMonthCardPosition(card, true, false, finalH);
|
||||
}
|
||||
|
||||
async function runThemeOfTheMonthCollapse(
|
||||
card: HTMLElement,
|
||||
backdrop: HTMLElement | null,
|
||||
descriptionHtml: string,
|
||||
): Promise<void> {
|
||||
const gen = ++themeOfTheMonthAnimGen;
|
||||
|
||||
const fromH = card.offsetHeight;
|
||||
const toH = measureCollapsedTargetHeight(card, descriptionHtml);
|
||||
|
||||
// Restore expanded visuals, then run one morph (size + position + height together).
|
||||
applyExpandedLayout(card, descriptionHtml);
|
||||
card.style.width = `${themeOfTheMonthExpandedWidth()}px`;
|
||||
|
||||
card.classList.add("themeOfTheMonthCardExpanding", "themeOfTheMonthCardCollapsing");
|
||||
card.style.height = `${fromH}px`;
|
||||
card.style.overflow = "hidden";
|
||||
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove("themeOfTheMonthBackdropVisible");
|
||||
backdrop.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
applyThemeOfTheMonthCardPosition(card, false, true, toH);
|
||||
|
||||
await sleep(TOTM_LAYOUT_SWAP_MS);
|
||||
if (gen !== themeOfTheMonthAnimGen) return;
|
||||
applyCollapsedLayout(card, descriptionHtml);
|
||||
|
||||
await sleep(TOTM_LAYOUT_SWAP_MS);
|
||||
if (gen !== themeOfTheMonthAnimGen) return;
|
||||
|
||||
card.classList.remove(
|
||||
"themeOfTheMonthCardExpanding",
|
||||
"themeOfTheMonthCardCollapsing",
|
||||
);
|
||||
card.style.height = `${toH}px`;
|
||||
card.style.overflow = "";
|
||||
card.style.transition = "";
|
||||
|
||||
if (backdrop) backdrop.hidden = true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instant (reduced-motion) state setter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setThemeOfTheMonthExpandedInstant(
|
||||
card: HTMLElement,
|
||||
backdrop: HTMLElement | null,
|
||||
expanded: boolean,
|
||||
descriptionHtml: string,
|
||||
): void {
|
||||
themeOfTheMonthAnimGen++;
|
||||
|
||||
card.classList.toggle("themeOfTheMonthCardExpanded", expanded);
|
||||
updateThemeOfTheMonthPopoutUi(card, expanded);
|
||||
|
||||
if (expanded) {
|
||||
applyExpandedLayout(card, descriptionHtml);
|
||||
if (backdrop) {
|
||||
backdrop.hidden = false;
|
||||
backdrop.setAttribute("aria-hidden", "false");
|
||||
backdrop.classList.add("themeOfTheMonthBackdropVisible");
|
||||
}
|
||||
applyExpandedCardShell(card);
|
||||
} else {
|
||||
applyCollapsedLayout(card, descriptionHtml);
|
||||
if (backdrop) {
|
||||
backdrop.classList.remove("themeOfTheMonthBackdropVisible");
|
||||
backdrop.setAttribute("aria-hidden", "true");
|
||||
backdrop.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
applyThemeOfTheMonthCardPosition(
|
||||
card,
|
||||
expanded,
|
||||
false,
|
||||
expanded ? themeOfTheMonthExpandedShellHeight() : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function updateThemeOfTheMonthPopoutUi(card: HTMLElement, expanded: boolean): void {
|
||||
const popout = card.querySelector<HTMLButtonElement>(".themeOfTheMonthCardPopout");
|
||||
const popoutIcon = popout?.querySelector(".themeOfTheMonthCardPopoutIcon");
|
||||
if (popoutIcon) {
|
||||
popoutIcon.innerHTML = expanded ? POPOUT_COLLAPSE_SVG : POPOUT_EXPAND_SVG;
|
||||
}
|
||||
if (popout) {
|
||||
popout.setAttribute("aria-label", expanded ? "Collapse" : "Expand");
|
||||
popout.title = expanded ? "Collapse" : "Expand";
|
||||
}
|
||||
}
|
||||
|
||||
function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void): void {
|
||||
if (card.classList.contains("themeOfTheMonthCardClosing")) return;
|
||||
card.classList.add("themeOfTheMonthCardClosing");
|
||||
window.setTimeout(() => {
|
||||
card.remove();
|
||||
@@ -118,38 +555,127 @@ function closeThemeOfTheMonthCard(card: HTMLElement, onDismissed?: () => void) {
|
||||
}, 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Theme of the Month announcement card.
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gallery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function initThemeOfTheMonthGallery(card: HTMLElement): void {
|
||||
const track = card.querySelector<HTMLElement>(".themeOfTheMonthCardGalleryTrack");
|
||||
if (!track) return;
|
||||
|
||||
const slides = [...track.querySelectorAll<HTMLElement>(".themeOfTheMonthCardGallerySlide")];
|
||||
if (slides.length <= 1) return;
|
||||
|
||||
const dots = [...card.querySelectorAll<HTMLButtonElement>(".themeOfTheMonthCardGalleryDot")];
|
||||
let activeIndex = 0;
|
||||
|
||||
const scrollToIndex = (index: number) => {
|
||||
const clamped = ((index % slides.length) + slides.length) % slides.length;
|
||||
activeIndex = clamped;
|
||||
const slide = slides[clamped];
|
||||
track.scrollTo({ left: slide.offsetLeft, behavior: "smooth" });
|
||||
for (const dot of dots) {
|
||||
const isActive = Number(dot.dataset.slide) === clamped;
|
||||
dot.classList.toggle("themeOfTheMonthCardGalleryDotActive", isActive);
|
||||
dot.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
}
|
||||
};
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardGalleryPrev")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
scrollToIndex(activeIndex - 1);
|
||||
});
|
||||
card.querySelector(".themeOfTheMonthCardGalleryNext")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
scrollToIndex(activeIndex + 1);
|
||||
});
|
||||
for (const dot of dots) {
|
||||
dot.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
scrollToIndex(Number((e.currentTarget as HTMLButtonElement).dataset.slide));
|
||||
});
|
||||
}
|
||||
|
||||
const syncDotsFromScroll = () => {
|
||||
const mid = track.scrollLeft + track.clientWidth / 2;
|
||||
let nearest = 0;
|
||||
let nearestDist = Infinity;
|
||||
slides.forEach((slide, i) => {
|
||||
const center = slide.offsetLeft + slide.offsetWidth / 2;
|
||||
const dist = Math.abs(center - mid);
|
||||
if (dist < nearestDist) {
|
||||
nearestDist = dist;
|
||||
nearest = i;
|
||||
}
|
||||
});
|
||||
if (nearest === activeIndex) return;
|
||||
activeIndex = nearest;
|
||||
for (const dot of dots) {
|
||||
const isActive = Number(dot.dataset.slide) === nearest;
|
||||
dot.classList.toggle("themeOfTheMonthCardGalleryDotActive", isActive);
|
||||
dot.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
}
|
||||
};
|
||||
|
||||
track.addEventListener("scroll", syncDotsFromScroll, { passive: true });
|
||||
}
|
||||
|
||||
function attachPopupImages(root: ParentNode): void {
|
||||
for (const img of root.querySelectorAll<HTMLImageElement>("img")) {
|
||||
attachPopupMediaFullscreen(img);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function OpenThemeOfTheMonthPopup(
|
||||
entry: ThemeOfTheMonthEntry,
|
||||
onDismissed?: () => void,
|
||||
) {
|
||||
): Promise<void> {
|
||||
document.getElementById("theme-of-the-month-card")?.remove();
|
||||
document.getElementById("theme-of-the-month-backdrop")?.remove();
|
||||
|
||||
const monthLabel = formatMonthLabel(entry.month);
|
||||
const heroUrl = await resolvePopupHeroImageUrl(entry);
|
||||
const description = escapeHTML(entry.description).replace(/\n/g, " ");
|
||||
const linkedThemeId = entry.theme_id ?? entry.theme?.id;
|
||||
const storeTheme = linkedThemeId ? await fetchThemeStoreTheme(linkedThemeId) : null;
|
||||
const heroUrl =
|
||||
(storeTheme ? heroUrlFromStoreTheme(storeTheme) : null) ??
|
||||
entry.cover_image?.trim() ??
|
||||
null;
|
||||
const gallerySlides = buildPopupGallerySlides(entry, storeTheme, heroUrl);
|
||||
const hasExpandableContent = gallerySlides.length > 0 || entry.description.trim().length > 0;
|
||||
|
||||
const descriptionHtml = escapeHTML(entry.description).replace(/\n/g, "<br />");
|
||||
const heroEmbossHtml =
|
||||
heroUrl && storeTheme ? renderHeroEmbossHtml(storeTheme, entry) : "";
|
||||
|
||||
const backdrop = stringToHTML(/* html */ `
|
||||
<div id="theme-of-the-month-backdrop" class="themeOfTheMonthBackdrop" hidden aria-hidden="true"></div>
|
||||
`).firstChild as HTMLElement;
|
||||
|
||||
const card = stringToHTML(/* html */ `
|
||||
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard" role="dialog" aria-label="Theme of the Month">
|
||||
${
|
||||
heroUrl
|
||||
? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />`
|
||||
: ""
|
||||
}
|
||||
<aside id="theme-of-the-month-card" class="themeOfTheMonthCard${settingsState.animations ? "" : " themeOfTheMonthCardReducedMotion"}" role="dialog" aria-label="Theme of the Month">
|
||||
<div class="themeOfTheMonthCardMedia">
|
||||
<button type="button" class="themeOfTheMonthCardPopout" aria-label="Expand" title="Expand"${hasExpandableContent ? "" : " hidden"}>
|
||||
<span class="themeOfTheMonthCardPopoutIcon">${POPOUT_EXPAND_SVG}</span>
|
||||
</button>
|
||||
<div class="themeOfTheMonthCardCompactMedia"${heroUrl ? "" : " hidden"}>
|
||||
${heroUrl ? `<img class="themeOfTheMonthCardImage" src="${escapeHTML(heroUrl)}" alt="${escapeHTML(entry.title)}" />` : ""}
|
||||
</div>
|
||||
<div class="themeOfTheMonthCardExpandedPanel" hidden>
|
||||
${renderGallerySlidesHtml(gallerySlides)}
|
||||
</div>
|
||||
${heroEmbossHtml}
|
||||
</div>
|
||||
<div class="themeOfTheMonthCardBody">
|
||||
<p class="themeOfTheMonthCardEyebrow">Theme of the Month · ${escapeHTML(monthLabel)}</p>
|
||||
<h2>${escapeHTML(entry.title)}</h2>
|
||||
<p class="themeOfTheMonthCardDescription">${description}</p>
|
||||
<p class="themeOfTheMonthCardDescription themeOfTheMonthCardDescriptionClipped">${descriptionHtml}</p>
|
||||
<div class="themeOfTheMonthCardActions">
|
||||
<div class="themeOfTheMonthCardActionsStart">
|
||||
${
|
||||
linkedThemeId
|
||||
? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>`
|
||||
: ""
|
||||
}
|
||||
${linkedThemeId ? `<button type="button" class="themeOfTheMonthCardPrimary">Open Store</button>` : ""}
|
||||
</div>
|
||||
<div class="themeOfTheMonthCardActionsEnd">
|
||||
<button type="button" class="themeOfTheMonthCardSecondary">Close</button>
|
||||
@@ -170,32 +696,97 @@ export async function OpenThemeOfTheMonthPopup(
|
||||
</aside>
|
||||
`).firstChild as HTMLElement;
|
||||
|
||||
const autoCloseTimeout = window.setTimeout(() => {
|
||||
closeThemeOfTheMonthCard(card, onDismissed);
|
||||
}, 30_000);
|
||||
let isExpanded = false;
|
||||
let expandAnimating = false;
|
||||
|
||||
const dismiss = () => {
|
||||
window.clearTimeout(autoCloseTimeout);
|
||||
const applyExpandedState = async (expanded: boolean): Promise<void> => {
|
||||
updateThemeOfTheMonthPopoutUi(card, expanded);
|
||||
if (!settingsState.animations) {
|
||||
setThemeOfTheMonthExpandedInstant(card, backdrop, expanded, descriptionHtml);
|
||||
return;
|
||||
}
|
||||
expandAnimating = true;
|
||||
try {
|
||||
if (expanded) {
|
||||
await runThemeOfTheMonthExpand(card, backdrop, descriptionHtml);
|
||||
} else {
|
||||
await runThemeOfTheMonthCollapse(card, backdrop, descriptionHtml);
|
||||
}
|
||||
} finally {
|
||||
expandAnimating = false;
|
||||
updateThemeOfTheMonthPopoutUi(card, expanded);
|
||||
}
|
||||
};
|
||||
|
||||
const onDocKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== "Escape") return;
|
||||
if (!isExpanded || expandAnimating) return;
|
||||
ev.stopPropagation();
|
||||
isExpanded = false;
|
||||
void applyExpandedState(false);
|
||||
};
|
||||
|
||||
let autoCloseTimeout = 0;
|
||||
const pauseAutoClose = () => window.clearTimeout(autoCloseTimeout);
|
||||
const onResize = () => {
|
||||
if (isExpanded) applyExpandedCardShell(card);
|
||||
applyThemeOfTheMonthCardPosition(
|
||||
card,
|
||||
isExpanded,
|
||||
false,
|
||||
isExpanded ? themeOfTheMonthExpandedShellHeight() : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const dismissWithCleanup = () => {
|
||||
pauseAutoClose();
|
||||
window.removeEventListener("resize", onResize);
|
||||
backdrop.remove();
|
||||
document.removeEventListener("keydown", onDocKey, true);
|
||||
closeThemeOfTheMonthCard(card, onDismissed);
|
||||
};
|
||||
|
||||
card.addEventListener("mouseenter", () => window.clearTimeout(autoCloseTimeout), { once: true });
|
||||
autoCloseTimeout = window.setTimeout(dismissWithCleanup, 30_000);
|
||||
card.addEventListener("mouseenter", pauseAutoClose, { once: true });
|
||||
|
||||
initThemeOfTheMonthGallery(card);
|
||||
attachPopupImages(card);
|
||||
|
||||
const confirmEl = card.querySelector<HTMLElement>(".themeOfTheMonthCardConfirm");
|
||||
|
||||
const toggleExpanded = () => {
|
||||
if (expandAnimating) return;
|
||||
isExpanded = !isExpanded;
|
||||
pauseAutoClose();
|
||||
void applyExpandedState(isExpanded);
|
||||
};
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardPopout")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpanded();
|
||||
});
|
||||
|
||||
backdrop.addEventListener("click", () => {
|
||||
if (!isExpanded || expandAnimating) return;
|
||||
isExpanded = false;
|
||||
void applyExpandedState(false);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", onDocKey, true);
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardSecondary")?.addEventListener("click", () => {
|
||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||
dismiss();
|
||||
dismissWithCleanup();
|
||||
});
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardPrimary")?.addEventListener("click", () => {
|
||||
settingsState.themeOfTheMonthDismissedMonth = entry.month;
|
||||
dismiss();
|
||||
dismissWithCleanup();
|
||||
openThemeStoreWithHighlight(linkedThemeId!);
|
||||
});
|
||||
|
||||
const openDontShowConfirm = () => {
|
||||
window.clearTimeout(autoCloseTimeout);
|
||||
pauseAutoClose();
|
||||
if (!confirmEl) return;
|
||||
confirmEl.hidden = false;
|
||||
requestAnimationFrame(() => confirmEl.classList.add("themeOfTheMonthCardConfirmVisible"));
|
||||
@@ -206,23 +797,37 @@ export async function OpenThemeOfTheMonthPopup(
|
||||
card.querySelector(".themeOfTheMonthCardConfirmCancel")?.addEventListener("click", () => {
|
||||
if (!confirmEl) return;
|
||||
confirmEl.classList.remove("themeOfTheMonthCardConfirmVisible");
|
||||
window.setTimeout(() => {
|
||||
confirmEl.hidden = true;
|
||||
}, 160);
|
||||
window.setTimeout(() => { confirmEl.hidden = true; }, 160);
|
||||
});
|
||||
|
||||
card.querySelector(".themeOfTheMonthCardConfirmAccept")?.addEventListener("click", () => {
|
||||
settingsState.themeOfTheMonthDisabled = true;
|
||||
dismiss();
|
||||
dismissWithCleanup();
|
||||
});
|
||||
|
||||
document.body.appendChild(card);
|
||||
// Mount — card at top:0; left:0, all positioning via transform.
|
||||
card.style.position = "fixed";
|
||||
card.style.top = "0";
|
||||
card.style.left = "0";
|
||||
|
||||
document.body.append(backdrop, card);
|
||||
|
||||
// Set initial collapsed position instantly (no transition).
|
||||
applyThemeOfTheMonthCardPosition(card, false, false);
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
// Enable morph-ready class after two frames so the initial snap doesn't
|
||||
// accidentally play a transition.
|
||||
if (settingsState.animations) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
card.classList.add("themeOfTheMonthCardMorphReady");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev helper: fetch the current month's entry and show the popup immediately,
|
||||
* even if the user dismissed it for this calendar month.
|
||||
*/
|
||||
export async function showThemeOfTheMonthPopupNow(): Promise<void> {
|
||||
const entry = await fetchThemeOfTheMonth();
|
||||
if (!entry) {
|
||||
@@ -240,4 +845,4 @@ export async function showThemeOfTheMonthPopupNow(): Promise<void> {
|
||||
}
|
||||
|
||||
await OpenThemeOfTheMonthPopup(entry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import localforage from "localforage";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
|
||||
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
|
||||
|
||||
const store = localforage.createInstance({
|
||||
name: "cloud-pfp-store",
|
||||
storeName: "cloudPfp",
|
||||
});
|
||||
|
||||
function hashKey(userId: string) {
|
||||
return `hash:${userId}`;
|
||||
}
|
||||
|
||||
function blobKey(userId: string) {
|
||||
return `blob:${userId}`;
|
||||
}
|
||||
|
||||
export function isAccountsHostedPfpUrl(url: string): boolean {
|
||||
if (!url.includes("/api/user/pfp/")) return false;
|
||||
if (url.includes("/hist/")) return false;
|
||||
return /\/api\/user\/pfp\/[^/?#]+/.test(url.split("?")[0]!);
|
||||
}
|
||||
|
||||
export function pfpUrlWithHash(url: string, hash: string | null | undefined): string {
|
||||
if (!url || !hash || !isAccountsHostedPfpUrl(url)) return url;
|
||||
const base = url.split("?")[0]!;
|
||||
return `${base}?v=${hash}`;
|
||||
}
|
||||
|
||||
async function fetchServerHash(userId: string): Promise<string | null> {
|
||||
const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp/${userId}/meta`);
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { pfpHash?: string | null };
|
||||
return data.pfpHash ?? null;
|
||||
}
|
||||
|
||||
async function clearLocal(userId: string): Promise<void> {
|
||||
await store.removeItem(hashKey(userId));
|
||||
await store.removeItem(blobKey(userId));
|
||||
}
|
||||
|
||||
export async function clearCloudPfpCache(userId?: string): Promise<void> {
|
||||
const id = userId ?? cloudAuth.state.user?.id;
|
||||
if (!id) return;
|
||||
await clearLocal(id);
|
||||
}
|
||||
|
||||
export type ResolveCloudPfpResult = {
|
||||
src: string;
|
||||
fromCache: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an object URL or direct URL for the cloud profile picture.
|
||||
* Order: session hash match → local blob; else meta → download → store blob then hash.
|
||||
*/
|
||||
export async function resolveCloudPfp(
|
||||
userId: string,
|
||||
pfpUrl: string,
|
||||
): Promise<ResolveCloudPfpResult | null> {
|
||||
if (!isAccountsHostedPfpUrl(pfpUrl)) {
|
||||
return { src: pfpUrl, fromCache: false };
|
||||
}
|
||||
|
||||
const sessionHash = cloudAuth.state.user?.pfpHash ?? null;
|
||||
const localHash = await store.getItem<string>(hashKey(userId));
|
||||
const localBlob = await store.getItem<Blob>(blobKey(userId));
|
||||
|
||||
let serverHash = sessionHash;
|
||||
|
||||
const localMatches =
|
||||
!!serverHash && serverHash === localHash && localBlob instanceof Blob;
|
||||
if (localMatches) {
|
||||
return { src: URL.createObjectURL(localBlob), fromCache: true };
|
||||
}
|
||||
|
||||
if (!serverHash || serverHash !== localHash) {
|
||||
serverHash = await fetchServerHash(userId);
|
||||
}
|
||||
|
||||
if (!serverHash) {
|
||||
await clearLocal(userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (serverHash === localHash && localBlob instanceof Blob) {
|
||||
return { src: URL.createObjectURL(localBlob), fromCache: true };
|
||||
}
|
||||
|
||||
await clearLocal(userId);
|
||||
|
||||
const imageUrl = pfpUrlWithHash(pfpUrl, serverHash);
|
||||
const headers: HeadersInit = {};
|
||||
if (localHash) {
|
||||
headers["If-None-Match"] = `"${localHash}"`;
|
||||
}
|
||||
|
||||
const res = await fetch(imageUrl, { headers });
|
||||
if (res.status === 304 && localBlob instanceof Blob) {
|
||||
await store.setItem(hashKey(userId), serverHash);
|
||||
return { src: URL.createObjectURL(localBlob), fromCache: true };
|
||||
}
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const blob = await res.blob();
|
||||
await store.setItem(blobKey(userId), blob);
|
||||
await store.setItem(hashKey(userId), serverHash);
|
||||
|
||||
return { src: URL.createObjectURL(blob), fromCache: false };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import localforage from "localforage";
|
||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||
import { clearCloudPfpCache, pfpUrlWithHash } from "@/seqta/utils/cloudPfpCache";
|
||||
|
||||
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
|
||||
const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings";
|
||||
@@ -10,9 +11,32 @@ const profileStore = localforage.createInstance({
|
||||
storeName: "profilePicture",
|
||||
});
|
||||
|
||||
function cacheBustPfpUrl(url: string): string {
|
||||
const base = url.split("?")[0]!;
|
||||
return `${base}?v=${Date.now()}`;
|
||||
/** Downscale before upload to reduce ingress (server still normalizes). */
|
||||
async function downscaleForUpload(blob: Blob, maxEdge = 512): Promise<Blob> {
|
||||
if (!blob.type.startsWith("image/")) return blob;
|
||||
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const maxSide = Math.max(bitmap.width, bitmap.height);
|
||||
if (maxSide <= maxEdge) {
|
||||
bitmap.close();
|
||||
return blob;
|
||||
}
|
||||
|
||||
const scale = maxEdge / maxSide;
|
||||
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
return blob;
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
bitmap.close();
|
||||
|
||||
const out = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.85 });
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function isUseCloudPfpEnabled(): Promise<boolean> {
|
||||
@@ -41,6 +65,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
||||
const token = await cloudAuth.getStoredToken();
|
||||
if (!token) return { success: false, error: "Not logged in" };
|
||||
|
||||
const user = cloudAuth.state.user;
|
||||
const userId = user?.id;
|
||||
|
||||
const blob = await profileStore.getItem<Blob>("profile-picture");
|
||||
|
||||
try {
|
||||
@@ -57,10 +84,10 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
||||
if (!res.ok) {
|
||||
return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` };
|
||||
}
|
||||
const user = cloudAuth.state.user;
|
||||
if (user) {
|
||||
await cloudAuth.setUser({ ...user, pfpUrl: undefined });
|
||||
await cloudAuth.setUser({ ...user, pfpUrl: undefined, pfpHash: null });
|
||||
}
|
||||
if (userId) await clearCloudPfpCache(userId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -71,8 +98,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
||||
return { success: false, error: "File too large (max 5MB)" };
|
||||
}
|
||||
|
||||
const uploadBlob = await downscaleForUpload(blob);
|
||||
const formData = new FormData();
|
||||
formData.append("file", blob, "profile-picture");
|
||||
formData.append("file", uploadBlob, "profile-picture.jpg");
|
||||
|
||||
const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp`, {
|
||||
method: "POST",
|
||||
@@ -85,10 +113,15 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
||||
}
|
||||
|
||||
const pfpUrl = data.pfpUrl as string | undefined;
|
||||
const user = cloudAuth.state.user;
|
||||
const pfpHash = (data.pfpHash as string | null | undefined) ?? null;
|
||||
if (user && pfpUrl) {
|
||||
await cloudAuth.setUser({ ...user, pfpUrl: cacheBustPfpUrl(pfpUrl) });
|
||||
await cloudAuth.setUser({
|
||||
...user,
|
||||
pfpUrl: pfpUrlWithHash(pfpUrl, pfpHash),
|
||||
pfpHash,
|
||||
});
|
||||
}
|
||||
if (userId) await clearCloudPfpCache(userId);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user