mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-13 07:04:39 +00:00
PFP caching
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import { animate } from "motion";
|
import { animate } from "motion";
|
||||||
import { delay } from "@/seqta/utils/delay.ts";
|
import { delay } from "@/seqta/utils/delay.ts";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
|
||||||
|
|
||||||
const { hidePanel } = $props<{
|
const { hidePanel } = $props<{
|
||||||
hidePanel: () => void;
|
hidePanel: () => void;
|
||||||
@@ -105,12 +106,12 @@
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{#if cloudState.user?.pfpUrl}
|
{#if cloudState.user?.pfpUrl}
|
||||||
<img
|
<CloudPfpAvatar
|
||||||
src={cloudState.user.pfpUrl}
|
user={cloudState.user}
|
||||||
alt=""
|
|
||||||
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{/if}
|
||||||
|
{#if !cloudState.user?.pfpUrl}
|
||||||
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
|
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
|
||||||
{getInitials()}
|
{getInitials()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache";
|
||||||
|
import type { CloudUser } from "@/seqta/utils/CloudAuth";
|
||||||
|
|
||||||
|
const { user, class: className = "" } = $props<{
|
||||||
|
user: CloudUser | null | undefined;
|
||||||
|
class?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let avatarSrc = $state<string | undefined>(undefined);
|
||||||
|
let revokeUrl: string | undefined;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const u = user;
|
||||||
|
if (revokeUrl) {
|
||||||
|
URL.revokeObjectURL(revokeUrl);
|
||||||
|
revokeUrl = undefined;
|
||||||
|
}
|
||||||
|
avatarSrc = undefined;
|
||||||
|
|
||||||
|
if (!u?.pfpUrl || !u.id) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void resolveCloudPfp(u.id, u.pfpUrl).then((resolved) => {
|
||||||
|
if (cancelled || !resolved) return;
|
||||||
|
if (resolved.fromCache) {
|
||||||
|
revokeUrl = resolved.src;
|
||||||
|
}
|
||||||
|
avatarSrc = resolved.src;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (revokeUrl) {
|
||||||
|
URL.revokeObjectURL(revokeUrl);
|
||||||
|
revokeUrl = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if avatarSrc}
|
||||||
|
<img src={avatarSrc} alt="" class={className} />
|
||||||
|
{/if}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
import CloudPfpAvatar from "@/interface/components/CloudPfpAvatar.svelte";
|
||||||
|
|
||||||
let { alwaysShowUserName = false, onClick = undefined } = $props<{
|
let { alwaysShowUserName = false, onClick = undefined } = $props<{
|
||||||
alwaysShowUserName?: boolean;
|
alwaysShowUserName?: boolean;
|
||||||
@@ -72,12 +73,12 @@
|
|||||||
>
|
>
|
||||||
{#if cloudState.isLoggedIn}
|
{#if cloudState.isLoggedIn}
|
||||||
{#if cloudState.user?.pfpUrl}
|
{#if cloudState.user?.pfpUrl}
|
||||||
<img
|
<CloudPfpAvatar
|
||||||
src={cloudState.user.pfpUrl}
|
user={cloudState.user}
|
||||||
alt=""
|
|
||||||
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
|
class="w-5 h-5 rounded-full object-cover ring-1 ring-zinc-200 dark:ring-zinc-600"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{/if}
|
||||||
|
{#if !cloudState.user?.pfpUrl}
|
||||||
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
|
<div class="flex items-center justify-center w-5 h-5 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-[0.6rem]">
|
||||||
{getInitials()}
|
{getInitials()}
|
||||||
</div>
|
</div>
|
||||||
@@ -111,12 +112,12 @@
|
|||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{#if cloudState.user?.pfpUrl}
|
{#if cloudState.user?.pfpUrl}
|
||||||
<img
|
<CloudPfpAvatar
|
||||||
src={cloudState.user.pfpUrl}
|
user={cloudState.user}
|
||||||
alt=""
|
|
||||||
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
class="w-12 h-12 rounded-full object-cover ring-2 ring-zinc-200 dark:ring-zinc-600"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{/if}
|
||||||
|
{#if !cloudState.user?.pfpUrl}
|
||||||
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
|
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-zinc-300 dark:bg-zinc-600 text-zinc-700 dark:text-zinc-200 font-semibold text-base">
|
||||||
{getInitials()}
|
{getInitials()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ProfilePictureSetting from "./ProfilePictureSetting.svelte";
|
|||||||
import { waitForElm } from "@/seqta/utils/waitForElm";
|
import { waitForElm } from "@/seqta/utils/waitForElm";
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
import { resolveCloudPfp } from "@/seqta/utils/cloudPfpCache";
|
||||||
import styles from "./styles.css?inline";
|
import styles from "./styles.css?inline";
|
||||||
import localforage from "localforage";
|
import localforage from "localforage";
|
||||||
|
|
||||||
@@ -65,15 +66,18 @@ const profilePicturePlugin: Plugin<typeof settings> = {
|
|||||||
const useCloud = api.settings.useCloudPfp;
|
const useCloud = api.settings.useCloudPfp;
|
||||||
const pfpUrl = cloudAuth.state.user?.pfpUrl;
|
const pfpUrl = cloudAuth.state.user?.pfpUrl;
|
||||||
|
|
||||||
if (useCloud && pfpUrl) {
|
if (useCloud && pfpUrl && cloudAuth.state.user?.id) {
|
||||||
|
const resolved = await resolveCloudPfp(cloudAuth.state.user.id, pfpUrl);
|
||||||
|
if (resolved) {
|
||||||
|
currentBlobUrl = resolved.src;
|
||||||
img = document.createElement("img");
|
img = document.createElement("img");
|
||||||
img.className = "userInfoImg";
|
img.className = "userInfoImg";
|
||||||
const base = pfpUrl.split("?")[0]!;
|
img.src = resolved.src;
|
||||||
img.src = `${base}?v=${Date.now()}`;
|
|
||||||
if (svg) svg.style.display = "none";
|
if (svg) svg.style.display = "none";
|
||||||
container.appendChild(img);
|
container.appendChild(img);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const blob = await store.getItem<Blob>("profile-picture");
|
const blob = await store.getItem<Blob>("profile-picture");
|
||||||
if (blob && blob instanceof Blob) {
|
if (blob && blob instanceof Blob) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
|
import { clearCloudPfpCache } from "@/seqta/utils/cloudPfpCache";
|
||||||
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
import { settingsState } from "@/seqta/utils/listeners/SettingsState";
|
||||||
|
|
||||||
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
const REDIRECT_URI = "https://accounts.betterseqta.org/auth/bsplus/callback";
|
||||||
@@ -16,6 +17,7 @@ export type CloudUser = {
|
|||||||
username?: string;
|
username?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
pfpUrl?: string;
|
pfpUrl?: string;
|
||||||
|
pfpHash?: string | null;
|
||||||
admin_level?: number;
|
admin_level?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -201,6 +203,8 @@ class CloudAuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async logout(): Promise<void> {
|
public async logout(): Promise<void> {
|
||||||
|
const userId = this._state.user?.id;
|
||||||
|
if (userId) await clearCloudPfpCache(userId);
|
||||||
await browser.storage.local.remove([
|
await browser.storage.local.remove([
|
||||||
STORAGE_KEYS.accessToken,
|
STORAGE_KEYS.accessToken,
|
||||||
STORAGE_KEYS.refreshToken,
|
STORAGE_KEYS.refreshToken,
|
||||||
|
|||||||
@@ -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 browser from "webextension-polyfill";
|
||||||
import localforage from "localforage";
|
import localforage from "localforage";
|
||||||
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
import { cloudAuth } from "@/seqta/utils/CloudAuth";
|
||||||
|
import { clearCloudPfpCache, pfpUrlWithHash } from "@/seqta/utils/cloudPfpCache";
|
||||||
|
|
||||||
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
|
const ACCOUNTS_BASE = "https://accounts.betterseqta.org";
|
||||||
const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings";
|
const PLUGIN_SETTINGS_KEY = "plugin.profile-picture.settings";
|
||||||
@@ -10,9 +11,32 @@ const profileStore = localforage.createInstance({
|
|||||||
storeName: "profilePicture",
|
storeName: "profilePicture",
|
||||||
});
|
});
|
||||||
|
|
||||||
function cacheBustPfpUrl(url: string): string {
|
/** Downscale before upload to reduce ingress (server still normalizes). */
|
||||||
const base = url.split("?")[0]!;
|
async function downscaleForUpload(blob: Blob, maxEdge = 512): Promise<Blob> {
|
||||||
return `${base}?v=${Date.now()}`;
|
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> {
|
export async function isUseCloudPfpEnabled(): Promise<boolean> {
|
||||||
@@ -41,6 +65,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
|||||||
const token = await cloudAuth.getStoredToken();
|
const token = await cloudAuth.getStoredToken();
|
||||||
if (!token) return { success: false, error: "Not logged in" };
|
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");
|
const blob = await profileStore.getItem<Blob>("profile-picture");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -57,10 +84,10 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` };
|
return { success: false, error: (data.error as string) ?? `Clear failed (${res.status})` };
|
||||||
}
|
}
|
||||||
const user = cloudAuth.state.user;
|
|
||||||
if (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 };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,8 +98,9 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
|||||||
return { success: false, error: "File too large (max 5MB)" };
|
return { success: false, error: "File too large (max 5MB)" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadBlob = await downscaleForUpload(blob);
|
||||||
const formData = new FormData();
|
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`, {
|
const res = await fetch(`${ACCOUNTS_BASE}/api/user/pfp`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -85,10 +113,15 @@ export async function syncLocalProfilePictureToCloud(): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pfpUrl = data.pfpUrl as string | undefined;
|
const pfpUrl = data.pfpUrl as string | undefined;
|
||||||
const user = cloudAuth.state.user;
|
const pfpHash = (data.pfpHash as string | null | undefined) ?? null;
|
||||||
if (user && pfpUrl) {
|
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 };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export interface SettingsState {
|
|||||||
bsplus_client_id?: string;
|
bsplus_client_id?: string;
|
||||||
bsplus_token?: string;
|
bsplus_token?: string;
|
||||||
bsplus_refresh_token?: string;
|
bsplus_refresh_token?: string;
|
||||||
bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; admin_level?: number };
|
bsplus_user?: { id: string; email?: string; username?: string; displayName?: string; pfpUrl?: string; pfpHash?: string | null; admin_level?: number };
|
||||||
/** When not `false`, automatic cloud settings sync is enabled (default-on). */
|
/** When not `false`, automatic cloud settings sync is enabled (default-on). */
|
||||||
autoCloudSettingsSync?: boolean;
|
autoCloudSettingsSync?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user