diff --git a/docs/plans/2026-01-02-onboarding-flow.md b/docs/plans/2026-01-02-onboarding-flow.md new file mode 100644 index 0000000..45a1a17 --- /dev/null +++ b/docs/plans/2026-01-02-onboarding-flow.md @@ -0,0 +1,661 @@ +# Onboarding Flow Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Redirect first-time users to an onboarding page after OAuth, prefilling their Grain profile form with their Bluesky profile data. + +**Architecture:** After OAuth callback, check if `socialGrainActorProfile` exists. If not, redirect to `/onboarding`. The onboarding component fetches `appBskyActorProfile` to prefill displayName, description, and avatar. Users can save or skip; both create a profile record to mark them as onboarded. + +**Tech Stack:** Lit web components, GraphQL via Quickslice client, existing avatar crop component + +--- + +## Task 1: Add Profile Queries to grain-api.js + +**Files:** +- Modify: `src/services/grain-api.js` + +**Step 1: Add hasGrainProfile method** + +Add this method to the GrainApiService class (after `resolveHandle` method, around line 1123): + +```javascript + async hasGrainProfile(client) { + const result = await client.query(` + query { + viewer { + socialGrainActorProfileByDid { + displayName + } + } + } + `); + return !!result.viewer?.socialGrainActorProfileByDid; + } +``` + +**Step 2: Add getBlueskyProfile method** + +Add this method after `hasGrainProfile`: + +```javascript + async getBlueskyProfile(client) { + const result = await client.query(` + query { + viewer { + did + handle + appBskyActorProfileByDid { + displayName + description + avatar { url ref mimeType size } + } + } + } + `); + + const viewer = result.viewer; + const profile = viewer?.appBskyActorProfileByDid; + const avatar = profile?.avatar; + + return { + did: viewer?.did || '', + handle: viewer?.handle || '', + displayName: profile?.displayName || '', + description: profile?.description || '', + avatarUrl: avatar?.url || '', + avatarBlob: avatar ? { + $type: 'blob', + ref: { $link: avatar.ref }, + mimeType: avatar.mimeType, + size: avatar.size + } : null + }; + } +``` + +**Step 3: Commit** + +```bash +git add src/services/grain-api.js +git commit -m "feat: add hasGrainProfile and getBlueskyProfile queries" +``` + +--- + +## Task 2: Add Profile Mutations to mutations.js + +**Files:** +- Modify: `src/services/mutations.js` + +**Step 1: Add updateProfile method** + +Add this method to the MutationsService class (after `updateAvatar` method, around line 200): + +```javascript + async updateProfile(input) { + const client = auth.getClient(); + + await client.mutate(` + mutation UpdateProfile($rkey: String!, $input: SocialGrainActorProfileInput!) { + updateSocialGrainActorProfile(rkey: $rkey, input: $input) { + uri + } + } + `, { rkey: 'self', input }); + + await auth.refreshUser(); + } + + async createEmptyProfile() { + return this.updateProfile({ + displayName: null, + description: null + }); + } +``` + +**Step 2: Commit** + +```bash +git add src/services/mutations.js +git commit -m "feat: add updateProfile and createEmptyProfile mutations" +``` + +--- + +## Task 3: Register Onboarding Route + +**Files:** +- Modify: `src/components/pages/grain-app.js` + +**Step 1: Add import** + +Add import at line 19 (after `grain-oauth-callback.js`): + +```javascript +import './grain-onboarding.js'; +``` + +**Step 2: Add route registration** + +Add route registration at line 69 (before the oauth/callback route): + +```javascript + .register('/onboarding', 'grain-onboarding') +``` + +**Step 3: Commit** + +```bash +git add src/components/pages/grain-app.js +git commit -m "feat: register /onboarding route" +``` + +--- + +## Task 4: Create Onboarding Component + +**Files:** +- Create: `src/components/pages/grain-onboarding.js` + +**Step 1: Create the component** + +Create `src/components/pages/grain-onboarding.js`: + +```javascript +import { LitElement, html, css } from 'lit'; +import { router } from '../../router.js'; +import { auth } from '../../services/auth.js'; +import { grainApi } from '../../services/grain-api.js'; +import { mutations } from '../../services/mutations.js'; +import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js'; +import '../atoms/grain-icon.js'; +import '../atoms/grain-button.js'; +import '../atoms/grain-input.js'; +import '../atoms/grain-textarea.js'; +import '../atoms/grain-avatar.js'; +import '../atoms/grain-spinner.js'; +import '../molecules/grain-form-field.js'; +import '../molecules/grain-avatar-crop.js'; + +export class GrainOnboarding extends LitElement { + static properties = { + _loading: { state: true }, + _saving: { state: true }, + _error: { state: true }, + _displayName: { state: true }, + _description: { state: true }, + _avatarUrl: { state: true }, + _avatarBlob: { state: true }, + _newAvatarDataUrl: { state: true }, + _showAvatarCrop: { state: true }, + _cropImageUrl: { state: true } + }; + + static styles = css` + :host { + display: block; + width: 100%; + max-width: var(--feed-max-width); + min-height: 100%; + padding-bottom: 80px; + background: var(--color-bg-primary); + align-self: center; + } + .header { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xl) var(--space-sm) var(--space-lg); + text-align: center; + } + h1 { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0; + } + .subtitle { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin: 0; + } + .content { + padding: 0 var(--space-sm); + } + @media (min-width: 600px) { + .content { + padding: 0; + } + } + .avatar-section { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: var(--space-lg); + } + .avatar-wrapper { + position: relative; + cursor: pointer; + } + .avatar-overlay { + position: absolute; + bottom: 0; + right: 0; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--color-bg-primary); + border: 2px solid var(--color-border); + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-primary); + } + .avatar-preview { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + background: var(--color-bg-elevated); + } + input[type="file"] { + display: none; + } + .actions { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-lg) var(--space-sm); + border-top: 1px solid var(--color-border); + margin-top: var(--space-lg); + } + @media (min-width: 600px) { + .actions { + padding-left: 0; + padding-right: 0; + } + } + .skip-button { + background: none; + border: none; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + cursor: pointer; + padding: var(--space-sm); + text-align: center; + } + .skip-button:hover { + color: var(--color-text-primary); + text-decoration: underline; + } + .error { + color: var(--color-danger, #dc3545); + font-size: var(--font-size-sm); + padding: var(--space-sm); + text-align: center; + } + .loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-md); + padding: var(--space-xl); + min-height: 300px; + } + .loading p { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + } + `; + + constructor() { + super(); + this._loading = true; + this._saving = false; + this._error = null; + this._displayName = ''; + this._description = ''; + this._avatarUrl = ''; + this._avatarBlob = null; + this._newAvatarDataUrl = null; + this._showAvatarCrop = false; + this._cropImageUrl = null; + } + + async connectedCallback() { + super.connectedCallback(); + + if (!auth.isAuthenticated) { + router.replace('/'); + return; + } + + await this.#checkAndLoad(); + } + + async #checkAndLoad() { + try { + const client = auth.getClient(); + + // Check if user already has a Grain profile + const hasProfile = await grainApi.hasGrainProfile(client); + if (hasProfile) { + this.#redirectToDestination(); + return; + } + + // Fetch Bluesky profile to prefill + const bskyProfile = await grainApi.getBlueskyProfile(client); + this._displayName = bskyProfile.displayName; + this._description = bskyProfile.description; + this._avatarUrl = bskyProfile.avatarUrl; + this._avatarBlob = bskyProfile.avatarBlob; + } catch (err) { + console.error('Failed to load profile data:', err); + } finally { + this._loading = false; + } + } + + #redirectToDestination() { + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; + sessionStorage.removeItem('oauth_return_url'); + router.replace(returnUrl); + } + + #handleDisplayNameChange(e) { + this._displayName = e.detail.value.slice(0, 64); + } + + #handleDescriptionChange(e) { + this._description = e.detail.value.slice(0, 256); + } + + #handleAvatarClick() { + this.shadowRoot.querySelector('#avatar-input').click(); + } + + async #handleAvatarChange(e) { + const input = e.target; + const file = input.files?.[0]; + if (!file) return; + + input.value = ''; + + try { + const dataUrl = await readFileAsDataURL(file); + const resized = await resizeImage(dataUrl, { + width: 2000, + height: 2000, + maxSize: 900000 + }); + this._cropImageUrl = resized.dataUrl; + this._showAvatarCrop = true; + } catch (err) { + console.error('Failed to process avatar:', err); + this._error = 'Failed to process image'; + } + } + + #handleCropCancel() { + this._showAvatarCrop = false; + this._cropImageUrl = null; + } + + #handleCrop(e) { + this._showAvatarCrop = false; + this._cropImageUrl = null; + this._newAvatarDataUrl = e.detail.dataUrl; + this._avatarBlob = null; + } + + get #displayedAvatarUrl() { + if (this._newAvatarDataUrl) return this._newAvatarDataUrl; + return this._avatarUrl; + } + + async #handleSave() { + if (this._saving) return; + + this._saving = true; + this._error = null; + + try { + const input = { + displayName: this._displayName.trim() || null, + description: this._description.trim() || null + }; + + if (this._newAvatarDataUrl) { + const base64Data = this._newAvatarDataUrl.split(',')[1]; + const blob = await mutations.uploadBlob(base64Data, 'image/jpeg'); + input.avatar = { + $type: 'blob', + ref: { $link: blob.ref }, + mimeType: blob.mimeType, + size: blob.size + }; + } else if (this._avatarBlob) { + input.avatar = this._avatarBlob; + } + + await mutations.updateProfile(input); + this.#redirectToDestination(); + } catch (err) { + console.error('Failed to save profile:', err); + this._error = err.message || 'Failed to save profile. Please try again.'; + } finally { + this._saving = false; + } + } + + async #handleSkip() { + if (this._saving) return; + + this._saving = true; + this._error = null; + + try { + await mutations.createEmptyProfile(); + this.#redirectToDestination(); + } catch (err) { + console.error('Failed to skip onboarding:', err); + this._error = err.message || 'Something went wrong. Please try again.'; + } finally { + this._saving = false; + } + } + + render() { + if (this._loading) { + return html` +
+ +

Loading...

+
+ `; + } + + return html` +
+

Welcome to Grain

+

Set up your profile to get started

+
+ + ${this._error ? html`

${this._error}

` : ''} + +
+
+
+ ${this.#displayedAvatarUrl ? html` + Profile avatar + ` : html` + + `} +
+ +
+
+ +
+ + + + + + + + +
+ +
+ Save & Continue + +
+ + + `; + } +} + +customElements.define('grain-onboarding', GrainOnboarding); +``` + +**Step 2: Verify the component compiles** + +Run: `npm run dev` +Navigate to: `http://localhost:5173/onboarding` +Expected: Page loads (redirects to home if not authenticated or already has profile) + +**Step 3: Commit** + +```bash +git add src/components/pages/grain-onboarding.js +git commit -m "feat: add onboarding component with Bluesky profile prefill" +``` + +--- + +## Task 5: Modify OAuth Callback to Check Profile + +**Files:** +- Modify: `src/services/auth.js` + +**Step 1: Add import for grainApi** + +Add at line 2 (after router import): + +```javascript +import { grainApi } from './grain-api.js'; +``` + +**Step 2: Update the redirect callback handler** + +Replace lines 19-26 in `src/services/auth.js` with: + +```javascript + // Handle OAuth callback if present + if (window.location.search.includes('code=')) { + await this.#client.handleRedirectCallback(); + + // Check if user has a Grain profile + const hasProfile = await grainApi.hasGrainProfile(this.#client); + + if (!hasProfile) { + // First-time user - redirect to onboarding + window.location.replace('/onboarding'); + return; + } + + // Existing user - redirect to their destination + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; + sessionStorage.removeItem('oauth_return_url'); + window.location.replace(returnUrl); + return; + } +``` + +**Step 3: Commit** + +```bash +git add src/services/auth.js +git commit -m "feat: redirect first-time users to onboarding after OAuth" +``` + +--- + +## Task 6: Test Complete Flow + +**Files:** +- None (manual testing) + +**Step 1: Test new user flow** + +1. Clear localStorage/sessionStorage (or use incognito) +2. Navigate to `/explore` +3. Click login, authenticate with Bluesky +4. Expected: Redirected to `/onboarding` with Bluesky profile prefilled +5. Click "Save & Continue" +6. Expected: Redirected to `/explore` (original return URL) + +**Step 2: Test skip flow** + +1. Use a different account without Grain profile +2. Go through OAuth +3. On onboarding, click "Skip for now" +4. Expected: Redirected to return URL, profile record created + +**Step 3: Test returning user flow** + +1. Log out and log back in with same account +2. Expected: Goes directly to return URL (no onboarding) + +**Step 4: Test manual onboarding access** + +1. While logged in with existing profile, navigate to `/onboarding` +2. Expected: Immediately redirected to home + +--- + +## Summary + +| Task | Description | Files | +|------|-------------|-------| +| 1 | Add profile queries | `grain-api.js` | +| 2 | Add profile mutations | `mutations.js` | +| 3 | Register route | `grain-app.js` | +| 4 | Create onboarding component | `grain-onboarding.js` (new) | +| 5 | Modify OAuth callback | `auth.js` | +| 6 | Test complete flow | Manual testing | diff --git a/src/components/pages/grain-app.js b/src/components/pages/grain-app.js index b02e373..545a739 100644 --- a/src/components/pages/grain-app.js +++ b/src/components/pages/grain-app.js @@ -17,6 +17,7 @@ import './grain-terms.js'; import './grain-privacy.js'; import './grain-copyright.js'; import './grain-oauth-callback.js'; +import './grain-onboarding.js'; import '../organisms/grain-header.js'; import '../organisms/grain-bottom-nav.js'; @@ -66,6 +67,7 @@ export class GrainApp extends LitElement { .register('/create/descriptions', 'grain-image-descriptions') .register('/explore', 'grain-explore') .register('/notifications', 'grain-notifications') + .register('/onboarding', 'grain-onboarding') .register('/oauth/callback', 'grain-oauth-callback') .register('*', 'grain-timeline') .connect(outlet); diff --git a/src/components/pages/grain-onboarding.js b/src/components/pages/grain-onboarding.js new file mode 100644 index 0000000..decc67b --- /dev/null +++ b/src/components/pages/grain-onboarding.js @@ -0,0 +1,391 @@ +import { LitElement, html, css } from 'lit'; +import { router } from '../../router.js'; +import { auth } from '../../services/auth.js'; +import { grainApi } from '../../services/grain-api.js'; +import { mutations } from '../../services/mutations.js'; +import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js'; +import '../atoms/grain-icon.js'; +import '../atoms/grain-button.js'; +import '../atoms/grain-input.js'; +import '../atoms/grain-textarea.js'; +import '../atoms/grain-avatar.js'; +import '../atoms/grain-spinner.js'; +import '../molecules/grain-form-field.js'; +import '../molecules/grain-avatar-crop.js'; + +export class GrainOnboarding extends LitElement { + static properties = { + _loading: { state: true }, + _saving: { state: true }, + _error: { state: true }, + _displayName: { state: true }, + _description: { state: true }, + _avatarUrl: { state: true }, + _avatarBlob: { state: true }, + _newAvatarDataUrl: { state: true }, + _showAvatarCrop: { state: true }, + _cropImageUrl: { state: true } + }; + + static styles = css` + :host { + display: block; + width: 100%; + max-width: var(--feed-max-width); + min-height: 100%; + padding-bottom: 80px; + background: var(--color-bg-primary); + align-self: center; + } + .header { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xl) var(--space-sm) var(--space-lg); + text-align: center; + } + h1 { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0; + } + .subtitle { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin: 0; + } + .content { + padding: 0 var(--space-sm); + } + @media (min-width: 600px) { + .content { + padding: 0; + } + } + .avatar-section { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: var(--space-lg); + } + .avatar-wrapper { + position: relative; + cursor: pointer; + } + .avatar-overlay { + position: absolute; + bottom: 0; + right: 0; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--color-bg-primary); + border: 2px solid var(--color-border); + display: flex; + align-items: center; + justify-content: center; + color: var(--color-text-primary); + } + .avatar-preview { + width: 80px; + height: 80px; + border-radius: 50%; + object-fit: cover; + background: var(--color-bg-elevated); + } + input[type="file"] { + display: none; + } + .actions { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-lg) var(--space-sm); + border-top: 1px solid var(--color-border); + margin-top: var(--space-lg); + } + @media (min-width: 600px) { + .actions { + padding-left: 0; + padding-right: 0; + } + } + .skip-button { + background: none; + border: none; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + cursor: pointer; + padding: var(--space-sm); + text-align: center; + } + .skip-button:hover { + color: var(--color-text-primary); + text-decoration: underline; + } + .error { + color: var(--color-danger, #dc3545); + font-size: var(--font-size-sm); + padding: var(--space-sm); + text-align: center; + } + .loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-md); + padding: var(--space-xl); + min-height: 300px; + } + .loading p { + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + } + `; + + constructor() { + super(); + this._loading = true; + this._saving = false; + this._error = null; + this._displayName = ''; + this._description = ''; + this._avatarUrl = ''; + this._avatarBlob = null; + this._newAvatarDataUrl = null; + this._showAvatarCrop = false; + this._cropImageUrl = null; + } + + async connectedCallback() { + super.connectedCallback(); + + if (!auth.isAuthenticated) { + router.replace('/'); + return; + } + + await this.#checkAndLoad(); + } + + async #checkAndLoad() { + try { + const client = auth.getClient(); + + // Check if user already has a Grain profile + const hasProfile = await grainApi.hasGrainProfile(client); + if (hasProfile) { + this.#redirectToDestination(); + return; + } + + // Fetch Bluesky profile to prefill + const bskyProfile = await grainApi.getBlueskyProfile(client); + this._displayName = bskyProfile.displayName; + this._description = bskyProfile.description; + this._avatarUrl = bskyProfile.avatarUrl; + this._avatarBlob = bskyProfile.avatarBlob; + } catch (err) { + console.error('Failed to load profile data:', err); + } finally { + this._loading = false; + } + } + + #redirectToDestination() { + const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; + sessionStorage.removeItem('oauth_return_url'); + router.replace(returnUrl); + } + + #handleDisplayNameChange(e) { + this._displayName = e.detail.value.slice(0, 64); + } + + #handleDescriptionChange(e) { + this._description = e.detail.value.slice(0, 256); + } + + #handleAvatarClick() { + this.shadowRoot.querySelector('#avatar-input').click(); + } + + async #handleAvatarChange(e) { + const input = e.target; + const file = input.files?.[0]; + if (!file) return; + + input.value = ''; + + try { + const dataUrl = await readFileAsDataURL(file); + const resized = await resizeImage(dataUrl, { + width: 2000, + height: 2000, + maxSize: 900000 + }); + this._cropImageUrl = resized.dataUrl; + this._showAvatarCrop = true; + } catch (err) { + console.error('Failed to process avatar:', err); + this._error = 'Failed to process image'; + } + } + + #handleCropCancel() { + this._showAvatarCrop = false; + this._cropImageUrl = null; + } + + #handleCrop(e) { + this._showAvatarCrop = false; + this._cropImageUrl = null; + this._newAvatarDataUrl = e.detail.dataUrl; + this._avatarBlob = null; + } + + get #displayedAvatarUrl() { + if (this._newAvatarDataUrl) return this._newAvatarDataUrl; + return this._avatarUrl; + } + + async #handleSave() { + if (this._saving) return; + + this._saving = true; + this._error = null; + + try { + const input = { + createdAt: new Date().toISOString() + }; + + const displayName = this._displayName.trim(); + const description = this._description.trim(); + if (displayName) input.displayName = displayName; + if (description) input.description = description; + + if (this._newAvatarDataUrl) { + const base64Data = this._newAvatarDataUrl.split(',')[1]; + const blob = await mutations.uploadBlob(base64Data, 'image/jpeg'); + input.avatar = { + $type: 'blob', + ref: { $link: blob.ref }, + mimeType: blob.mimeType, + size: blob.size + }; + } else if (this._avatarBlob) { + input.avatar = this._avatarBlob; + } + + await mutations.updateProfile(input); + this.#redirectToDestination(); + } catch (err) { + console.error('Failed to save profile:', err); + this._error = err.message || 'Failed to save profile. Please try again.'; + } finally { + this._saving = false; + } + } + + async #handleSkip() { + if (this._saving) return; + + this._saving = true; + this._error = null; + + try { + await mutations.createEmptyProfile(); + this.#redirectToDestination(); + } catch (err) { + console.error('Failed to skip onboarding:', err); + this._error = err.message || 'Something went wrong. Please try again.'; + } finally { + this._saving = false; + } + } + + render() { + if (this._loading) { + return html` +
+ +

Loading...

+
+ `; + } + + return html` +
+

Welcome to Grain

+

Set up your profile to get started

+
+ + ${this._error ? html`

${this._error}

` : ''} + +
+
+
+ ${this.#displayedAvatarUrl ? html` + Profile avatar + ` : html` + + `} +
+ +
+
+ +
+ + + + + + + + +
+ +
+ Save & Continue + +
+ + + `; + } +} + +customElements.define('grain-onboarding', GrainOnboarding); diff --git a/src/services/auth.js b/src/services/auth.js index ac727ba..20fd76c 100644 --- a/src/services/auth.js +++ b/src/services/auth.js @@ -1,5 +1,6 @@ import { createQuicksliceClient } from 'quickslice-client-js'; import { router } from '../router.js'; +import { grainApi } from './grain-api.js'; class AuthService { #client = null; @@ -19,6 +20,17 @@ class AuthService { // Handle OAuth callback if present if (window.location.search.includes('code=')) { await this.#client.handleRedirectCallback(); + + // Check if user has a Grain profile + const hasProfile = await grainApi.hasGrainProfile(this.#client); + + if (!hasProfile) { + // First-time user - redirect to onboarding + window.location.replace('/onboarding'); + return; + } + + // Existing user - redirect to their destination const returnUrl = sessionStorage.getItem('oauth_return_url') || '/'; sessionStorage.removeItem('oauth_return_url'); window.location.replace(returnUrl); diff --git a/src/services/grain-api.js b/src/services/grain-api.js index 81b0de0..ad5e069 100644 --- a/src/services/grain-api.js +++ b/src/services/grain-api.js @@ -1121,6 +1121,53 @@ class GrainApiService { return did; } + + async hasGrainProfile(client) { + const result = await client.query(` + query { + viewer { + socialGrainActorProfileByDid { + displayName + } + } + } + `); + return !!result.viewer?.socialGrainActorProfileByDid; + } + + async getBlueskyProfile(client) { + const result = await client.query(` + query { + viewer { + did + handle + appBskyActorProfileByDid { + displayName + description + avatar { url ref mimeType size } + } + } + } + `); + + const viewer = result.viewer; + const profile = viewer?.appBskyActorProfileByDid; + const avatar = profile?.avatar; + + return { + did: viewer?.did || '', + handle: viewer?.handle || '', + displayName: profile?.displayName || '', + description: profile?.description || '', + avatarUrl: avatar?.url || '', + avatarBlob: avatar ? { + $type: 'blob', + ref: { $link: avatar.ref }, + mimeType: avatar.mimeType, + size: avatar.size + } : null + }; + } } export const grainApi = new GrainApiService(); diff --git a/src/services/mutations.js b/src/services/mutations.js index 6af0aa9..0a5a8ef 100644 --- a/src/services/mutations.js +++ b/src/services/mutations.js @@ -198,6 +198,26 @@ class MutationsService { // Refresh user data await auth.refreshUser(); } + + async updateProfile(input) { + const client = auth.getClient(); + + await client.mutate(` + mutation UpdateProfile($rkey: String!, $input: SocialGrainActorProfileInput!) { + updateSocialGrainActorProfile(rkey: $rkey, input: $input) { + uri + } + } + `, { rkey: 'self', input }); + + await auth.refreshUser(); + } + + async createEmptyProfile() { + return this.updateProfile({ + createdAt: new Date().toISOString() + }); + } } export const mutations = new MutationsService();