From d0cc439fd1d026f65e1a011cc781aca04c35f383 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Thu, 9 Apr 2026 19:39:04 +0200 Subject: [PATCH] feat: add automatic identity synchronization (#167) --- app/[locale]/page.tsx | 2 ++ .../identity/identity-manager-modal.tsx | 13 ++++++++ hooks/use-identity-sync.ts | 31 +++++++++++++++++++ stores/auth-store.ts | 13 ++++++++ 4 files changed, 59 insertions(+) create mode 100644 hooks/use-identity-sync.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index c174de69..0e9a6ee3 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -39,6 +39,7 @@ import { NavigationRail } from "@/components/layout/navigation-rail"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { useSidebarApps } from "@/hooks/use-sidebar-apps"; +import { useIdentitySync } from "@/hooks/use-identity-sync"; import { Input } from "@/components/ui/input"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; @@ -77,6 +78,7 @@ export default function Home() { const markAsReadTimeoutRef = useRef(null); const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { identities } = useIdentityStore(); + useIdentitySync(); useEffect(() => { if (!isRateLimited || !rateLimitUntil) { diff --git a/components/identity/identity-manager-modal.tsx b/components/identity/identity-manager-modal.tsx index cb0d960f..ea6a916a 100644 --- a/components/identity/identity-manager-modal.tsx +++ b/components/identity/identity-manager-modal.tsx @@ -14,6 +14,10 @@ function useSyncIdentities() { const syncIdentities = useAuthStore((state) => state.syncIdentities); return syncIdentities; } + +function useRefreshIdentities() { + return useAuthStore((state) => state.refreshIdentities); +} import type { Identity, EmailAddress } from '@/lib/jmap/types'; import { toast } from '@/stores/toast-store'; import { useFocusTrap } from '@/hooks/use-focus-trap'; @@ -49,6 +53,15 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr const setPreferredPrimary = useIdentityStore((state) => state.setPreferredPrimary); const syncIdentities = useSyncIdentities(); + const refreshIdentitiesFromServer = useRefreshIdentities(); + + // Refresh identities from server whenever the modal is opened + useEffect(() => { + if (isOpen) { + refreshIdentitiesFromServer(); + } + }, [isOpen, refreshIdentitiesFromServer]); + const [editingId, setEditingId] = useState(null); const [isCreating, setIsCreating] = useState(false); const [deletingId, setDeletingId] = useState(null); diff --git a/hooks/use-identity-sync.ts b/hooks/use-identity-sync.ts new file mode 100644 index 00000000..72bc5c37 --- /dev/null +++ b/hooks/use-identity-sync.ts @@ -0,0 +1,31 @@ +'use client'; + +import { useEffect } from 'react'; +import { useAuthStore } from '@/stores/auth-store'; + +// Re-sync identities every 30 minutes while the app is open +const SYNC_INTERVAL_MS = 30 * 60 * 1000; + +export function useIdentitySync() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const refreshIdentities = useAuthStore((s) => s.refreshIdentities); + + useEffect(() => { + if (!isAuthenticated) return; + + // Sync when the user returns to the tab (e.g. after adding an alias in Stalwart) + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + refreshIdentities(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + const interval = setInterval(refreshIdentities, SYNC_INTERVAL_MS); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + clearInterval(interval); + }; + }, [isAuthenticated, refreshIdentities]); +} diff --git a/stores/auth-store.ts b/stores/auth-store.ts index aa421232..3d141452 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -47,6 +47,7 @@ interface AuthState { checkAuth: () => Promise; clearError: () => void; syncIdentities: () => void; + refreshIdentities: () => Promise; getClientForAccount: (accountId: string) => JMAPClient | undefined; } @@ -1513,6 +1514,18 @@ export const useAuthStore = create()( set({ identities, primaryIdentity }); }, + refreshIdentities: async () => { + const { client, username } = get(); + if (!client || !username) return; + try { + const rawIdentities = await client.getIdentities(); + const { identities, primaryIdentity } = loadIdentities(rawIdentities, username); + set({ identities, primaryIdentity }); + } catch { + // Silently fail — background sync should not surface errors to the user + } + }, + getClientForAccount: (accountId: string) => { return clients.get(accountId); },