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
114 changes: 83 additions & 31 deletions packages/happy-app/sources/app/(app)/settings/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { View, Text, Pressable, Platform } from 'react-native';
import { useAuth } from '@/auth/AuthContext';
import { Ionicons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard';
import * as ImagePicker from 'expo-image-picker';
import { useFocusEffect } from '@react-navigation/native';
import { Typography } from '@/constants/Typography';
import { formatSecretKeyForBackup } from '@/auth/secretKeyBackup';
Expand All @@ -23,6 +24,7 @@ import { useHappyAction } from '@/hooks/useHappyAction';
import { disconnectGitHub } from '@/sync/apiGithub';
import { disconnectService } from '@/sync/apiServices';
import { fetchPushTokens, type PushToken } from '@/sync/apiPush';
import { uploadAvatar } from '@/sync/apiUpload';
import {
getCurrentExpoPushToken,
getCurrentPushDeviceMetadata,
Expand Down Expand Up @@ -163,6 +165,49 @@ export default React.memo(() => {
}, [loadPushSettings])
);

// Avatar upload
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const handleUploadAvatar = useCallback(async () => {
if (!auth.credentials) {
return;
}

const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Modal.alert(
t('common.error'),
'Photo library access is required to upload an avatar. Please enable it in your device settings.'
);
return;
}

const pickerResult = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.85,
});

if (pickerResult.canceled || !pickerResult.assets[0]) {
return;
}

const asset = pickerResult.assets[0];
setUploadingAvatar(true);
try {
await uploadAvatar(auth.credentials, asset);
await sync.refreshProfile();
} catch (error) {
console.error('Avatar upload failed:', error);
Modal.alert(
t('common.error'),
error instanceof Error ? error.message : 'Failed to upload avatar. Please try again.'
);
} finally {
setUploadingAvatar(false);
}
}, [auth.credentials]);

// GitHub disconnection
const [disconnecting, handleDisconnectGitHub] = useHappyAction(async () => {
const confirmed = await Modal.confirm(
Expand Down Expand Up @@ -343,39 +388,46 @@ export default React.memo(() => {
</ItemGroup>

{/* Profile Section */}
{(displayName || githubUsername || profile.avatar) && (
<ItemGroup title={t('settingsAccount.profile')}>
{displayName && (
<Item
title={t('settingsAccount.name')}
detail={displayName}
showChevron={false}
/>
)}
{githubUsername && (
<Item
title={t('settingsAccount.github')}
detail={`@${githubUsername}`}
subtitle={t('settingsAccount.tapToDisconnect')}
onPress={handleDisconnectGitHub}
loading={disconnecting}
showChevron={false}
icon={profile.avatar?.url ? (
<Image
source={{ uri: profile.avatar.url }}
style={{ width: 29, height: 29, borderRadius: 14.5 }}
placeholder={{ thumbhash: profile.avatar.thumbhash }}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
) : (
<Ionicons name="logo-github" size={29} color={theme.colors.textSecondary} />
)}
<ItemGroup title={t('settingsAccount.profile')}>
{displayName && (
<Item
title={t('settingsAccount.name')}
detail={displayName}
showChevron={false}
/>
)}
<Item
title="Avatar"
subtitle={uploadingAvatar ? 'Uploading…' : 'Tap to upload a photo from your library'}
onPress={handleUploadAvatar}
loading={uploadingAvatar}
disabled={uploadingAvatar}
showChevron={false}
icon={profile.avatar?.url ? (
<Image
source={{ uri: profile.avatar.url }}
style={{ width: 29, height: 29, borderRadius: 14.5 }}
placeholder={{ thumbhash: profile.avatar.thumbhash }}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
) : (
<Ionicons name="person-circle-outline" size={29} color={theme.colors.textSecondary} />
)}
</ItemGroup>
)}
/>
{githubUsername && (
<Item
title={t('settingsAccount.github')}
detail={`@${githubUsername}`}
subtitle={t('settingsAccount.tapToDisconnect')}
onPress={handleDisconnectGitHub}
loading={disconnecting}
showChevron={false}
icon={<Ionicons name="logo-github" size={29} color={theme.colors.textSecondary} />}
/>
)}
</ItemGroup>

{/* Connected Services Section */}
{profile.connectedServices && profile.connectedServices.length > 0 && (() => {
Expand Down
111 changes: 111 additions & 0 deletions packages/happy-app/sources/sync/apiUpload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { getServerUrl } from "@/sync/serverConfig";
import type { AuthCredentials } from "@/auth/tokenStorage";
import type { ImagePickerAsset } from "expo-image-picker";

const UPLOAD_ROUTE = "avatarUpload";

/**
* Uploads a user avatar directly to Cloudflare R2 using a presigned URL
* issued by the server.
*
* Flow:
* 1. Request a presigned upload URL from the server (POST /v1/upload?action=presign)
* 2. PUT the image bytes straight to R2 — the file never transits the server
* 3. Notify the server that the upload finished (POST /v1/upload?action=complete)
* which triggers thumbhash generation and the account avatar update
*/
export async function uploadAvatar(
credentials: AuthCredentials,
asset: ImagePickerAsset
): Promise<void> {
const serverUrl = getServerUrl();
const endpoint = `${serverUrl}/v1/upload`;
const authHeader = `Bearer ${credentials.token}`;

const fileName = asset.fileName ?? `avatar.${asset.mimeType === "image/png" ? "png" : "jpg"}`;
const mimeType = asset.mimeType ?? "image/jpeg";
const fileSize = asset.fileSize ?? 0;

// ── Step 1: Request a presigned URL ──────────────────────────────────────
const presignResponse = await fetch(
`${endpoint}?route=${UPLOAD_ROUTE}&action=presign`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": authHeader,
},
body: JSON.stringify({
files: [{ name: fileName, size: fileSize, type: mimeType }],
}),
}
);

if (!presignResponse.ok) {
const err = await presignResponse.json().catch(() => ({})) as Record<string, unknown>;
throw new Error((err as { error?: string }).error ?? `Presign failed: ${presignResponse.status}`);
}

const presignData = await presignResponse.json() as {
success: boolean;
error?: string;
results: Array<{
success: boolean;
presignedUrl: string;
key: string;
error?: string;
}>;
};

if (!presignData.success || !presignData.results?.[0]?.success) {
throw new Error(presignData.error ?? presignData.results?.[0]?.error ?? "Failed to get presigned URL");
}

const { presignedUrl, key } = presignData.results[0];

// ── Step 2: Upload the image bytes directly to R2 ────────────────────────
// React Native's fetch supports reading local file URIs as blobs.
const fileResponse = await fetch(asset.uri);
const blob = await fileResponse.blob();

const uploadResponse = await fetch(presignedUrl, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: blob,
});

if (!uploadResponse.ok) {
throw new Error(`R2 upload failed: ${uploadResponse.status}`);
}

// ── Step 3: Notify the server to finalise the avatar ─────────────────────
// The server fetches the image from R2, generates the thumbhash with Sharp,
// persists the file record, updates the account avatar, and emits a
// real-time socket update to all connected clients.
const completeResponse = await fetch(
`${endpoint}?route=${UPLOAD_ROUTE}&action=complete`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": authHeader,
},
body: JSON.stringify({
completions: [
{
key,
file: { name: fileName, size: fileSize, type: mimeType },
},
],
}),
}
);

if (!completeResponse.ok) {
// The upload itself succeeded — only the DB record and socket update failed.
// Log the error but don't surface it as a hard failure.
const err = await completeResponse.json().catch(() => ({})) as Record<string, unknown>;
console.error("Avatar finalisation failed:", err);
throw new Error((err as { error?: string }).error ?? `Finalise failed: ${completeResponse.status}`);
}
}
11 changes: 10 additions & 1 deletion packages/happy-server/.env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING=true
# Redis (cross-process pub/sub)
# REDIS_URL=redis://localhost:6379

# S3/MinIO — file storage (start with `yarn s3`)
# S3/MinIO — legacy file storage for GitHub OAuth avatars (start with `yarn s3`)
# S3_HOST=localhost
# S3_PORT=9000
# S3_USE_SSL=false
Expand All @@ -37,5 +37,14 @@ DANGEROUSLY_LOG_TO_SERVER_FOR_AI_AUTO_DEBUGGING=true
# S3_BUCKET=happy
# S3_PUBLIC_URL=http://localhost:9000/happy

# Cloudflare R2 — user-uploaded files (avatar photos, etc.)
# Create an R2 bucket and an API token with Object Read & Write permissions.
# See: https://developers.cloudflare.com/r2/api/s3/tokens/
# CLOUDFLARE_ACCOUNT_ID=your-account-id
# CLOUDFLARE_R2_BUCKET=happy-uploads
# CLOUDFLARE_R2_ACCESS_KEY_ID=your-r2-access-key-id
# CLOUDFLARE_R2_SECRET_ACCESS_KEY=your-r2-secret-access-key
# CLOUDFLARE_R2_PUBLIC_URL=https://pub-your-bucket-hash.r2.dev

# Voice — 11Labs API key (secret, not checked in)
# ELEVENLABS_API_KEY=
4 changes: 3 additions & 1 deletion packages/happy-server/sources/app/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { userRoutes } from "./routes/userRoutes";
import { feedRoutes } from "./routes/feedRoutes";
import { kvRoutes } from "./routes/kvRoutes";
import { v3SessionRoutes } from "./routes/v3SessionRoutes";
import { uploadRoutes } from "./routes/uploadRoutes";
import { isLocalStorage, getLocalFilesDir } from "@/storage/files";
import * as path from "path";
import * as fs from "fs";
Expand Down Expand Up @@ -90,8 +91,9 @@ export async function startApi() {
feedRoutes(typed);
kvRoutes(typed);
v3SessionRoutes(typed);
uploadRoutes(typed);

// Start HTTP
// Start HTTP
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005;
await app.listen({ port, host: '0.0.0.0' });
onShutdown('api', async () => {
Expand Down
Loading