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 });
+}