Skip to content

Commit

Permalink
feat: add Sync screen (#241)
Browse files Browse the repository at this point in the history
* WIP

* use svg image for project sync display

* make header right button navigate to project settings

* remove old screen name

* remove unused variable

* do not stop sync when leaving screen

* add useSyncProgress() hook

* chore: small typing fixes

* chore: add isSyncDone

* removes console.log

* chore: cleanup sync on dismount

* chore: use navigation listener to stop sync

* chore:translations

---------

Co-authored-by: ErikSin <[email protected]>
  • Loading branch information
achou11 and ErikSin authored Apr 24, 2024
1 parent 7df0cdb commit de78926
Show file tree
Hide file tree
Showing 16 changed files with 818 additions and 81 deletions.
53 changes: 52 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"message": "Start Tracks"
},
"Modal.GPSEnable.button.loading": {
"message": "Loading"
"message": "Loading..."
},
"Modal.GPSEnable.button.stop": {
"message": "Stop Tracks"
Expand Down Expand Up @@ -632,6 +632,57 @@
"description": "Title of settings screen",
"message": "Settings"
},
"screens.Sync.CreateOrJoinProjectDisplay.buttonText": {
"message": "Create or Join Project"
},
"screens.Sync.CreateOrJoinProjectDisplay.description": {
"message": "Create or Join a Project to sync with other devices"
},
"screens.Sync.HeaderTitle.noWiFi": {
"message": "No WiFi"
},
"screens.Sync.NoWifiDisplay.buttonText": {
"message": "Open Settings"
},
"screens.Sync.NoWifiDisplay.description": {
"message": "Open your phone settings and connect to a WiFi network to synchronize"
},
"screens.Sync.NoWifiDisplay.title": {
"message": "No WiFi"
},
"screens.Sync.ProjectSyncDisplay.buttonTextDone": {
"message": "You're all caught up"
},
"screens.Sync.ProjectSyncDisplay.buttonTextStop": {
"message": "Stop"
},
"screens.Sync.ProjectSyncDisplay.buttonTextSync": {
"message": "Sync"
},
"screens.Sync.ProjectSyncDisplay.deviceName": {
"message": "Your device name is {name}"
},
"screens.Sync.ProjectSyncDisplay.devicesNearby": {
"message": "{count} {count, plural, one {device} other {devices}} nearby/connected"
},
"screens.Sync.ProjectSyncDisplay.devicesSyncing": {
"message": "Syncing with {count} {count, plural, one {Device} other {Devices}}"
},
"screens.Sync.ProjectSyncDisplay.devicesWaitingToSync": {
"message": "{count} {count, plural, one {Device} other {Devices}} Waiting to Sync with you"
},
"screens.Sync.ProjectSyncDisplay.noDevicesSyncing": {
"message": "No Devices are Syncing"
},
"screens.Sync.ProjectSyncDisplay.syncProgress": {
"message": "{value}%"
},
"screens.Sync.ProjectSyncDisplay.syncing": {
"message": "Syncing…"
},
"screens.Sync.ProjectSyncDisplay.upToDate": {
"message": "Up to Date! No data to Sync"
},
"sharedComponents.DeviceCard.ThisDevice": {
"message": "This Device!"
},
Expand Down
5 changes: 1 addition & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion src/frontend/Navigation/ScreenGroups/AppScreens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ import {CameraTabBarIcon} from './TabBar/CameraTabBarIcon';
import {MapTabBarIcon} from './TabBar/MapTabBarIcon';
import {InviteDeclined} from '../../screens/Settings/ProjectSettings/YourTeam/InviteDeclined';
import {UnableToCancelInvite} from '../../screens/Settings/ProjectSettings/YourTeam/ReviewAndInvite/UnableToCancelInvite';
import {
SyncScreen,
createNavigationOptions as createSyncNavOptions,
} from '../../screens/Sync';

export const TAB_BAR_HEIGHT = 70;

Expand All @@ -76,7 +80,6 @@ type InviteProps = {
export type AppList = {
Home: NavigatorScreenParams<HomeTabsList>;
GpsModal: undefined;
SyncModal: undefined;
Settings: undefined;
ProjectConfig: undefined;
AboutMapeo: undefined;
Expand Down Expand Up @@ -136,6 +139,7 @@ export type AppList = {
UnableToCancelInvite: InviteProps;
DeviceNameDisplay: undefined;
DeviceNameEdit: undefined;
Sync: undefined;
};

const Tab = createBottomTabNavigator<HomeTabsList>();
Expand Down Expand Up @@ -366,5 +370,10 @@ export const createDefaultScreenGroup = (
component={UnableToCancelInvite}
options={{headerShown: false}}
/>
<RootStack.Screen
name="Sync"
component={SyncScreen}
options={createSyncNavOptions()}
/>
</RootStack.Group>
);
10 changes: 6 additions & 4 deletions src/frontend/hooks/server/observations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
import {useProject} from './projects';
import {ClientGeneratedObservation} from '../../sharedTypes';

export const OBSERVATION_KEY = 'observations';

export function useObservations() {
const project = useProject();

Expand All @@ -24,7 +26,7 @@ export function useObservation(observationId: string) {
const project = useProject();

return useSuspenseQuery({
queryKey: ['observations', observationId],
queryKey: [OBSERVATION_KEY, observationId],
queryFn: async () => {
if (!project) throw new Error('Project instance does not exist');
return project.observation.getByDocId(observationId);
Expand All @@ -46,7 +48,7 @@ export function useCreateObservation() {
});
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['observations']});
queryClient.invalidateQueries({queryKey: [OBSERVATION_KEY]});
},
});
}
Expand All @@ -67,7 +69,7 @@ export function useEditObservation() {
return project.observation.update(id, value);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['observations']});
queryClient.invalidateQueries({queryKey: [OBSERVATION_KEY]});
},
});
}
Expand All @@ -82,7 +84,7 @@ export function useDeleteObservation() {
return project.observation.delete(id);
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['observations']});
queryClient.invalidateQueries({queryKey: [OBSERVATION_KEY]});
},
});
}
7 changes: 4 additions & 3 deletions src/frontend/hooks/server/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,11 @@ export function useProjectMembers() {

export function useProjectSettings() {
const project = useProject();

return useQuery({
queryFn: async () => {
return await project.$getProjectSettings();
},
queryKey: ['projectSettings'],
queryFn: () => {
return project.$getProjectSettings();
},
});
}
186 changes: 129 additions & 57 deletions src/frontend/hooks/useSyncState.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import {MapeoProjectApi} from '@mapeo/ipc';
import React from 'react';
import {useCallback, useSyncExternalStore} from 'react';

import {useProject} from './server/projects';

type SyncState = Awaited<ReturnType<MapeoProjectApi['$sync']['getState']>>;
export type SyncState = Awaited<
ReturnType<MapeoProjectApi['$sync']['getState']>
>;

const projectStateMap = new WeakMap<
MapeoProjectApi,
ReturnType<typeof createSyncState>
>();
const projectSyncStoreMap = new WeakMap<MapeoProjectApi, SyncStore>();

function identity(state: SyncState | undefined) {
return state;
function useSyncStore() {
const project = useProject();

let syncStore = projectSyncStoreMap.get(project);

if (!syncStore) {
syncStore = new SyncStore(project);
projectSyncStoreMap.set(project, syncStore);
}

return syncStore;
}

/**
Expand All @@ -29,68 +38,131 @@ function identity(state: SyncState | undefined) {
* @param selector Select a subset of the state to subscribe to. Defaults to return the entire state.
* @returns
*/
export function useSyncState<S = SyncState | undefined>(
selector: (state: SyncState | undefined) => S = identity as any,
export function useSyncState<S = SyncState | null>(
selector: (state: SyncState | null) => S = identity as any,
): S {
const project = useProject();
const syncStore = useSyncStore();

let state = projectStateMap.get(project);
if (!state) {
state = createSyncState(project);
projectStateMap.set(project, state);
}

const {subscribe, getSnapshot} = state;
const {subscribe, getStateSnapshot} = syncStore;

const getSelectorSnapshot = React.useCallback(
() => selector(getSnapshot()),
[selector, getSnapshot],
const getSelectorSnapshot = useCallback(
() => selector(getStateSnapshot()),
[selector, getStateSnapshot],
);

return React.useSyncExternalStore(subscribe, getSelectorSnapshot);
return useSyncExternalStore(subscribe, getSelectorSnapshot);
}

function createSyncState(project: MapeoProjectApi) {
let state: SyncState | undefined;
let isSubscribedInternal = false;
const listeners = new Set<() => void>();
let error: Error | undefined;
/**
* Calculates progress of *data* sync based on sync state.
*
* @returns A number between 0 and 1 when data sync is enabled. `null` otherwise.
*/
export function useSyncProgress() {
const {subscribe, getProgressSnapshot} = useSyncStore();
return useSyncExternalStore(subscribe, getProgressSnapshot);
}

class SyncStore {
#project: MapeoProjectApi;

#listeners = new Set<() => void>();
#isSubscribedInternal = false;
#error: Error | null = null;
#state: SyncState | null = null;

/**
* Represents maximum value of `#state.data.want + #state.data.wanted` while data syncing is enabled.
* Resets to null when data syncing goes from enabled to disabled.
*/
#maxDataSyncCount: number | null = null;

constructor(project: MapeoProjectApi) {
this.#project = project;
}

subscribe = (listener: () => void) => {
this.#listeners.add(listener);
if (!this.#isSubscribedInternal) this.#startSubscription();
return () => {
this.#listeners.delete(listener);
if (this.#listeners.size === 0) this.#stopSubscription();
};
};

getStateSnapshot = () => {
if (this.#error) throw this.#error;
return this.#state;
};

getProgressSnapshot = () => {
if (this.#maxDataSyncCount === null || this.#state === null) {
return null;
}

if (this.#maxDataSyncCount === 0) {
return 1;
}

const currentCount = this.#state.data.want + this.#state.data.wanted;

const ratio =
(this.#maxDataSyncCount - currentCount) / this.#maxDataSyncCount;

if (ratio <= 0) return 0;
if (ratio >= 1) return 1;

function onSyncState(newState: SyncState) {
state = newState;
error = undefined;
listeners.forEach(listener => listener());
return clamp(ratio, 0.01, 0.99);
};

#notifyListeners() {
for (const listener of this.#listeners) {
listener();
}
}

function subscribeInternal() {
project.$sync.on('sync-state', onSyncState);
isSubscribedInternal = true;
project.$sync
#onSyncState = (state: SyncState) => {
// Indicates whether data syncing went from enabled to disabled
const isDataSyncStopped = this.#state?.data.syncing && !state.data.syncing;

if (isDataSyncStopped) {
this.#maxDataSyncCount = null;
} else {
const newSyncCount = state.data.want + state.data.wanted;

this.#maxDataSyncCount =
this.#maxDataSyncCount === null
? newSyncCount
: Math.max(this.#maxDataSyncCount, newSyncCount);
}

this.#state = state;
this.#error = null;
this.#notifyListeners();
};

#startSubscription = () => {
this.#project.$sync.on('sync-state', this.#onSyncState);
this.#isSubscribedInternal = true;
this.#project.$sync
.getState()
.then(onSyncState)
.then(this.#onSyncState)
.catch(e => {
error = e;
listeners.forEach(listener => listener());
this.#error = e;
this.#notifyListeners();
});
}

function unsubscribeInternal() {
isSubscribedInternal = false;
project.$sync.off('sync-state', onSyncState);
}
};

return {
subscribe: (listener: () => void) => {
listeners.add(listener);
if (!isSubscribedInternal) subscribeInternal();
return () => {
listeners.delete(listener);
if (listeners.size === 0) unsubscribeInternal();
};
},
getSnapshot: () => {
if (error) throw error;
return state;
},
#stopSubscription = () => {
this.#isSubscribedInternal = false;
this.#project.$sync.off('sync-state', this.#onSyncState);
};
}

function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(value, max));
}

function identity(state: SyncState | undefined) {
return state;
}
Loading

0 comments on commit de78926

Please sign in to comment.