From 0878910043d082ad8b21af852fd09cb3c1cd94e9 Mon Sep 17 00:00:00 2001 From: StroepWafel Date: Thu, 4 Jun 2026 12:44:34 +0930 Subject: [PATCH] Sync PFP on change --- .../ProfilePictureSetting.svelte | 17 ++- src/plugins/built-in/profilePicture/index.ts | 22 +++- src/seqta/utils/CloudAuth.ts | 11 ++ src/seqta/utils/cloudPfpSync.ts | 105 ++++++++++++++++++ 4 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 src/seqta/utils/cloudPfpSync.ts diff --git a/src/plugins/built-in/profilePicture/ProfilePictureSetting.svelte b/src/plugins/built-in/profilePicture/ProfilePictureSetting.svelte index 4e36ade9..57600619 100644 --- a/src/plugins/built-in/profilePicture/ProfilePictureSetting.svelte +++ b/src/plugins/built-in/profilePicture/ProfilePictureSetting.svelte @@ -1,5 +1,10 @@ diff --git a/src/plugins/built-in/profilePicture/index.ts b/src/plugins/built-in/profilePicture/index.ts index 9cf3e7d5..c3c05327 100644 --- a/src/plugins/built-in/profilePicture/index.ts +++ b/src/plugins/built-in/profilePicture/index.ts @@ -6,6 +6,7 @@ import { } from "@/plugins/core/settingsHelpers"; import ProfilePictureSetting from "./ProfilePictureSetting.svelte"; import { waitForElm } from "@/seqta/utils/waitForElm"; +import browser from "webextension-polyfill"; import { cloudAuth } from "@/seqta/utils/CloudAuth"; import styles from "./styles.css?inline"; import localforage from "localforage"; @@ -67,7 +68,8 @@ const profilePicturePlugin: Plugin = { if (useCloud && pfpUrl) { img = document.createElement("img"); img.className = "userInfoImg"; - img.src = pfpUrl; + const base = pfpUrl.split("?")[0]!; + img.src = `${base}?v=${Date.now()}`; if (svg) svg.style.display = "none"; container.appendChild(img); return; @@ -93,11 +95,26 @@ const profilePicturePlugin: Plugin = { }; window.addEventListener("profile-picture-updated", onLocalPictureUpdated); + const onStorageRevision = ( + changes: Record, + areaName: string, + ) => { + if (areaName === "local" && changes.profile_picture_revision) { + void applyProfileImage(); + } + }; + browser.storage.onChanged.addListener(onStorageRevision); + const cloudUnsub = cloudAuth.subscribe(() => { void applyProfileImage(); }); - const useCloudUnreg = api.settings.onChange("useCloudPfp", () => { + const useCloudUnreg = api.settings.onChange("useCloudPfp", (enabled: boolean) => { + if (enabled) { + void import("@/seqta/utils/cloudPfpSync").then(({ syncLocalProfilePictureToCloud }) => + syncLocalProfilePictureToCloud(), + ); + } void applyProfileImage(); }); @@ -105,6 +122,7 @@ const profilePicturePlugin: Plugin = { useCloudUnreg.unregister(); cloudUnsub(); window.removeEventListener("profile-picture-updated", onLocalPictureUpdated); + browser.storage.onChanged.removeListener(onStorageRevision); if (img) img.remove(); if (svg) svg.style.display = ""; if (currentBlobUrl) URL.revokeObjectURL(currentBlobUrl); diff --git a/src/seqta/utils/CloudAuth.ts b/src/seqta/utils/CloudAuth.ts index a2490959..d96d8a6e 100644 --- a/src/seqta/utils/CloudAuth.ts +++ b/src/seqta/utils/CloudAuth.ts @@ -107,6 +107,17 @@ class CloudAuthService { return (result[STORAGE_KEYS.accessToken] as string) ?? null; } + /** Persist an updated user object (e.g. after cloud profile picture sync). */ + public async setUser(user: CloudUser | null): Promise { + (settingsState as any).setKey(STORAGE_KEYS.user, user); + await browser.storage.local.set({ [STORAGE_KEYS.user]: user }); + this._state = { + isLoggedIn: this._state.isLoggedIn, + user, + }; + this.notify(); + } + private async getClientId(): Promise { let clientId = (settingsState as any)[STORAGE_KEYS.clientId] as string | undefined; if (!clientId) { diff --git a/src/seqta/utils/cloudPfpSync.ts b/src/seqta/utils/cloudPfpSync.ts new file mode 100644 index 00000000..dd8f1c82 --- /dev/null +++ b/src/seqta/utils/cloudPfpSync.ts @@ -0,0 +1,105 @@ +import browser from "webextension-polyfill"; +import localforage from "localforage"; +import { cloudAuth } from "@/seqta/utils/CloudAuth"; + +const ACCOUNTS_BASE = "https://accounts.betterseqta.org"; +const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings"; + +const profileStore = localforage.createInstance({ + name: "profile-picture-store", + storeName: "profilePicture", +}); + +function cacheBustPfpUrl(url: string): string { + const base = url.split("?")[0]!; + return `${base}?v=${Date.now()}`; +} + +export async function isUseCloudPfpEnabled(): Promise { + const stored = await browser.storage.local.get(PLUGIN_SETTINGS_KEY); + const settings = stored[PLUGIN_SETTINGS_KEY] as { useCloudPfp?: boolean } | undefined; + return !!settings?.useCloudPfp; +} + +async function parseJsonResponse(r: Response): Promise> { + const text = await r.text(); + try { + return text ? (JSON.parse(text) as Record) : {}; + } catch { + return {}; + } +} + +export async function syncLocalProfilePictureToCloud(): Promise<{ + success: boolean; + error?: string; +}> { + if (!(await isUseCloudPfpEnabled()) || !cloudAuth.state.isLoggedIn) { + return { success: true }; + } + + const token = await cloudAuth.getStoredToken(); + if (!token) return { success: false, error: "Not logged in" }; + + const blob = await profileStore.getItem("profile-picture"); + + try { + if (!blob || !(blob instanceof Blob)) { + const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp/clear`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + const data = await parseJsonResponse(res); + 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 }); + } + return { success: true }; + } + + if (!blob.type.startsWith("image/")) { + return { success: false, error: "Invalid file type" }; + } + if (blob.size > 5 * 1024 * 1024) { + return { success: false, error: "File too large (max 5MB)" }; + } + + const formData = new FormData(); + formData.append("file", blob, "profile-picture"); + + const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp`, { + method: "POST", + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + const data = await parseJsonResponse(res); + if (!res.ok) { + return { success: false, error: (data.error as string) ?? `Upload failed (${res.status})` }; + } + + const pfpUrl = data.pfpUrl as string | undefined; + const user = cloudAuth.state.user; + if (user && pfpUrl) { + await cloudAuth.setUser({ ...user, pfpUrl: cacheBustPfpUrl(pfpUrl) }); + } + return { success: true }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : "Cloud profile picture sync failed", + }; + } +} + +/** Notify SEQTA content scripts to refresh the in-page profile image. */ +export async function notifyProfilePictureChanged(): Promise { + const revision = Date.now(); + await browser.storage.local.set({ profile_picture_revision: revision }); +}