add network listener management and enhance sync on reconnect functionality
This commit is contained in:
@@ -57,6 +57,11 @@ class NetworkManager {
|
|||||||
registerNetworkListener(callback) {
|
registerNetworkListener(callback) {
|
||||||
this.connectionStatusListeners.push(callback)
|
this.connectionStatusListeners.push(callback)
|
||||||
}
|
}
|
||||||
|
unregisterNetworkListener(callback) {
|
||||||
|
this.connectionStatusListeners = this.connectionStatusListeners.filter(
|
||||||
|
cb => cb !== callback,
|
||||||
|
)
|
||||||
|
}
|
||||||
registerBackendSyncListener(callback) {
|
registerBackendSyncListener(callback) {
|
||||||
// if callback is not in the list already, add it
|
// if callback is not in the list already, add it
|
||||||
if (!this.queueSyncListeners.includes(callback)) {
|
if (!this.queueSyncListeners.includes(callback)) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { App as capacitorApp } from '@capacitor/app'
|
||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { commandQueue } from '../utils/CommandQueue'
|
import { commandQueue } from '../utils/CommandQueue'
|
||||||
@@ -14,6 +16,18 @@ export function useSyncOnReconnect() {
|
|||||||
const initialized = useRef(false)
|
const initialized = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let pendingPollInterval
|
||||||
|
let cacheRefreshInterval
|
||||||
|
let resumeListener
|
||||||
|
let networkListener
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
runSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnline = () => runSync()
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
if (initialized.current) return
|
if (initialized.current) return
|
||||||
initialized.current = true
|
initialized.current = true
|
||||||
@@ -23,24 +37,36 @@ export function useSyncOnReconnect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Device network change (works on native + real network drops)
|
// 1. Device network change (works on native + real network drops)
|
||||||
networkManager.registerNetworkListener(async isOnline => {
|
networkListener = async isOnline => {
|
||||||
if (isOnline) {
|
if (isOnline) {
|
||||||
await runSync()
|
await runSync()
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
networkManager.registerNetworkListener(networkListener)
|
||||||
|
|
||||||
// 2. Tab becomes visible (user switches back to the tab after reconnecting backend)
|
// 2. Tab becomes visible (user switches back to the tab after reconnecting backend)
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||||
if (document.visibilityState === 'visible') {
|
|
||||||
runSync()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. Browser online event (fires when device network is restored)
|
// 3. Browser online event (fires when device network is restored)
|
||||||
window.addEventListener('online', () => runSync())
|
window.addEventListener('online', handleOnline)
|
||||||
|
|
||||||
|
// 3.5 Native app resume (fires when returning to the foreground)
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
resumeListener = await capacitorApp.addListener(
|
||||||
|
'appStateChange',
|
||||||
|
({ isActive }) => {
|
||||||
|
if (isActive) {
|
||||||
|
console.log(
|
||||||
|
'App resumed, checking connectivity and syncing if online...',
|
||||||
|
)
|
||||||
|
runSync()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Retry pending commands every 30s (catches backend restart)
|
// 4. Retry pending commands every 30s (catches backend restart)
|
||||||
setInterval(async () => {
|
pendingPollInterval = setInterval(async () => {
|
||||||
const pending = await commandQueue.getPending()
|
const pending = await commandQueue.getPending()
|
||||||
if (pending.length > 0) {
|
if (pending.length > 0) {
|
||||||
runSync()
|
runSync()
|
||||||
@@ -48,7 +74,7 @@ export function useSyncOnReconnect() {
|
|||||||
}, PENDING_POLL_MS)
|
}, PENDING_POLL_MS)
|
||||||
|
|
||||||
// 5. Keep IDB cache fresh every 5 min while online (so offline reads are current)
|
// 5. Keep IDB cache fresh every 5 min while online (so offline reads are current)
|
||||||
setInterval(() => {
|
cacheRefreshInterval = setInterval(() => {
|
||||||
runSync()
|
runSync()
|
||||||
}, CACHE_REFRESH_MS)
|
}, CACHE_REFRESH_MS)
|
||||||
}
|
}
|
||||||
@@ -62,5 +88,22 @@ export function useSyncOnReconnect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init()
|
init()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pendingPollInterval) {
|
||||||
|
clearInterval(pendingPollInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cacheRefreshInterval) {
|
||||||
|
clearInterval(cacheRefreshInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (networkListener) {
|
||||||
|
networkManager.unregisterNetworkListener(networkListener)
|
||||||
|
}
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
resumeListener?.remove()
|
||||||
|
}
|
||||||
}, [queryClient])
|
}, [queryClient])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export const useCreateChore = () => {
|
|||||||
return { ...oldData, res: [...oldData.res, offlineChore] }
|
return { ...oldData, res: [...oldData.res, offlineChore] }
|
||||||
})
|
})
|
||||||
|
|
||||||
return { res: offlineChore }
|
return offlineChore
|
||||||
}
|
}
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -170,11 +170,11 @@ export const useCreateChore = () => {
|
|||||||
}
|
}
|
||||||
// Successfully created the chore on the server, return the created chore
|
// Successfully created the chore on the server, return the created chore
|
||||||
// update the local chores cache with the new chore:
|
// update the local chores cache with the new chore:
|
||||||
queryClient.setQueryData(['chores'], oldData => {
|
queryClient.setQueryData(['chores', false], oldData => {
|
||||||
if (!oldData) return { res: [createdChore.res] }
|
if (!oldData) return { res: [createdChore.res] }
|
||||||
return { res: [...oldData.res, createdChore.res] }
|
return { res: [...oldData.res, createdChore.res] }
|
||||||
})
|
})
|
||||||
return { res: createdChore }
|
return createdChore.res
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isNetworkError(error)) {
|
if (isNetworkError(error)) {
|
||||||
return queueOfflineCreate(newTask)
|
return queueOfflineCreate(newTask)
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const useCircleMembers = () => {
|
|||||||
const result = await GetAllCircleMembers()
|
const result = await GetAllCircleMembers()
|
||||||
// Cache for offline use
|
// Cache for offline use
|
||||||
if (result?.res) {
|
if (result?.res) {
|
||||||
offlineDB.saveKV('circle_members', result.res)
|
offlineDB.saveKV('circle_members', result.res).catch(() => {})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -966,7 +966,6 @@ class OfflineDB {
|
|||||||
async getHistoryByChore(choreId) {
|
async getHistoryByChore(choreId) {
|
||||||
if (!isOfflineFeatureEnabled()) return []
|
if (!isOfflineFeatureEnabled()) return []
|
||||||
await this._ensureInit()
|
await this._ensureInit()
|
||||||
console.log('MO: Fetching history for chore', choreId)
|
|
||||||
return this.backend.getHistoryByChore(choreId)
|
return this.backend.getHistoryByChore(choreId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -367,7 +367,11 @@ const ChoreEdit = () => {
|
|||||||
|
|
||||||
SaveFunction(chore)
|
SaveFunction(chore)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (result?._pendingUpdate || result?._pendingCreate) {
|
if (
|
||||||
|
result?._pendingUpdate ||
|
||||||
|
result?._pendingCreate ||
|
||||||
|
result?.res?._pendingCreate
|
||||||
|
) {
|
||||||
showSuccess({
|
showSuccess({
|
||||||
title: 'Saved Offline',
|
title: 'Saved Offline',
|
||||||
message: 'Your changes will sync when you are back online.',
|
message: 'Your changes will sync when you are back online.',
|
||||||
|
|||||||
@@ -907,8 +907,8 @@ const ChoreView = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const content = decodeHtmlEntities(chore.description || '')
|
const raw = chore.description || ''
|
||||||
const shouldRenderHtml = hasHtmlTags(content)
|
const shouldRenderHtml = hasHtmlTags(raw)
|
||||||
|
|
||||||
return shouldRenderHtml ? (
|
return shouldRenderHtml ? (
|
||||||
<Box
|
<Box
|
||||||
@@ -916,7 +916,7 @@ const ChoreView = () => {
|
|||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: content }}
|
dangerouslySetInnerHTML={{ __html: raw }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Typography
|
<Typography
|
||||||
@@ -926,7 +926,7 @@ const ChoreView = () => {
|
|||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{decodeHtmlEntities(raw)}
|
||||||
</Typography>
|
</Typography>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@@ -975,8 +975,8 @@ const ChoreView = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const content = decodeHtmlEntities(chore.notes || '')
|
const raw = chore.notes || ''
|
||||||
const shouldRenderHtml = hasHtmlTags(content)
|
const shouldRenderHtml = hasHtmlTags(raw)
|
||||||
|
|
||||||
return shouldRenderHtml ? (
|
return shouldRenderHtml ? (
|
||||||
<Box
|
<Box
|
||||||
@@ -984,7 +984,7 @@ const ChoreView = () => {
|
|||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}}
|
}}
|
||||||
dangerouslySetInnerHTML={{ __html: content }}
|
dangerouslySetInnerHTML={{ __html: raw }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Typography
|
<Typography
|
||||||
@@ -994,7 +994,7 @@ const ChoreView = () => {
|
|||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{decodeHtmlEntities(raw)}
|
||||||
</Typography>
|
</Typography>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { GetProjects, CreateProject, UpdateProject, DeleteProject } from '../../utils/Fetcher'
|
import {
|
||||||
|
CreateProject,
|
||||||
|
DeleteProject,
|
||||||
|
GetProjects,
|
||||||
|
UpdateProject,
|
||||||
|
} from '../../utils/Fetcher'
|
||||||
import { offlineDB } from '../../utils/OfflineDB'
|
import { offlineDB } from '../../utils/OfflineDB'
|
||||||
|
|
||||||
// Query hook for fetching all projects
|
// Query hook for fetching all projects
|
||||||
@@ -33,7 +38,7 @@ export const useCreateProject = () => {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (projectData) => {
|
mutationFn: async projectData => {
|
||||||
try {
|
try {
|
||||||
const response = await CreateProject(projectData)
|
const response = await CreateProject(projectData)
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -53,7 +58,7 @@ export const useCreateProject = () => {
|
|||||||
return localProject
|
return localProject
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (newProject) => {
|
onSuccess: newProject => {
|
||||||
// Update the projects cache
|
// Update the projects cache
|
||||||
queryClient.setQueryData(['projects'], (oldProjects = []) => {
|
queryClient.setQueryData(['projects'], (oldProjects = []) => {
|
||||||
const updatedProjects = [...oldProjects, newProject]
|
const updatedProjects = [...oldProjects, newProject]
|
||||||
@@ -63,7 +68,7 @@ export const useCreateProject = () => {
|
|||||||
// Invalidate and refetch
|
// Invalidate and refetch
|
||||||
queryClient.invalidateQueries(['projects'])
|
queryClient.invalidateQueries(['projects'])
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: error => {
|
||||||
console.error('Create project mutation failed:', error)
|
console.error('Create project mutation failed:', error)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -92,18 +97,18 @@ export const useUpdateProject = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (updatedProject) => {
|
onSuccess: updatedProject => {
|
||||||
// Update the projects cache
|
// Update the projects cache
|
||||||
queryClient.setQueryData(['projects'], (oldProjects = []) => {
|
queryClient.setQueryData(['projects'], (oldProjects = []) => {
|
||||||
return oldProjects.map(project =>
|
return oldProjects.map(project =>
|
||||||
project.id === updatedProject.id ? updatedProject : project
|
project.id === updatedProject.id ? updatedProject : project,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Invalidate and refetch
|
// Invalidate and refetch
|
||||||
queryClient.invalidateQueries(['projects'])
|
queryClient.invalidateQueries(['projects'])
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: error => {
|
||||||
console.error('Update project mutation failed:', error)
|
console.error('Update project mutation failed:', error)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -114,7 +119,7 @@ export const useDeleteProject = () => {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (projectId) => {
|
mutationFn: async projectId => {
|
||||||
try {
|
try {
|
||||||
// Prevent deletion of default project
|
// Prevent deletion of default project
|
||||||
if (projectId === 'default') {
|
if (projectId === 'default') {
|
||||||
@@ -141,14 +146,14 @@ export const useDeleteProject = () => {
|
|||||||
// Invalidate and refetch
|
// Invalidate and refetch
|
||||||
queryClient.invalidateQueries(['projects'])
|
queryClient.invalidateQueries(['projects'])
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: error => {
|
||||||
console.error('Delete project mutation failed:', error)
|
console.error('Delete project mutation failed:', error)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook to get a specific project by ID
|
// Hook to get a specific project by ID
|
||||||
export const useProject = (projectId) => {
|
export const useProject = projectId => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['projects', projectId],
|
queryKey: ['projects', projectId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -179,4 +184,4 @@ export const useProject = (projectId) => {
|
|||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
cacheTime: 10 * 60 * 1000,
|
cacheTime: 10 * 60 * 1000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user