Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/migrations/0049_profile_updated_at.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Add profile_updated_at to track when profile data was last refreshed from Bluesky
ALTER TABLE users ADD COLUMN profile_updated_at INTEGER;

-- Backfill existing rows with updated_at value
UPDATE users SET profile_updated_at = updated_at WHERE profile_updated_at IS NULL;
57 changes: 52 additions & 5 deletions backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,14 +638,15 @@ export async function handleAuthCallback(
// Store/update user in D1 BEFORE storing session (sessions table has FK to users)
await env.DB.prepare(
`
INSERT INTO users (did, handle, display_name, avatar_url, pds_url, updated_at, registered_at)
VALUES (?, ?, ?, ?, ?, unixepoch(), unixepoch())
INSERT INTO users (did, handle, display_name, avatar_url, pds_url, updated_at, registered_at, profile_updated_at)
VALUES (?, ?, ?, ?, ?, unixepoch(), unixepoch(), unixepoch())
ON CONFLICT(did) DO UPDATE SET
handle = excluded.handle,
display_name = excluded.display_name,
avatar_url = excluded.avatar_url,
pds_url = excluded.pds_url,
updated_at = unixepoch(),
profile_updated_at = unixepoch(),
registered_at = COALESCE(users.registered_at, unixepoch())
`
)
Expand Down Expand Up @@ -700,6 +701,8 @@ export async function handleAuthCallback(
}
}

const PROFILE_REFRESH_INTERVAL = 24 * 60 * 60; // 24 hours in seconds

export async function handleAuthMe(request: Request, env: Env): Promise<Response> {
const session = await getSessionFromRequest(request, env);

Expand All @@ -713,12 +716,56 @@ export async function handleAuthMe(request: Request, env: Env): Promise<Response
const tier = await getUserTier(env, session.did);
const limits = getLimitsForTier(tier);

// Check if profile data is stale and refresh in the background
let { handle, displayName, avatarUrl } = session;
try {
const user = await env.DB.prepare('SELECT profile_updated_at FROM users WHERE did = ?')
.bind(session.did)
.first<{ profile_updated_at: number | null }>();

const now = Math.floor(Date.now() / 1000);
const profileAge = user?.profile_updated_at ? now - user.profile_updated_at : Infinity;

if (profileAge > PROFILE_REFRESH_INTERVAL) {
const profileUrl = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(session.did)}`;
const profileResponse = await fetch(profileUrl);

if (profileResponse.ok) {
const profile = (await profileResponse.json()) as {
handle: string;
displayName?: string;
avatar?: string;
};

handle = profile.handle;
displayName = profile.displayName;
avatarUrl = profile.avatar;

// Update users table and session
await env.DB.prepare(
`UPDATE users SET handle = ?, display_name = ?, avatar_url = ?, profile_updated_at = unixepoch(), updated_at = unixepoch() WHERE did = ?`
)
.bind(handle, displayName || null, avatarUrl || null, session.did)
.run();

await env.DB.prepare(
`UPDATE sessions SET handle = ?, display_name = ?, avatar_url = ? WHERE did = ?`
)
.bind(handle, displayName || null, avatarUrl || null, session.did)
.run();
}
}
} catch (error) {
// Non-critical: if profile refresh fails, return cached data
console.error('Profile refresh error:', error);
}

return new Response(
JSON.stringify({
did: session.did,
handle: session.handle,
displayName: session.displayName,
avatarUrl: session.avatarUrl,
handle,
displayName,
avatarUrl,
pdsUrl: session.pdsUrl,
tier,
limits,
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/lib/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,17 @@
<div class="sidebar-header">
<a href="/settings" class="user-info" onclick={() => sidebarStore.closeMobile()}>
{#if auth.user?.avatarUrl}
<img src={auth.user.avatarUrl} alt="" class="avatar" />
<img
src={auth.user.avatarUrl}
alt=""
class="avatar"
onerror={(e) => {
const img = e.currentTarget as HTMLImageElement;
img.style.display = 'none';
img.nextElementSibling?.classList.remove('hidden');
}}
/>
<div class="avatar-placeholder hidden"></div>
{:else}
<div class="avatar-placeholder"></div>
{/if}
Expand Down Expand Up @@ -538,6 +548,10 @@
flex-shrink: 0;
}

.avatar-placeholder.hidden {
display: none;
}

.username {
font-size: 0.875rem;
font-weight: 500;
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/lib/stores/auth.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { browser } from '$app/environment';
import { api } from '$lib/services/api';
import { clearAllData } from '$lib/services/db';
import { unregisterPeriodicSync } from '$lib/services/backgroundRefresh';
import { profileService } from '$lib/services/profiles';
import type { User } from '$lib/types';

interface AuthState {
Expand Down Expand Up @@ -31,6 +32,33 @@ function createAuthStore() {
}
}

// Refresh profile data from Bluesky public API (non-blocking)
async function refreshProfile() {
const user = state.user;
if (!user?.did) return;

try {
const profile = await profileService.getProfile(user.did);
if (!profile) return;

// Check if anything changed
const avatarChanged = profile.avatar !== user.avatarUrl;
const handleChanged = profile.handle !== user.handle;
const displayNameChanged = (profile.displayName || undefined) !== user.displayName;

if (avatarChanged || handleChanged || displayNameChanged) {
setUser({
...user,
avatarUrl: profile.avatar,
handle: profile.handle,
displayName: profile.displayName,
});
}
} catch {
// Non-critical, fail silently
}
}

// Restore session from localStorage on init
// User data is cached for display, but session is verified via cookie
if (browser) {
Expand All @@ -53,6 +81,18 @@ function createAuthStore() {
api.setOnScopeUpgradeRequired(() => {
state.scopeUpgradeRequired = true;
});

// Refresh profile on init (non-blocking)
if (state.user) {
refreshProfile();
}

// Refresh profile when tab becomes visible again
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && state.user) {
refreshProfile();
}
});
}

// Set user after successful authentication
Expand Down
26 changes: 25 additions & 1 deletion frontend/src/routes/settings/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,19 @@
<h2>Account</h2>
<div class="user-info">
{#if auth.user.avatarUrl}
<img src={auth.user.avatarUrl} alt="" class="avatar" />
<img
src={auth.user.avatarUrl}
alt=""
class="avatar"
onerror={(e) => {
const img = e.currentTarget as HTMLImageElement;
img.style.display = 'none';
img.nextElementSibling?.classList.remove('hidden');
}}
/>
<div class="avatar-placeholder hidden"></div>
{:else}
<div class="avatar-placeholder"></div>
{/if}
<div>
<p class="display-name">{auth.user.displayName || auth.user.handle}</p>
Expand Down Expand Up @@ -497,6 +509,18 @@
border-radius: 50%;
}

.avatar-placeholder {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--color-border);
flex-shrink: 0;
}

.avatar-placeholder.hidden {
display: none;
}

.display-name {
font-weight: 600;
}
Expand Down
Loading