diff --git a/cypress/e2e/account/avatar-management.cy.ts b/cypress/e2e/account/avatar-management.cy.ts
deleted file mode 100644
index 45ad81af..00000000
--- a/cypress/e2e/account/avatar-management.cy.ts
+++ /dev/null
@@ -1,517 +0,0 @@
-import { v4 as uuidv4 } from 'uuid';
-
-import { APP_EVENTS } from '../../../src/application/constants';
-
-import { AuthTestUtils } from '../../support/auth-utils';
-import { AvatarSelectors } from '../../support/avatar-selectors';
-import { dbUtils } from '../../support/db-utils';
-import { WorkspaceSelectors } from '../../support/selectors';
-
-describe('Avatar Management', () => {
- const generateRandomEmail = () => `${uuidv4()}@appflowy.io`;
- const APPFLOWY_BASE_URL = Cypress.env('APPFLOWY_BASE_URL');
-
- /**
- * Helper function to update user avatar via API
- */
- const updateAvatarViaAPI = (avatarUrl: string) => {
- return cy.window().then((win) => {
- const tokenStr = win.localStorage.getItem('token');
-
- if (!tokenStr) {
- throw new Error('No token found in localStorage');
- }
-
- const token = JSON.parse(tokenStr);
-
- return cy.request({
- method: 'POST',
- url: `${APPFLOWY_BASE_URL}/api/user/update`,
- headers: {
- Authorization: `Bearer ${token.access_token}`,
- 'Content-Type': 'application/json',
- },
- body: {
- metadata: {
- icon_url: avatarUrl,
- },
- },
- failOnStatusCode: false,
- });
- });
- };
-
- beforeEach(() => {
- // Suppress known transient errors
- cy.on('uncaught:exception', (err) => {
- if (
- err.message.includes('Minified React error') ||
- err.message.includes('View not found') ||
- err.message.includes('No workspace or service found')
- ) {
- return false;
- }
-
- return true;
- });
- cy.viewport(1280, 720);
- });
-
- describe('Avatar Upload via API', () => {
- it('should update avatar URL via API and display in UI', () => {
- const testEmail = generateRandomEmail();
- const authUtils = new AuthTestUtils();
- const testAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=test';
-
- cy.log('Step 1: Visit login page');
- cy.visit('/login', { failOnStatusCode: false });
- cy.wait(2000);
-
- cy.log('Step 2: Sign in with test account');
- authUtils.signInWithTestUrl(testEmail).then(() => {
- cy.url({ timeout: 30000 }).should('include', '/app');
- cy.wait(3000);
-
- cy.log('Step 3: Update avatar via API');
- updateAvatarViaAPI(testAvatarUrl).then((response) => {
- expect(response.status).to.equal(200);
-
- cy.log('Step 4: Reload page to see updated avatar');
- cy.reload();
- cy.wait(3000);
-
- cy.log('Step 5: Open Account Settings to verify avatar');
- WorkspaceSelectors.dropdownTrigger().click();
- cy.wait(1000);
- cy.contains('Settings').click();
- AvatarSelectors.accountSettingsDialog().should('be.visible');
-
- cy.log('Step 6: Verify avatar image uses updated URL');
- AvatarSelectors.avatarImage().should('exist').and('have.attr', 'src', testAvatarUrl);
- });
- });
- });
-
- it('should display emoji as avatar via API', () => {
- const testEmail = generateRandomEmail();
- const authUtils = new AuthTestUtils();
- const testEmoji = '🎨';
-
- cy.log('Step 1: Visit login page');
- cy.visit('/login', { failOnStatusCode: false });
- cy.wait(2000);
-
- cy.log('Step 2: Sign in with test account');
- authUtils.signInWithTestUrl(testEmail).then(() => {
- cy.url({ timeout: 30000 }).should('include', '/app');
- cy.wait(3000);
-
- cy.log('Step 3: Update avatar to emoji via API');
- updateAvatarViaAPI(testEmoji).then((response) => {
- expect(response.status).to.equal(200);
-
- cy.log('Step 4: Reload page');
- cy.reload();
- cy.wait(3000);
-
- cy.log('Step 5: Open Account Settings');
- WorkspaceSelectors.dropdownTrigger().click();
- cy.wait(1000);
- cy.contains('Settings').click();
- AvatarSelectors.accountSettingsDialog().should('be.visible');
-
- cy.log('Step 6: Verify emoji is displayed in fallback');
- AvatarSelectors.avatarFallback().should('contain.text', testEmoji);
- });
- });
- });
-
- it('should display fallback character when no avatar is set', () => {
- const testEmail = generateRandomEmail();
- const authUtils = new AuthTestUtils();
-
- cy.log('Step 1: Visit login page');
- cy.visit('/login', { failOnStatusCode: false });
- cy.wait(2000);
-
- cy.log('Step 2: Sign in with test account (no avatar set)');
- authUtils.signInWithTestUrl(testEmail).then(() => {
- cy.url({ timeout: 30000 }).should('include', '/app');
- cy.wait(3000);
-
- cy.log('Step 3: Open Account Settings');
- WorkspaceSelectors.dropdownTrigger().click();
- cy.wait(1000);
- cy.contains('Settings').click();
- AvatarSelectors.accountSettingsDialog().should('be.visible');
-
- cy.log('Step 4: Verify fallback is displayed (first letter or ?)');
- AvatarSelectors.avatarFallback().should('be.visible');
- });
- });
- });
-
- describe('Authenticated Image Loading', () => {
- it('should send Authorization header when loading file storage avatars', () => {
- const testEmail = generateRandomEmail();
- const authUtils = new AuthTestUtils();
- const fileStorageUrl = `/api/file_storage/workspace-id/v1/blob/view-id/file-id`;
-
- // Mock file storage endpoint and verify auth header
- cy.intercept('GET', '**/api/file_storage/**', (req) => {
- cy.log('Step 5: Verify Authorization header is present');
- expect(req.headers).to.have.property('authorization');
- expect(req.headers.authorization).to.match(/^Bearer .+/);
-
- // Return mock image data
- req.reply({
- statusCode: 200,
- headers: {
- 'Content-Type': 'image/png',
- },
- body: 'mock-image-data',
- });
- }).as('avatarFileLoad');
-
- cy.log('Step 1: Visit login page');
- cy.visit('/login', { failOnStatusCode: false });
- cy.wait(2000);
-
- cy.log('Step 2: Sign in with test account');
- authUtils.signInWithTestUrl(testEmail).then(() => {
- cy.url({ timeout: 30000 }).should('include', '/app');
- cy.wait(3000);
-
- cy.log('Step 3: Update avatar to file storage URL via API');
- updateAvatarViaAPI(fileStorageUrl).then((response) => {
- expect(response.status).to.equal(200);
-
- cy.log('Step 4: Reload page to trigger avatar load');
- cy.reload();
- cy.wait(3000);
-
- cy.log('Step 6: Wait for avatar to load with authentication');
- cy.wait('@avatarFileLoad', { timeout: 10000 });
-
- cy.log('Step 7: Open Account Settings to verify');
- WorkspaceSelectors.dropdownTrigger().click();
- cy.wait(1000);
- cy.contains('Settings').click();
- AvatarSelectors.accountSettingsDialog().should('be.visible');
-
- cy.log('Step 8: Verify avatar displays blob URL');
- AvatarSelectors.avatarImage()
- .should('exist')
- .and('have.attr', 'src')
- .should('match', /^blob:/);
- });
- });
- });
-
- it('should handle failed file storage loads gracefully', () => {
- const testEmail = generateRandomEmail();
- const authUtils = new AuthTestUtils();
- const fileStorageUrl = `/api/file_storage/workspace-id/v1/blob/invalid/invalid`;
-
- // Mock file storage endpoint with 404 error
- cy.intercept('GET', '**/api/file_storage/**', {
- statusCode: 404,
- body: { error: 'File not found' },
- }).as('avatarFileLoadFailed');
-
- cy.log('Step 1: Visit login page');
- cy.visit('/login', { failOnStatusCode: false });
- cy.wait(2000);
-
- cy.log('Step 2: Sign in with test account');
- authUtils.signInWithTestUrl(testEmail).then(() => {
- cy.url({ timeout: 30000 }).should('include', '/app');
- cy.wait(3000);
-
- cy.log('Step 3: Update avatar to invalid file storage URL via API');
- updateAvatarViaAPI(fileStorageUrl).then((response) => {
- expect(response.status).to.equal(200);
-
- cy.log('Step 4: Reload page');
- cy.reload();
- cy.wait(3000);
-
- cy.log('Step 5: Wait for failed load');
- cy.wait('@avatarFileLoadFailed', { timeout: 10000 });
-
- cy.log('Step 6: Open Account Settings');
- WorkspaceSelectors.dropdownTrigger().click();
- cy.wait(1000);
- cy.contains('Settings').click();
- AvatarSelectors.accountSettingsDialog().should('be.visible');
-
- cy.log('Step 7: Verify fallback avatar is displayed');
- AvatarSelectors.avatarFallback().should('be.visible');
- });
- });
- });
- });
-
- describe('Database Schema Verification', () => {
- it('should have database version 2 with workspace_member_profiles table', () => {
- const testEmail = generateRandomEmail();
- const authUtils = new AuthTestUtils();
-
- cy.log('Step 1: Visit login page');
- cy.visit('/login', { failOnStatusCode: false });
- cy.wait(2000);
-
- cy.log('Step 2: Sign in with test account');
- authUtils.signInWithTestUrl(testEmail).then(() => {
- cy.url({ timeout: 30000 }).should('include', '/app');
- cy.wait(3000);
-
- cy.log('Step 3: Verify database version is 2');
- dbUtils.getDBVersion().should('equal', 2);
-
- cy.log('Step 4: Verify workspace_member_profiles table exists');
- dbUtils.tableExists('workspace_member_profiles').should('be.true');
-
- cy.log('Step 5: Verify other tables still exist');
- dbUtils.tableExists('view_metas').should('be.true');
- dbUtils.tableExists('users').should('be.true');
- dbUtils.tableExists('rows').should('be.true');
- });
- });
-
- it('should verify workspace_member_profiles table schema', () => {
- const testEmail = generateRandomEmail();
- const authUtils = new AuthTestUtils();
-
- cy.log('Step 1: Visit login page');
- cy.visit('/login', { failOnStatusCode: false });
- cy.wait(2000);
-
- cy.log('Step 2: Sign in with test account');
- authUtils.signInWithTestUrl(testEmail).then(() => {
- cy.url({ timeout: 30000 }).should('include', '/app');
- cy.wait(3000);
-
- cy.log('Step 3: Verify database schema');
- dbUtils.verifySchema(2, ['view_metas', 'users', 'rows', 'workspace_member_profiles']).should('be.true');
- });
- });
- });
-
- describe('Workspace Avatar Priority', () => {
- it('should prioritize workspace avatar over user avatar', () => {
- const testEmail = generateRandomEmail();
- const authUtils = new AuthTestUtils();
- const userAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=user';
- const workspaceAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=workspace';
-
- cy.log('Step 1: Visit login page');
- cy.visit('/login', { failOnStatusCode: false });
- cy.wait(2000);
-
- cy.log('Step 2: Sign in with test account');
- authUtils.signInWithTestUrl(testEmail).then(() => {
- cy.url({ timeout: 30000 }).should('include', '/app');
- cy.wait(3000);
-
- cy.log('Step 3: Set user profile avatar via API');
- updateAvatarViaAPI(userAvatarUrl).then((response) => {
- expect(response.status).to.equal(200);
-
- cy.log('Step 4: Get workspace and user IDs');
- cy.window().then((win) => {
- const userId = win.localStorage.getItem('af_user_id');
- const workspaceId = win.localStorage.getItem('af_current_workspace_id');
-
- if (userId && workspaceId) {
- cy.log('Step 5: Add workspace member profile to database');
- dbUtils.putWorkspaceMemberProfile({
- workspace_id: workspaceId,
- user_uuid: userId,
- person_id: userId,
- name: 'Test User',
- email: testEmail,
- role: 1,
- avatar_url: workspaceAvatarUrl,
- cover_image_url: null,
- custom_image_url: null,
- description: null,
- invited: false,
- last_mentioned_at: null,
- updated_at: Date.now(),
- });
-
- cy.log('Step 6: Reload page to trigger avatar update');
- cy.reload();
- cy.wait(3000);
-
- cy.log('Step 7: Verify workspace avatar is in database');
- dbUtils
- .getWorkspaceMemberProfile(workspaceId, userId)
- .should('not.be.null')
- .and('have.property', 'avatar_url', workspaceAvatarUrl);
- }
- });
- });
- });
- });
- });
-
- describe('Workspace Profile Notifications', () => {
- it('should preserve avatar when notification omits avatar fields', () => {
- const testEmail = generateRandomEmail();
- const authUtils = new AuthTestUtils();
- const workspaceAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=workspace-notification';
- let userUuid = '';
- let workspaceId = '';
-
- cy.log('Step 1: Visit login page');
- cy.visit('/login', { failOnStatusCode: false });
- cy.wait(2000);
-
- cy.log('Step 2: Sign in with test account');
- authUtils.signInWithTestUrl(testEmail).then(() => {
- cy.url({ timeout: 30000 }).should('include', '/app');
- cy.wait(3000);
-
- cy.log('Step 3: Capture current workspace and user');
- cy.window().then((win) => {
- userUuid = win.localStorage.getItem('af_user_id') || '';
- workspaceId = win.localStorage.getItem('af_current_workspace_id') || '';
-
- expect(userUuid, 'user UUID').to.not.be.empty;
- expect(workspaceId, 'workspace ID').to.not.be.empty;
- });
-
- cy.log('Step 4: Seed workspace member profile with avatar');
- cy.then(() => dbUtils.clearWorkspaceMemberProfiles());
- cy.then(() =>
- dbUtils.putWorkspaceMemberProfile({
- workspace_id: workspaceId,
- user_uuid: userUuid,
- person_id: userUuid,
- name: 'Seeded User',
- email: testEmail,
- role: 1,
- avatar_url: workspaceAvatarUrl,
- cover_image_url: null,
- custom_image_url: null,
- description: 'Initial description',
- invited: false,
- last_mentioned_at: null,
- updated_at: Date.now(),
- })
- );
-
- cy.log('Step 5: Reload to ensure hooks consume seeded data');
- cy.reload();
- cy.wait(3000);
-
- cy.log('Step 6: Emit workspace notification without avatar');
- cy.window().then((win) => {
- const emitter = (win as typeof window & { __APPFLOWY_EVENT_EMITTER__?: { emit: (...args: unknown[]) => void } })
- .__APPFLOWY_EVENT_EMITTER__;
-
- expect(emitter, 'App event emitter').to.exist;
-
- emitter?.emit(APP_EVENTS.WORKSPACE_MEMBER_PROFILE_CHANGED, {
- userUuid,
- name: 'Updated Test User',
- description: 'Notification without avatar',
- });
- });
-
- cy.wait(1000);
-
- cy.log('Step 7: Verify avatar remains unchanged');
- dbUtils.getWorkspaceMemberProfile(workspaceId, userUuid).should((profile) => {
- expect(profile, 'workspace profile').to.not.be.null;
- expect(profile?.avatar_url).to.equal(workspaceAvatarUrl);
- expect(profile?.name).to.equal('Updated Test User');
- expect(profile?.description).to.equal('Notification without avatar');
- });
- });
- });
- });
-
- describe('Avatar Persistence', () => {
- it('should persist avatar across page reloads', () => {
- const testEmail = generateRandomEmail();
- const authUtils = new AuthTestUtils();
- const testAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=persist';
-
- cy.log('Step 1: Visit login page');
- cy.visit('/login', { failOnStatusCode: false });
- cy.wait(2000);
-
- cy.log('Step 2: Sign in with test account');
- authUtils.signInWithTestUrl(testEmail).then(() => {
- cy.url({ timeout: 30000 }).should('include', '/app');
- cy.wait(3000);
-
- cy.log('Step 3: Set avatar via API');
- updateAvatarViaAPI(testAvatarUrl).then((response) => {
- expect(response.status).to.equal(200);
-
- cy.log('Step 4: Reload page');
- cy.reload();
- cy.wait(3000);
-
- cy.log('Step 5: Open Account Settings');
- WorkspaceSelectors.dropdownTrigger().click();
- cy.wait(1000);
- cy.contains('Settings').click();
- AvatarSelectors.accountSettingsDialog().should('be.visible');
-
- cy.log('Step 6: Verify avatar persisted across reloads');
- AvatarSelectors.avatarImage().should('exist').and('have.attr', 'src', testAvatarUrl);
- });
- });
- });
- });
-
- describe('Error Handling', () => {
- it('should handle missing AppProvider gracefully (publish page scenario)', () => {
- cy.log('Step 1: Test that hook does not throw when AppProvider is missing');
- cy.log('Note: This is validated by the hook using useContext instead of throwing hooks');
- cy.log('The hook will return null when context is unavailable');
-
- // This test verifies the code pattern exists
- // Actual publish page testing would require publishing a page first
- expect(true).to.be.true;
- });
-
- it('should handle empty avatar URL gracefully', () => {
- const testEmail = generateRandomEmail();
- const authUtils = new AuthTestUtils();
-
- cy.log('Step 1: Visit login page');
- cy.visit('/login', { failOnStatusCode: false });
- cy.wait(2000);
-
- cy.log('Step 2: Sign in with test account');
- authUtils.signInWithTestUrl(testEmail).then(() => {
- cy.url({ timeout: 30000 }).should('include', '/app');
- cy.wait(3000);
-
- cy.log('Step 3: Set avatar to empty string via API');
- updateAvatarViaAPI('').then((response) => {
- expect(response.status).to.equal(200);
-
- cy.log('Step 4: Reload page');
- cy.reload();
- cy.wait(3000);
-
- cy.log('Step 5: Open Account Settings');
- WorkspaceSelectors.dropdownTrigger().click();
- cy.wait(1000);
- cy.contains('Settings').click();
- AvatarSelectors.accountSettingsDialog().should('be.visible');
-
- cy.log('Step 6: Verify fallback avatar is displayed');
- AvatarSelectors.avatarFallback().should('be.visible');
- });
- });
- });
- });
-});
diff --git a/cypress/e2e/account/avatar/avatar-api.cy.ts b/cypress/e2e/account/avatar/avatar-api.cy.ts
new file mode 100644
index 00000000..dd5f27f9
--- /dev/null
+++ b/cypress/e2e/account/avatar/avatar-api.cy.ts
@@ -0,0 +1,193 @@
+import { avatarTestUtils } from './avatar-test-utils';
+
+const { generateRandomEmail, setupBeforeEach, imports } = avatarTestUtils;
+const { updateUserMetadata, AuthTestUtils, AvatarSelectors, WorkspaceSelectors } = imports;
+
+describe('Avatar API', () => {
+ beforeEach(() => {
+ setupBeforeEach();
+ });
+
+ describe('Avatar Upload via API', () => {
+ it('should update avatar URL via API and display in UI', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const testAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=test';
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail);
+
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Update avatar via API');
+ updateUserMetadata(testAvatarUrl).then((response) => {
+ cy.task('log', `API Response: ${JSON.stringify(response)}`);
+ expect(response.status).to.equal(200);
+ });
+
+ cy.task('log', 'Step 4: Reload page to see updated avatar');
+ cy.reload();
+ cy.wait(3000);
+
+ cy.task('log', 'Step 5: Open Account Settings to verify avatar');
+ WorkspaceSelectors.dropdownTrigger().click();
+ cy.wait(1000);
+ cy.get('[data-testid="account-settings-button"]').click();
+ AvatarSelectors.accountSettingsDialog().should('be.visible');
+
+ cy.task('log', 'Step 6: Verify avatar image is displayed in Account Settings');
+ // Note: Account Settings dialog may not display avatar directly
+ // The avatar is displayed via getUserIconUrl which prioritizes workspace member avatar
+ // Since we updated user metadata (icon_url), it should be available
+ // But the actual display location might be in the workspace dropdown or elsewhere
+
+ // Wait for any avatar image to be present and loaded
+ // The AvatarImage component loads asynchronously and sets opacity to 0 while loading
+ cy.get('[data-testid="avatar-image"]', { timeout: 10000 })
+ .should('exist')
+ .should(($imgs) => {
+ // Find the first visible avatar image (opacity not 0)
+ let foundVisible = false;
+ $imgs.each((index, img) => {
+ const $img = Cypress.$(img);
+ const opacity = $img.css('opacity');
+ const src = $img.attr('src');
+ if (opacity !== '0' && src && src.length > 0) {
+ foundVisible = true;
+ return false; // break
+ }
+ });
+ expect(foundVisible, 'At least one avatar image should be visible').to.be.true;
+ });
+
+ // Verify that the avatar image has loaded (check for non-empty src and visible state)
+ cy.get('[data-testid="avatar-image"]').then(($imgs) => {
+ let foundLoaded = false;
+ $imgs.each((index, img) => {
+ const $img = Cypress.$(img);
+ const opacity = parseFloat($img.css('opacity') || '0');
+ const src = $img.attr('src') || '';
+
+ if (opacity > 0 && src.length > 0) {
+ foundLoaded = true;
+ cy.task('log', `Found loaded avatar image with src: ${src.substring(0, 50)}...`);
+ return false; // break
+ }
+ });
+ expect(foundLoaded, 'At least one avatar image should be loaded and visible').to.be.true;
+ });
+ });
+
+ it('test direct API call', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const testAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=test';
+
+ cy.task('log', '========== Step 1: Visit login page ==========');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', '========== Step 2: Sign in with test account ==========');
+ authUtils.signInWithTestUrl(testEmail);
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', '========== Step 3: Get token from localStorage ==========');
+ cy.window()
+ .its('localStorage')
+ .invoke('getItem', 'token')
+ .then((tokenStr) => {
+ cy.task('log', `Token string: ${tokenStr ? 'Found' : 'Not found'}`);
+ const token = JSON.parse(tokenStr);
+ const accessToken = token.access_token;
+ cy.task('log', `Access token: ${accessToken ? 'Present (length: ' + accessToken.length + ')' : 'Missing'}`);
+ });
+
+ cy.task('log', '========== Step 4: Making API request ==========');
+ cy.task('log', `URL: ${avatarTestUtils.APPFLOWY_BASE_URL}/api/user/update`);
+ cy.task('log', `Avatar URL: ${testAvatarUrl}`);
+
+ updateUserMetadata(testAvatarUrl).then((response) => {
+ cy.task('log', '========== Step 5: Checking response ==========');
+ cy.task('log', `Response is null: ${response === null}`);
+ cy.task('log', `Response type: ${typeof response}`);
+ cy.task('log', `Response status: ${response?.status}`);
+ cy.task('log', `Response body: ${JSON.stringify(response?.body)}`);
+ cy.task('log', `Response headers: ${JSON.stringify(response?.headers)}`);
+
+ expect(response).to.not.be.null;
+ expect(response.status).to.equal(200);
+
+ if (response.body) {
+ cy.task('log', `Response body code: ${response.body.code}`);
+ cy.task('log', `Response body message: ${response.body.message}`);
+ }
+ });
+ });
+
+ it('should display emoji as avatar via API', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const testEmoji = '🎨';
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail);
+
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Update avatar to emoji via API');
+ updateUserMetadata(testEmoji).then((response) => {
+ expect(response).to.not.be.null;
+ expect(response.status).to.equal(200);
+ });
+
+ cy.task('log', 'Step 4: Reload page');
+ cy.reload();
+ cy.wait(3000);
+
+ cy.task('log', 'Step 5: Open Account Settings');
+ WorkspaceSelectors.dropdownTrigger().click();
+ cy.wait(1000);
+ cy.get('[data-testid="account-settings-button"]').click();
+ AvatarSelectors.accountSettingsDialog().should('be.visible');
+
+ cy.task('log', 'Step 6: Verify emoji is displayed in fallback');
+ AvatarSelectors.avatarFallback().should('contain.text', testEmoji);
+ });
+
+ it('should display fallback character when no avatar is set', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account (no avatar set)');
+ authUtils.signInWithTestUrl(testEmail).then(() => {
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Open workspace dropdown to see avatar');
+ WorkspaceSelectors.dropdownTrigger().click();
+ cy.wait(500);
+
+ cy.task('log', 'Step 4: Verify fallback is displayed in workspace dropdown avatar');
+ AvatarSelectors.workspaceDropdownAvatar().within(() => {
+ AvatarSelectors.avatarFallback().should('be.visible');
+ });
+ });
+ });
+ });
+});
+
diff --git a/cypress/e2e/account/avatar/avatar-database.cy.ts b/cypress/e2e/account/avatar/avatar-database.cy.ts
new file mode 100644
index 00000000..8fdd9122
--- /dev/null
+++ b/cypress/e2e/account/avatar/avatar-database.cy.ts
@@ -0,0 +1,52 @@
+import { avatarTestUtils } from './avatar-test-utils';
+
+const { generateRandomEmail, setupBeforeEach, imports } = avatarTestUtils;
+const { updateWorkspaceMemberAvatar, AuthTestUtils, dbUtils } = imports;
+
+describe('Avatar Database', () => {
+ beforeEach(() => {
+ setupBeforeEach();
+ });
+
+ describe('Database Verification', () => {
+ it('should store avatar in workspace_member_profiles table', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const testAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=db-test';
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail).then(() => {
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Set avatar via API');
+ dbUtils.getCurrentWorkspaceId().then((workspaceId) => {
+ expect(workspaceId).to.not.be.null;
+
+ updateWorkspaceMemberAvatar(workspaceId!, testAvatarUrl).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+
+ cy.wait(3000);
+
+ cy.task('log', 'Step 4: Verify avatar is stored in database');
+ dbUtils.getCurrentUserUuid().then((userUuid) => {
+ expect(userUuid).to.not.be.null;
+
+ dbUtils.getWorkspaceMemberProfile(workspaceId!, userUuid!).then((profile) => {
+ expect(profile).to.not.be.null;
+ expect(profile?.avatar_url).to.equal(testAvatarUrl);
+ expect(profile?.workspace_id).to.equal(workspaceId);
+ expect(profile?.user_uuid).to.equal(userUuid);
+ });
+ });
+ });
+ });
+ });
+ });
+});
+
diff --git a/cypress/e2e/account/avatar/avatar-header.cy.ts b/cypress/e2e/account/avatar/avatar-header.cy.ts
new file mode 100644
index 00000000..922ff82c
--- /dev/null
+++ b/cypress/e2e/account/avatar/avatar-header.cy.ts
@@ -0,0 +1,266 @@
+import { avatarTestUtils } from './avatar-test-utils';
+
+const { generateRandomEmail, setupBeforeEach, imports } = avatarTestUtils;
+const { APP_EVENTS, updateWorkspaceMemberAvatar, AuthTestUtils, AvatarSelectors, dbUtils } = imports;
+
+describe('Avatar Header Display', () => {
+ beforeEach(() => {
+ setupBeforeEach();
+ });
+
+ describe('Header Avatar Display (Top Right Corner)', () => {
+ it('should display avatar in header top right corner after setting workspace avatar', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const testAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=header-test';
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail).then(() => {
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Set avatar via workspace member profile API');
+ dbUtils.getCurrentWorkspaceId().then((workspaceId) => {
+ expect(workspaceId).to.not.be.null;
+
+ updateWorkspaceMemberAvatar(workspaceId!, testAvatarUrl).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+
+ cy.wait(2000);
+ cy.reload();
+ cy.wait(3000);
+
+ cy.task('log', 'Step 4: Interact with editor to trigger collaborative user awareness');
+ // Click on a page to open editor
+ cy.get('body').then(($body) => {
+ // Try to find and click on a page in the sidebar
+ if ($body.find('[data-testid*="page"]').length > 0) {
+ cy.get('[data-testid*="page"]').first().click();
+ } else if ($body.text().includes('Getting started')) {
+ cy.contains('Getting started').click();
+ }
+ });
+
+ cy.wait(2000);
+
+ // Interact with editor to make user appear in collaborative users
+ cy.get('[contenteditable="true"]').then(($editors) => {
+ if ($editors.length > 0) {
+ // Find main editor (not title)
+ let editorFound = false;
+ $editors.each((index, el) => {
+ const $el = Cypress.$(el);
+ if (!$el.attr('data-testid')?.includes('title') && !$el.hasClass('editor-title')) {
+ cy.wrap(el).click({ force: true });
+ cy.wait(500);
+ cy.wrap(el).type(' ', { force: true }); // Type space to trigger awareness
+ editorFound = true;
+ return false;
+ }
+ });
+ if (!editorFound && $editors.length > 0) {
+ cy.wrap($editors.last()).click({ force: true });
+ cy.wait(500);
+ cy.wrap($editors.last()).type(' ', { force: true });
+ }
+ }
+ });
+
+ cy.wait(2000);
+
+ cy.task('log', 'Step 5: Verify avatar appears in header top right corner');
+ // Wait for header to be visible
+ cy.get('.appflowy-top-bar').should('be.visible');
+
+ // Check if avatar container exists in header (collaborative users area)
+ // The current user's avatar will appear there when they're actively editing
+ cy.task('log', 'Header avatar area should be visible');
+ AvatarSelectors.headerAvatarContainer().should('exist');
+
+ // Verify avatar image or fallback is present
+ cy.get('.appflowy-top-bar [data-slot="avatar"]').should('have.length.at.least', 1);
+ });
+ });
+ });
+
+ it('should display emoji avatar in header when emoji is set', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const testEmoji = '🎨';
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail).then(() => {
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Set emoji avatar via API');
+ dbUtils.getCurrentWorkspaceId().then((workspaceId) => {
+ expect(workspaceId).to.not.be.null;
+
+ updateWorkspaceMemberAvatar(workspaceId!, testEmoji).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+
+ cy.wait(2000);
+ cy.reload();
+ cy.wait(3000);
+
+ cy.task('log', 'Step 4: Interact with editor to trigger collaborative user awareness');
+ // Click on a page to open editor
+ cy.get('body').then(($body) => {
+ if ($body.find('[data-testid*="page"]').length > 0) {
+ cy.get('[data-testid*="page"]').first().click();
+ } else if ($body.text().includes('Getting started')) {
+ cy.contains('Getting started').click();
+ }
+ });
+
+ cy.wait(2000);
+
+ // Interact with editor
+ cy.get('[contenteditable="true"]').then(($editors) => {
+ if ($editors.length > 0) {
+ let editorFound = false;
+ $editors.each((index, el) => {
+ const $el = Cypress.$(el);
+ if (!$el.attr('data-testid')?.includes('title') && !$el.hasClass('editor-title')) {
+ cy.wrap(el).click({ force: true });
+ cy.wait(500);
+ cy.wrap(el).type(' ', { force: true });
+ editorFound = true;
+ return false;
+ }
+ });
+ if (!editorFound && $editors.length > 0) {
+ cy.wrap($editors.last()).click({ force: true });
+ cy.wait(500);
+ cy.wrap($editors.last()).type(' ', { force: true });
+ }
+ }
+ });
+
+ cy.wait(2000);
+
+ cy.task('log', 'Step 5: Verify emoji appears in header avatar fallback');
+ cy.get('.appflowy-top-bar').should('be.visible');
+
+ // When user is actively editing, their avatar should appear in header
+ // Emoji avatars show in fallback
+ cy.task('log', 'Header should be visible with avatar area');
+ AvatarSelectors.headerAvatarContainer().should('exist');
+
+ // Verify emoji appears in fallback
+ cy.get('.appflowy-top-bar [data-slot="avatar"]').should('have.length.at.least', 1);
+ AvatarSelectors.headerAvatarFallback(0).should('contain.text', testEmoji);
+ });
+ });
+ });
+
+ it('should update header avatar when workspace member profile notification is received', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const testAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=header-notification';
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail).then(() => {
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Get user UUID and workspace ID');
+ dbUtils.getCurrentWorkspaceId().then((workspaceId) => {
+ expect(workspaceId).to.not.be.null;
+
+ dbUtils.getCurrentUserUuid().then((userUuid) => {
+ expect(userUuid).to.not.be.null;
+
+ cy.task('log', 'Step 4: Simulate workspace member profile changed notification');
+ cy.window().then((win) => {
+ const emitter = (win as typeof window & {
+ __APPFLOWY_EVENT_EMITTER__?: { emit: (...args: unknown[]) => void };
+ }).__APPFLOWY_EVENT_EMITTER__;
+
+ expect(emitter, 'Event emitter should be available').to.exist;
+
+ // Simulate notification with avatar URL update
+ emitter?.emit(APP_EVENTS.WORKSPACE_MEMBER_PROFILE_CHANGED, {
+ userUuid: userUuid,
+ name: 'Test User',
+ avatarUrl: testAvatarUrl,
+ });
+ });
+
+ cy.wait(2000);
+
+ cy.task('log', 'Step 5: Verify avatar is updated in database');
+ dbUtils.getWorkspaceMemberProfile(workspaceId!, userUuid!).then((profile) => {
+ expect(profile).to.not.be.null;
+ expect(profile?.avatar_url).to.equal(testAvatarUrl);
+ });
+
+ cy.task('log', 'Step 6: Interact with editor to trigger collaborative user awareness');
+ // Click on a page to open editor
+ cy.get('body').then(($body) => {
+ if ($body.find('[data-testid*="page"]').length > 0) {
+ cy.get('[data-testid*="page"]').first().click();
+ } else if ($body.text().includes('Getting started')) {
+ cy.contains('Getting started').click();
+ }
+ });
+
+ cy.wait(2000);
+
+ // Interact with editor
+ cy.get('[contenteditable="true"]').then(($editors) => {
+ if ($editors.length > 0) {
+ let editorFound = false;
+ $editors.each((index, el) => {
+ const $el = Cypress.$(el);
+ if (!$el.attr('data-testid')?.includes('title') && !$el.hasClass('editor-title')) {
+ cy.wrap(el).click({ force: true });
+ cy.wait(500);
+ cy.wrap(el).type(' ', { force: true });
+ editorFound = true;
+ return false;
+ }
+ });
+ if (!editorFound && $editors.length > 0) {
+ cy.wrap($editors.last()).click({ force: true });
+ cy.wait(500);
+ cy.wrap($editors.last()).type(' ', { force: true });
+ }
+ }
+ });
+
+ cy.wait(2000);
+
+ cy.task('log', 'Step 7: Verify header avatar area is visible and updated');
+ cy.get('.appflowy-top-bar').should('be.visible');
+ AvatarSelectors.headerAvatarContainer().should('exist');
+
+ // Verify avatar appears in header
+ cy.get('.appflowy-top-bar [data-slot="avatar"]').should('have.length.at.least', 1);
+
+ // Verify the avatar image uses the updated URL (if image is loaded)
+ // The avatar might show as image or fallback depending on loading state
+ // We already verified the database update in Step 5, so just verify avatar container exists
+ cy.task('log', 'Avatar container verified in header - database update confirmed in Step 5');
+ });
+ });
+ });
+ });
+ });
+});
+
diff --git a/cypress/e2e/account/avatar/avatar-notifications.cy.ts b/cypress/e2e/account/avatar/avatar-notifications.cy.ts
new file mode 100644
index 00000000..6b6494a5
--- /dev/null
+++ b/cypress/e2e/account/avatar/avatar-notifications.cy.ts
@@ -0,0 +1,186 @@
+import { avatarTestUtils } from './avatar-test-utils';
+
+const { generateRandomEmail, setupBeforeEach, imports } = avatarTestUtils;
+const { APP_EVENTS, updateWorkspaceMemberAvatar, AuthTestUtils, AvatarSelectors, dbUtils, WorkspaceSelectors } = imports;
+
+describe('Avatar Notifications', () => {
+ beforeEach(() => {
+ setupBeforeEach();
+ });
+
+ describe('Workspace Member Profile Notifications', () => {
+ it('should update avatar when workspace member profile notification is received', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const testAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=notification-test';
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail).then(() => {
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Get user UUID and workspace ID');
+ dbUtils.getCurrentWorkspaceId().then((workspaceId) => {
+ expect(workspaceId).to.not.be.null;
+
+ dbUtils.getCurrentUserUuid().then((userUuid) => {
+ expect(userUuid).to.not.be.null;
+
+ cy.task('log', 'Step 4: Simulate workspace member profile changed notification');
+ cy.window().then((win) => {
+ const emitter = (win as typeof window & {
+ __APPFLOWY_EVENT_EMITTER__?: { emit: (...args: unknown[]) => void };
+ }).__APPFLOWY_EVENT_EMITTER__;
+
+ expect(emitter, 'Event emitter should be available').to.exist;
+
+ // Simulate notification with avatar URL update
+ emitter?.emit(APP_EVENTS.WORKSPACE_MEMBER_PROFILE_CHANGED, {
+ userUuid: userUuid,
+ name: 'Test User',
+ avatarUrl: testAvatarUrl,
+ });
+ });
+
+ cy.wait(2000);
+
+ cy.task('log', 'Step 5: Verify avatar is updated in database');
+ dbUtils.getWorkspaceMemberProfile(workspaceId!, userUuid!).then((profile) => {
+ expect(profile).to.not.be.null;
+ expect(profile?.avatar_url).to.equal(testAvatarUrl);
+ });
+
+ cy.task('log', 'Step 6: Reload page and verify avatar persists');
+ cy.reload();
+ cy.wait(3000);
+
+ cy.task('log', 'Step 7: Open Account Settings to verify avatar');
+ WorkspaceSelectors.dropdownTrigger().click();
+ cy.wait(1000);
+ cy.get('[data-testid="account-settings-button"]').click();
+ AvatarSelectors.accountSettingsDialog().should('be.visible');
+
+ cy.task('log', 'Step 8: Verify avatar image uses updated URL');
+ AvatarSelectors.avatarImage().should('exist').and('have.attr', 'src', testAvatarUrl);
+ });
+ });
+ });
+ });
+
+ it('should preserve existing avatar when notification omits avatar field', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const existingAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=existing';
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail).then(() => {
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Set initial avatar via API');
+ dbUtils.getCurrentWorkspaceId().then((workspaceId) => {
+ expect(workspaceId).to.not.be.null;
+
+ updateWorkspaceMemberAvatar(workspaceId!, existingAvatarUrl).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+
+ cy.wait(2000);
+
+ cy.task('log', 'Step 4: Get user UUID and workspace ID');
+ dbUtils.getCurrentUserUuid().then((userUuid) => {
+ expect(userUuid).to.not.be.null;
+
+ cy.task('log', 'Step 5: Verify initial avatar is set');
+ dbUtils.getWorkspaceMemberProfile(workspaceId!, userUuid!).then((profile) => {
+ expect(profile?.avatar_url).to.equal(existingAvatarUrl);
+ });
+
+ cy.task('log', 'Step 6: Simulate notification without avatar field');
+ cy.window().then((win) => {
+ const emitter = (win as typeof window & {
+ __APPFLOWY_EVENT_EMITTER__?: { emit: (...args: unknown[]) => void };
+ }).__APPFLOWY_EVENT_EMITTER__;
+
+ // Simulate notification that only updates name, not avatar
+ emitter?.emit(APP_EVENTS.WORKSPACE_MEMBER_PROFILE_CHANGED, {
+ userUuid: userUuid,
+ name: 'Updated Name',
+ // avatarUrl is undefined - should preserve existing
+ });
+ });
+
+ cy.wait(2000);
+
+ cy.task('log', 'Step 7: Verify avatar is preserved');
+ dbUtils.getWorkspaceMemberProfile(workspaceId!, userUuid!).then((profile) => {
+ expect(profile?.avatar_url).to.equal(existingAvatarUrl);
+ expect(profile?.name).to.equal('Updated Name');
+ });
+ });
+ });
+ });
+ });
+
+ it('should clear avatar when notification sends empty string', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const testAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=to-clear';
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail).then(() => {
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Set initial avatar');
+ dbUtils.getCurrentWorkspaceId().then((workspaceId) => {
+ expect(workspaceId).to.not.be.null;
+
+ updateWorkspaceMemberAvatar(workspaceId!, testAvatarUrl).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+
+ cy.wait(2000);
+
+ dbUtils.getCurrentUserUuid().then((userUuid) => {
+ expect(userUuid).to.not.be.null;
+
+ cy.task('log', 'Step 4: Simulate notification with empty avatar');
+ cy.window().then((win) => {
+ const emitter = (win as typeof window & {
+ __APPFLOWY_EVENT_EMITTER__?: { emit: (...args: unknown[]) => void };
+ }).__APPFLOWY_EVENT_EMITTER__;
+
+ // Simulate notification that clears avatar
+ emitter?.emit(APP_EVENTS.WORKSPACE_MEMBER_PROFILE_CHANGED, {
+ userUuid: userUuid,
+ name: 'Test User',
+ avatarUrl: '', // Empty string should clear avatar
+ });
+ });
+
+ cy.wait(2000);
+
+ cy.task('log', 'Step 5: Verify avatar is cleared');
+ dbUtils.getWorkspaceMemberProfile(workspaceId!, userUuid!).then((profile) => {
+ expect(profile?.avatar_url).to.be.null;
+ });
+ });
+ });
+ });
+ });
+ });
+});
+
diff --git a/cypress/e2e/account/avatar/avatar-persistence.cy.ts b/cypress/e2e/account/avatar/avatar-persistence.cy.ts
new file mode 100644
index 00000000..b883322c
--- /dev/null
+++ b/cypress/e2e/account/avatar/avatar-persistence.cy.ts
@@ -0,0 +1,61 @@
+import { avatarTestUtils } from './avatar-test-utils';
+
+const { generateRandomEmail, setupBeforeEach, imports } = avatarTestUtils;
+const { updateWorkspaceMemberAvatar, AuthTestUtils, AvatarSelectors, dbUtils, WorkspaceSelectors } = imports;
+
+describe('Avatar Persistence', () => {
+ beforeEach(() => {
+ setupBeforeEach();
+ });
+
+ it('should persist avatar across page reloads', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const testAvatarUrl = 'https://api.dicebear.com/7.x/avataaars/svg?seed=persist';
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail).then(() => {
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Set avatar via workspace member profile API');
+ dbUtils.getCurrentWorkspaceId().then((workspaceId) => {
+ expect(workspaceId).to.not.be.null;
+
+ updateWorkspaceMemberAvatar(workspaceId!, testAvatarUrl).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+
+ cy.wait(2000);
+
+ cy.task('log', 'Step 4: Reload page');
+ cy.reload();
+ cy.wait(3000);
+
+ cy.task('log', 'Step 5: Verify avatar persisted');
+ WorkspaceSelectors.dropdownTrigger().click();
+ cy.wait(1000);
+ cy.get('[data-testid="account-settings-button"]').click();
+ AvatarSelectors.accountSettingsDialog().should('be.visible');
+
+ AvatarSelectors.avatarImage().should('exist').and('have.attr', 'src', testAvatarUrl);
+
+ cy.task('log', 'Step 6: Reload again to verify persistence');
+ cy.reload();
+ cy.wait(3000);
+
+ WorkspaceSelectors.dropdownTrigger().click();
+ cy.wait(1000);
+ cy.get('[data-testid="account-settings-button"]').click();
+ AvatarSelectors.accountSettingsDialog().should('be.visible');
+
+ AvatarSelectors.avatarImage().should('exist').and('have.attr', 'src', testAvatarUrl);
+ });
+ });
+ });
+});
+
diff --git a/cypress/e2e/account/avatar/avatar-priority.cy.ts b/cypress/e2e/account/avatar/avatar-priority.cy.ts
new file mode 100644
index 00000000..f92758d5
--- /dev/null
+++ b/cypress/e2e/account/avatar/avatar-priority.cy.ts
@@ -0,0 +1,57 @@
+import { avatarTestUtils } from './avatar-test-utils';
+
+const { generateRandomEmail, setupBeforeEach, imports } = avatarTestUtils;
+const { updateUserMetadata, updateWorkspaceMemberAvatar, AuthTestUtils, AvatarSelectors, dbUtils, WorkspaceSelectors } = imports;
+
+describe('Avatar Priority', () => {
+ beforeEach(() => {
+ setupBeforeEach();
+ });
+
+ it('should prioritize workspace avatar over user metadata avatar', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const userMetadataAvatar = 'https://api.dicebear.com/7.x/avataaars/svg?seed=user-metadata';
+ const workspaceAvatar = 'https://api.dicebear.com/7.x/avataaars/svg?seed=workspace';
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail).then(() => {
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Set user metadata avatar');
+ updateUserMetadata(userMetadataAvatar).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+
+ cy.wait(2000);
+
+ cy.task('log', 'Step 4: Set workspace member avatar');
+ dbUtils.getCurrentWorkspaceId().then((workspaceId) => {
+ expect(workspaceId).to.not.be.null;
+
+ updateWorkspaceMemberAvatar(workspaceId!, workspaceAvatar).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+
+ cy.wait(2000);
+ cy.reload();
+ cy.wait(3000);
+
+ cy.task('log', 'Step 5: Verify workspace avatar is displayed (priority)');
+ WorkspaceSelectors.dropdownTrigger().click();
+ cy.wait(1000);
+ cy.get('[data-testid="account-settings-button"]').click();
+ AvatarSelectors.accountSettingsDialog().should('be.visible');
+
+ // Workspace avatar should be displayed, not user metadata avatar
+ AvatarSelectors.avatarImage().should('exist').and('have.attr', 'src', workspaceAvatar);
+ });
+ });
+ });
+});
+
diff --git a/cypress/e2e/account/avatar/avatar-test-utils.ts b/cypress/e2e/account/avatar/avatar-test-utils.ts
new file mode 100644
index 00000000..1b837c4e
--- /dev/null
+++ b/cypress/e2e/account/avatar/avatar-test-utils.ts
@@ -0,0 +1,50 @@
+import { v4 as uuidv4 } from 'uuid';
+
+import { APP_EVENTS } from '../../../../src/application/constants';
+
+import { updateUserMetadata, updateWorkspaceMemberAvatar } from '../../../support/api-utils';
+import { AuthTestUtils } from '../../../support/auth-utils';
+import { AvatarSelectors } from '../../../support/avatar-selectors';
+import { dbUtils } from '../../../support/db-utils';
+import { WorkspaceSelectors } from '../../../support/selectors';
+
+/**
+ * Shared utilities and setup for avatar tests
+ */
+export const avatarTestUtils = {
+ generateRandomEmail: () => `${uuidv4()}@appflowy.io`,
+ APPFLOWY_BASE_URL: Cypress.env('APPFLOWY_BASE_URL'),
+
+ /**
+ * Common beforeEach setup for avatar tests
+ */
+ setupBeforeEach: () => {
+ // Suppress known transient errors
+ cy.on('uncaught:exception', (err) => {
+ if (
+ err.message.includes('Minified React error') ||
+ err.message.includes('View not found') ||
+ err.message.includes('No workspace or service found')
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+ cy.viewport(1280, 720);
+ },
+
+ /**
+ * Common imports for avatar tests
+ */
+ imports: {
+ APP_EVENTS,
+ updateUserMetadata,
+ updateWorkspaceMemberAvatar,
+ AuthTestUtils,
+ AvatarSelectors,
+ dbUtils,
+ WorkspaceSelectors,
+ },
+};
+
diff --git a/cypress/e2e/account/avatar/avatar-types.cy.ts b/cypress/e2e/account/avatar/avatar-types.cy.ts
new file mode 100644
index 00000000..beea62ac
--- /dev/null
+++ b/cypress/e2e/account/avatar/avatar-types.cy.ts
@@ -0,0 +1,86 @@
+import { avatarTestUtils } from './avatar-test-utils';
+
+const { generateRandomEmail, setupBeforeEach, imports } = avatarTestUtils;
+const { updateWorkspaceMemberAvatar, AuthTestUtils, AvatarSelectors, dbUtils, WorkspaceSelectors } = imports;
+
+describe('Avatar Types', () => {
+ beforeEach(() => {
+ setupBeforeEach();
+ });
+
+ it('should handle different avatar URL types (HTTP, HTTPS, data URL)', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const httpsAvatar = 'https://api.dicebear.com/7.x/avataaars/svg?seed=https';
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail).then(() => {
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Test HTTPS avatar URL');
+ dbUtils.getCurrentWorkspaceId().then((workspaceId) => {
+ expect(workspaceId).to.not.be.null;
+
+ updateWorkspaceMemberAvatar(workspaceId!, httpsAvatar).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+
+ cy.wait(2000);
+ cy.reload();
+ cy.wait(3000);
+
+ WorkspaceSelectors.dropdownTrigger().click();
+ cy.wait(1000);
+ cy.get('[data-testid="account-settings-button"]').click();
+ AvatarSelectors.accountSettingsDialog().should('be.visible');
+
+ AvatarSelectors.avatarImage().should('exist').and('have.attr', 'src', httpsAvatar);
+ });
+ });
+ });
+
+ it('should handle emoji avatars correctly', () => {
+ const testEmail = generateRandomEmail();
+ const authUtils = new AuthTestUtils();
+ const emojiAvatars = ['🎨', '🚀', '⭐', '🎯'];
+
+ cy.task('log', 'Step 1: Visit login page');
+ cy.visit('/login', { failOnStatusCode: false });
+ cy.wait(2000);
+
+ cy.task('log', 'Step 2: Sign in with test account');
+ authUtils.signInWithTestUrl(testEmail).then(() => {
+ cy.url({ timeout: 30000 }).should('include', '/app');
+ cy.wait(3000);
+
+ cy.task('log', 'Step 3: Test each emoji avatar');
+ dbUtils.getCurrentWorkspaceId().then((workspaceId) => {
+ expect(workspaceId).to.not.be.null;
+
+ emojiAvatars.forEach((emoji, index) => {
+ updateWorkspaceMemberAvatar(workspaceId!, emoji).then((response) => {
+ expect(response.status).to.equal(200);
+ });
+
+ cy.wait(2000);
+ cy.reload();
+ cy.wait(3000);
+
+ WorkspaceSelectors.dropdownTrigger().click();
+ cy.wait(1000);
+ cy.get('[data-testid="account-settings-button"]').click();
+ AvatarSelectors.accountSettingsDialog().should('be.visible');
+
+ // Emoji should be displayed in fallback, not as image
+ AvatarSelectors.avatarFallback().should('contain.text', emoji);
+ });
+ });
+ });
+ });
+});
+
diff --git a/cypress/support/api-utils.ts b/cypress/support/api-utils.ts
new file mode 100644
index 00000000..74ec8695
--- /dev/null
+++ b/cypress/support/api-utils.ts
@@ -0,0 +1,90 @@
+///
+
+/**
+ * API utilities for Cypress tests
+ * Provides reusable functions for common API operations
+ */
+
+const APPFLOWY_BASE_URL = Cypress.env('APPFLOWY_BASE_URL');
+
+/**
+ * Get access token from localStorage
+ */
+function getAccessToken(): Cypress.Chainable {
+ return cy
+ .window()
+ .its('localStorage')
+ .invoke('getItem', 'token')
+ .then(JSON.parse)
+ .its('access_token');
+}
+
+/**
+ * Update user metadata (including icon_url for user-level avatar)
+ * @param iconUrl - The avatar URL or emoji to set
+ */
+export function updateUserMetadata(iconUrl: string): Cypress.Chainable> {
+ return getAccessToken().then((accessToken) => {
+ return cy.request({
+ method: 'POST',
+ url: `${APPFLOWY_BASE_URL}/api/user/update`,
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: {
+ metadata: {
+ icon_url: iconUrl,
+ },
+ },
+ failOnStatusCode: false,
+ });
+ });
+}
+
+/**
+ * Update workspace member profile
+ * @param workspaceId - The workspace ID
+ * @param profileData - The profile data to update (name, avatar_url, etc.)
+ */
+export function updateWorkspaceMemberProfile(
+ workspaceId: string,
+ profileData: {
+ name?: string;
+ avatar_url?: string | null;
+ cover_image_url?: string | null;
+ custom_image_url?: string | null;
+ description?: string | null;
+ }
+): Cypress.Chainable> {
+ return getAccessToken().then((accessToken) => {
+ return cy.request({
+ method: 'PUT',
+ url: `${APPFLOWY_BASE_URL}/api/workspace/${workspaceId}/update-member-profile`,
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: profileData,
+ failOnStatusCode: false,
+ });
+ });
+}
+
+/**
+ * Update workspace member avatar (convenience function)
+ * @param workspaceId - The workspace ID
+ * @param avatarUrl - The avatar URL or emoji to set
+ * @param name - Optional name to update
+ */
+export function updateWorkspaceMemberAvatar(
+ workspaceId: string,
+ avatarUrl: string,
+ name: string = 'Test User'
+): Cypress.Chainable> {
+ return updateWorkspaceMemberProfile(workspaceId, {
+ name,
+ avatar_url: avatarUrl,
+ });
+}
+
diff --git a/cypress/support/avatar-selectors.ts b/cypress/support/avatar-selectors.ts
index 41e3f882..c47a68e3 100644
--- a/cypress/support/avatar-selectors.ts
+++ b/cypress/support/avatar-selectors.ts
@@ -11,11 +11,17 @@ export const AvatarSelectors = {
updateAvatarButton: () => cy.get(byTestId('update-avatar-button')),
// Avatar Display Elements
- avatarImage: () => cy.get('[data-slot="avatar-image"]'),
+ avatarImage: () => cy.get('[data-testid="avatar-image"]'),
avatarFallback: () => cy.get('[data-slot="avatar-fallback"]'),
// Workspace Dropdown Avatar
- workspaceDropdownAvatar: () => cy.get('[data-testid="workspace-dropdown"] [data-slot="avatar"]'),
+ workspaceDropdownAvatar: () => cy.get('[data-testid="workspace-dropdown-trigger"] [data-slot="avatar"]'),
+
+ // Header Avatars (Top Right Corner - Collaborative Users)
+ headerAvatars: () => cy.get('.appflowy-top-bar [data-slot="avatar"]'),
+ headerAvatarContainer: () => cy.get('.appflowy-top-bar').find('[class*="flex"][class*="-space-x-2"]').first(),
+ headerAvatarImage: (index = 0) => cy.get('.appflowy-top-bar [data-slot="avatar"]').eq(index).find('[data-slot="avatar-image"]'),
+ headerAvatarFallback: (index = 0) => cy.get('.appflowy-top-bar [data-slot="avatar"]').eq(index).find('[data-slot="avatar-fallback"]'),
// Date/Time Format Dropdowns (in Account Settings)
dateFormatDropdown: () => cy.get(byTestId('date-format-dropdown')),
diff --git a/cypress/support/db-utils.ts b/cypress/support/db-utils.ts
index 373d2f07..a533c426 100644
--- a/cypress/support/db-utils.ts
+++ b/cypress/support/db-utils.ts
@@ -81,8 +81,7 @@ export class DBTestUtils {
return new Cypress.Promise((resolve, reject) => {
const transaction = db.transaction(['workspace_member_profiles'], 'readonly');
const store = transaction.objectStore('workspace_member_profiles');
- const index = store.index('[workspace_id+user_uuid]');
- const request = index.get([workspaceId, userUuid]);
+ const request = store.get([workspaceId, userUuid]);
request.onsuccess = () => {
resolve(request.result || null);
@@ -162,6 +161,51 @@ export class DBTestUtils {
});
}
+ /**
+ * Get current user UUID from database using user ID from token
+ */
+ getCurrentUserUuid(): Cypress.Chainable {
+ return cy.window().then((win) => {
+ const tokenStr = win.localStorage.getItem('token');
+ if (!tokenStr) return null;
+
+ const token = JSON.parse(tokenStr);
+ const userUuid = token?.user?.id;
+ if (!userUuid) return null;
+
+ return this.openDB().then((db) => {
+ return new Cypress.Promise((resolve, reject) => {
+ const transaction = db.transaction(['users'], 'readonly');
+ const store = transaction.objectStore('users');
+ const request = store.get(userUuid);
+
+ request.onsuccess = () => {
+ const user = request.result;
+ resolve(user?.uuid || null);
+ };
+
+ request.onerror = () => {
+ reject(request.error);
+ };
+
+ transaction.oncomplete = () => {
+ db.close();
+ };
+ });
+ });
+ });
+ }
+
+ /**
+ * Get workspace ID from current URL
+ */
+ getCurrentWorkspaceId(): Cypress.Chainable {
+ return cy.window().then((win) => {
+ const urlMatch = win.location.pathname.match(/\/app\/([^/]+)/);
+ return urlMatch ? urlMatch[1] : null;
+ });
+ }
+
/**
* Delete the entire database (for cleanup)
*/
diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts
index d629e1e3..d50e6073 100644
--- a/src/application/services/js-services/http/http_api.ts
+++ b/src/application/services/js-services/http/http_api.ts
@@ -8,7 +8,7 @@ import { ERROR_CODE } from '@/application/constants';
import { initGrantService, refreshToken } from '@/application/services/js-services/http/gotrue';
import { parseGoTrueErrorFromUrl } from '@/application/services/js-services/http/gotrue-error';
import { blobToBytes } from '@/application/services/js-services/http/utils';
-import { AFCloudConfig } from '@/application/services/services.type';
+import { AFCloudConfig, WorkspaceMemberProfileUpdate } from '@/application/services/services.type';
import { getTokenParsed, invalidToken } from '@/application/session/token';
import {
Template,
@@ -354,14 +354,6 @@ export async function getWorkspaceMemberProfile(workspaceId: string): Promise Promise;
getWorkspaceFolder: (workspaceId: string) => Promise;
getCurrentUser: () => Promise;
+ getWorkspaceMemberProfile: (workspaceId: string) => Promise;
updateUserProfile: (metadata: Record) => Promise;
+ updateWorkspaceMemberProfile: (workspaceId: string, profile: WorkspaceMemberProfileUpdate) => Promise;
getUserWorkspaceInfo: () => Promise;
uploadTemplateAvatar: (file: File) => Promise;
getInvitation: (invitationId: string) => Promise;
@@ -174,6 +176,14 @@ export interface AppService {
checkIfCollabExists: (workspaceId: string, objectId: string) => Promise;
}
+export interface WorkspaceMemberProfileUpdate {
+ name: string;
+ avatar_url?: string;
+ cover_image_url?: string;
+ custom_image_url?: string;
+ description?: string;
+}
+
export interface QuickNoteService {
getQuickNoteList: (
workspaceId: string,
diff --git a/src/components/app/layers/AppSyncLayer.tsx b/src/components/app/layers/AppSyncLayer.tsx
index caa383e9..913db551 100644
--- a/src/components/app/layers/AppSyncLayer.tsx
+++ b/src/components/app/layers/AppSyncLayer.tsx
@@ -1,14 +1,14 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EventEmitter from 'events';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Awareness } from 'y-protocols/awareness';
import { APP_EVENTS } from '@/application/constants';
-import { getTokenParsed } from '@/application/session/token';
import { db } from '@/application/db';
+import { getTokenParsed } from '@/application/session/token';
import { useAppflowyWebSocket, useBroadcastChannel, useSync } from '@/components/ws';
-import { SyncInternalContext, SyncInternalContextType } from '../contexts/SyncInternalContext';
-import { useAuthInternal } from '../contexts/AuthInternalContext';
import { notification } from '@/proto/messages';
+import { useAuthInternal } from '../contexts/AuthInternalContext';
+import { SyncInternalContext, SyncInternalContextType } from '../contexts/SyncInternalContext';
interface AppSyncLayerProps {
children: React.ReactNode;
@@ -99,8 +99,6 @@ export const AppSyncLayer: React.FC = ({ children }) => {
const handleUserProfileChange = async (profileChange: notification.IUserProfileChange) => {
try {
- console.log('Received user profile change notification:', profileChange);
-
// Extract user ID from authentication token
const token = getTokenParsed();
const userId = token?.user?.id;
@@ -118,15 +116,17 @@ export const AppSyncLayer: React.FC = ({ children }) => {
return;
}
+ // UserProfileChange notification only contains uid, name, and email
+ // It does NOT include metadata or avatar_url
+ // Avatar updates come via WorkspaceMemberProfileChanged notification
const updatedUser = {
...existingUser,
- name: profileChange.name || existingUser.name,
- email: profileChange.email || existingUser.email,
+ name: profileChange.name ?? existingUser.name,
+ email: profileChange.email ?? existingUser.email,
+ // Preserve existing metadata - UserProfileChange doesn't include it
};
await db.users.put(updatedUser, userId);
-
- console.log('User profile updated in database:', updatedUser);
} catch (error) {
console.error('Failed to handle user profile change notification:', error);
}
@@ -135,8 +135,6 @@ export const AppSyncLayer: React.FC = ({ children }) => {
const handleWorkspaceMemberProfileChange = async (
profileChange: notification.IWorkspaceMemberProfileChanged
) => {
- console.log('Received workspace member profile change notification:', profileChange);
-
if (!currentWorkspaceId) {
console.warn('No current workspace ID available');
return;
@@ -149,12 +147,90 @@ export const AppSyncLayer: React.FC = ({ children }) => {
return;
}
+ // Name is required in the proto, but we handle it defensively
+ if (!profileChange.name) {
+ console.warn('Workspace member profile change missing required name field');
+ }
+
+ // Note: Field name conversion
+ // - Server sends protobuf with snake_case: avatar_url, cover_image_url, etc.
+ // - Protobuf JS generator automatically converts to camelCase: avatarUrl, coverImageUrl, etc.
+ // - We use camelCase (avatarUrl) when reading from profileChange
+ // - We use snake_case (avatar_url) when storing in database (matches schema)
+
try {
const existingProfile = await db.workspace_member_profiles
.where('[workspace_id+user_uuid]')
.equals([currentWorkspaceId, userUuid])
.first();
+ // If profile doesn't exist locally and this is the current user's profile,
+ // try fetching it from the API first (only works for current user)
+ // This can happen if the notification arrives before initial hydration
+ if (!existingProfile && service) {
+ // Check if this notification is for the current user
+ const token = getTokenParsed();
+ const currentUser = await db.users.get(token?.user?.id || '');
+ const isCurrentUser = currentUser?.uuid === userUuid;
+
+ if (isCurrentUser) {
+ try {
+ const fetchedProfile = await service.getWorkspaceMemberProfile(currentWorkspaceId);
+ if (fetchedProfile) {
+ // Use fetched profile as base, then apply notification updates
+ const baseProfile = {
+ workspace_id: currentWorkspaceId,
+ user_uuid: userUuid,
+ person_id: fetchedProfile.person_id ?? userUuid,
+ name: profileChange.name ?? fetchedProfile.name ?? '',
+ email: fetchedProfile.email ?? '',
+ role: fetchedProfile.role ?? 0,
+ avatar_url: fetchedProfile.avatar_url ?? null,
+ cover_image_url: fetchedProfile.cover_image_url ?? null,
+ custom_image_url: fetchedProfile.custom_image_url ?? null,
+ description: fetchedProfile.description ?? null,
+ invited: fetchedProfile.invited ?? false,
+ last_mentioned_at: fetchedProfile.last_mentioned_at ?? null,
+ updated_at: Date.now(),
+ };
+
+ // Apply notification updates, handling optional fields correctly
+ // undefined = field not in notification (preserve existing)
+ // null/empty string = field explicitly cleared
+ // Note: profileChange uses camelCase (avatarUrl) from proto, we convert to snake_case (avatar_url) for database
+ const updatedProfile = {
+ ...baseProfile,
+ name: profileChange.name ?? baseProfile.name,
+ avatar_url:
+ profileChange.avatarUrl !== undefined
+ ? profileChange.avatarUrl || null
+ : baseProfile.avatar_url,
+ cover_image_url:
+ profileChange.coverImageUrl !== undefined
+ ? profileChange.coverImageUrl || null
+ : baseProfile.cover_image_url,
+ custom_image_url:
+ profileChange.customImageUrl !== undefined
+ ? profileChange.customImageUrl || null
+ : baseProfile.custom_image_url,
+ description:
+ profileChange.description !== undefined
+ ? profileChange.description || null
+ : baseProfile.description,
+ };
+
+ await db.workspace_member_profiles.put(updatedProfile);
+ return;
+ }
+ } catch (error) {
+ console.warn('Failed to fetch workspace member profile for notification:', error);
+ // Continue with creating a minimal profile from notification data
+ }
+ }
+ // For other users' profiles, we'll create from notification data below
+ }
+
+ // Update existing profile or create new one from notification
const updatedProfile = {
workspace_id: currentWorkspaceId,
user_uuid: userUuid,
@@ -162,10 +238,24 @@ export const AppSyncLayer: React.FC = ({ children }) => {
name: profileChange.name ?? existingProfile?.name ?? '',
email: existingProfile?.email ?? '',
role: existingProfile?.role ?? 0,
- avatar_url: profileChange.avatarUrl ?? existingProfile?.avatar_url ?? null,
- cover_image_url: profileChange.coverImageUrl ?? existingProfile?.cover_image_url ?? null,
- custom_image_url: profileChange.customImageUrl ?? existingProfile?.custom_image_url ?? null,
- description: profileChange.description ?? existingProfile?.description ?? null,
+ // Handle optional fields: undefined = preserve, null/empty = clear
+ // Note: profileChange uses camelCase (avatarUrl) from proto, we convert to snake_case (avatar_url) for database
+ avatar_url:
+ profileChange.avatarUrl !== undefined
+ ? profileChange.avatarUrl || null
+ : existingProfile?.avatar_url ?? null,
+ cover_image_url:
+ profileChange.coverImageUrl !== undefined
+ ? profileChange.coverImageUrl || null
+ : existingProfile?.cover_image_url ?? null,
+ custom_image_url:
+ profileChange.customImageUrl !== undefined
+ ? profileChange.customImageUrl || null
+ : existingProfile?.custom_image_url ?? null,
+ description:
+ profileChange.description !== undefined
+ ? profileChange.description || null
+ : existingProfile?.description ?? null,
invited: existingProfile?.invited ?? false,
last_mentioned_at: existingProfile?.last_mentioned_at ?? null,
updated_at: Date.now(),
@@ -174,12 +264,6 @@ export const AppSyncLayer: React.FC = ({ children }) => {
// Update workspace member profile in local database while preserving unspecified fields
await db.workspace_member_profiles.put(updatedProfile);
- console.log('Workspace member profile updated in database:', {
- workspace_id: currentWorkspaceId,
- user_uuid: userUuid,
- avatar_url: updatedProfile.avatar_url,
- });
-
// Note: No need to re-emit event here. Components using useCurrentUserWorkspaceAvatar
// will automatically re-render when the database is updated via Dexie's reactive queries.
} catch (error) {
diff --git a/src/components/app/useWorkspaceMemberProfile.ts b/src/components/app/useWorkspaceMemberProfile.ts
index 0b68ea1e..73056da9 100644
--- a/src/components/app/useWorkspaceMemberProfile.ts
+++ b/src/components/app/useWorkspaceMemberProfile.ts
@@ -1,10 +1,12 @@
import { useLiveQuery } from 'dexie-react-hooks';
-import { useContext, useMemo } from 'react';
+import { useContext, useEffect, useMemo } from 'react';
import { db } from '@/application/db';
import { AppContext } from '@/components/app/app.hooks';
import { AFConfigContext } from '@/components/main/app.hooks';
+const pendingHydrations = new Set();
+
/**
* Hook to get the current user's workspace member profile avatar
* Returns the avatar URL or null
@@ -21,6 +23,62 @@ export function useCurrentUserWorkspaceAvatar() {
const currentWorkspaceId = appContext?.currentWorkspaceId;
const currentUser = configContext?.currentUser;
+ const service = configContext?.service;
+
+ useEffect(() => {
+ if (!currentWorkspaceId || !currentUser?.uuid || !service) {
+ return;
+ }
+
+ const cacheKey = `${currentWorkspaceId}:${currentUser.uuid}`;
+ let addedToPending = false;
+ let canceled = false;
+
+ const hydrateProfile = async () => {
+ try {
+ const existingProfile = await db.workspace_member_profiles
+ .where('[workspace_id+user_uuid]')
+ .equals([currentWorkspaceId, currentUser.uuid])
+ .first();
+
+ if (existingProfile) {
+ return;
+ }
+
+ if (pendingHydrations.has(cacheKey)) {
+ return;
+ }
+
+ pendingHydrations.add(cacheKey);
+ addedToPending = true;
+
+ const profile = await service.getWorkspaceMemberProfile(currentWorkspaceId);
+
+ if (!profile || canceled) {
+ return;
+ }
+
+ await db.workspace_member_profiles.put({
+ workspace_id: currentWorkspaceId,
+ user_uuid: currentUser.uuid,
+ ...profile,
+ updated_at: Date.now(),
+ });
+ } catch (error) {
+ console.error('Failed to hydrate workspace member profile:', error);
+ } finally {
+ if (addedToPending) {
+ pendingHydrations.delete(cacheKey);
+ }
+ }
+ };
+
+ void hydrateProfile();
+
+ return () => {
+ canceled = true;
+ };
+ }, [currentWorkspaceId, currentUser?.uuid, service]);
// Use useLiveQuery to reactively watch the database for changes
const profile = useLiveQuery(
diff --git a/src/components/main/App.tsx b/src/components/main/App.tsx
index 7476ea40..a507d04c 100644
--- a/src/components/main/App.tsx
+++ b/src/components/main/App.tsx
@@ -76,7 +76,12 @@ const AppMain = withAppWrapper(() => {
function App() {
return (
-
+
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
index 08a1ac94..5a238ee4 100644
--- a/src/components/ui/avatar.tsx
+++ b/src/components/ui/avatar.tsx
@@ -96,6 +96,7 @@ function AvatarImage({ className, src, ...props }: React.ComponentProps