-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: implement custom map file support #758
Changes from 5 commits
7ce39f0
b0baa17
44ec0de
ed0f9e4
4215dff
cd9a5e1
3184254
0b45968
5284dc3
34976e4
1bff26e
dcac9e0
c0d973e
f28d371
b2c015a
89d06e0
3005200
5a702b4
b79e78d
e65020c
698c819
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,19 +2,23 @@ import debug from 'debug' | |
import { join } from 'path' | ||
import { mkdirSync } from 'fs' | ||
import { createRequire } from 'module' | ||
const require = createRequire(import.meta.url) | ||
/** @type {import('../types/rn-bridge.js')} */ | ||
const rnBridge = require('rn-bridge') | ||
import { MapeoManager, FastifyController } from '@comapeo/core' | ||
import { createMapeoServer } from '@comapeo/ipc' | ||
import Fastify from 'fastify' | ||
|
||
import MessagePortLike from './message-port-like.js' | ||
import { ServerStatus } from './status.js' | ||
|
||
const require = createRequire(import.meta.url) | ||
|
||
/** @type {import('../types/rn-bridge.js')} */ | ||
const rnBridge = require('rn-bridge') | ||
|
||
// Do not touch these! | ||
const DB_DIR_NAME = 'sqlite-dbs' | ||
const CORE_STORAGE_DIR_NAME = 'core-storage' | ||
const CUSTOM_MAPS_DIR_NAME = 'maps' | ||
const DEFAULT_CUSTOM_MAP_FILE_NAME = 'default.smp' | ||
Comment on lines
18
to
+21
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just confirming that this requires the developer to know the names of the directories? If so, that seems quite fragile, maybe something to consider refactoring at the UK retreat. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah there's some duplication between frontend and backend in terms of knowing where this directory is. However, there's no good way to share code between the two unless you involve an unnecessary IPC roundtrip, which I'd rather avoid in this case. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. for posterity: the way this custom map feature works is the following:
|
||
|
||
const MAPBOX_ACCESS_TOKEN = | ||
'pk.eyJ1IjoiZGlnaWRlbSIsImEiOiJjbHRyaGh3cm0wN3l4Mmpsam95NDI3c2xiIn0.daq2iZFZXQ08BD0VZWAGUw' | ||
|
@@ -64,9 +68,11 @@ export async function init({ | |
const privateStorageDir = rnBridge.app.datadir() | ||
const dbDir = join(privateStorageDir, DB_DIR_NAME) | ||
const indexDir = join(privateStorageDir, CORE_STORAGE_DIR_NAME) | ||
const customMapsDir = join(privateStorageDir, CUSTOM_MAPS_DIR_NAME) | ||
|
||
mkdirSync(dbDir, { recursive: true }) | ||
mkdirSync(indexDir, { recursive: true }) | ||
mkdirSync(customMapsDir, { recursive: true }) | ||
|
||
const fastify = Fastify() | ||
const fastifyController = new FastifyController({ fastify }) | ||
|
@@ -80,6 +86,7 @@ export async function init({ | |
fastify, | ||
defaultConfigPath, | ||
defaultOnlineStyleUrl: DEFAULT_ONLINE_MAP_STYLE_URL, | ||
customMapPath: join(customMapsDir, DEFAULT_CUSTOM_MAP_FILE_NAME), | ||
}) | ||
|
||
// Don't await, methods that use the server will await this internally | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import {create} from 'zustand'; | ||
|
||
interface RefreshTokenStoreSlice { | ||
value: number; | ||
actions: { | ||
refresh: () => void; | ||
}; | ||
} | ||
|
||
/** | ||
* Factory for creating a bound store instance and return its relevant hooks, | ||
* which allows the creation of isolated stores to account for contextual needs. | ||
*/ | ||
export function createRefreshTokenStore(initialValue?: number) { | ||
const useRefreshTokenStore = create<RefreshTokenStoreSlice>(set => { | ||
return { | ||
value: typeof initialValue === 'number' ? initialValue : Date.now(), | ||
actions: { | ||
refresh: () => { | ||
set({value: Date.now()}); | ||
}, | ||
}, | ||
}; | ||
}); | ||
|
||
return { | ||
useRefreshToken: () => { | ||
return useRefreshTokenStore(valueSelector); | ||
}, | ||
useRefreshTokenActions: () => { | ||
return useRefreshTokenStore(actionsSelector); | ||
}, | ||
}; | ||
} | ||
|
||
function valueSelector(state: RefreshTokenStoreSlice) { | ||
return state.value; | ||
} | ||
|
||
function actionsSelector(state: RefreshTokenStoreSlice) { | ||
return state.actions; | ||
} |
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; | ||
import * as FileSystem from 'expo-file-system'; | ||
import * as v from 'valibot'; | ||
|
||
import {useApi} from '../../contexts/ApiContext'; | ||
import {DOCUMENT_DIRECTORY, selectFile} from '../../lib/file-system'; | ||
|
||
import {createRefreshTokenStore} from '../refreshTokenStore'; | ||
|
||
export const MAPS_QUERY_KEY = 'maps'; | ||
|
||
const CUSTOM_MAPS_DIRECTORY = new URL('maps', DOCUMENT_DIRECTORY).href; | ||
const DEFAULT_CUSTOM_MAP_FILE_PATH = CUSTOM_MAPS_DIRECTORY + '/default.smp'; | ||
|
||
const CustomMapInfoSchema = v.object({ | ||
created: v.pipe( | ||
v.string(), | ||
v.isoTimestamp(), | ||
v.transform(input => new Date(input)), | ||
), | ||
name: v.string(), | ||
size: v.pipe(v.number(), v.minValue(0)), | ||
}); | ||
|
||
export type CustomMapInfo = v.InferOutput<typeof CustomMapInfoSchema>; | ||
|
||
const {useRefreshToken, useRefreshTokenActions} = createRefreshTokenStore(); | ||
|
||
export function useMapStyleJsonUrl() { | ||
const api = useApi(); | ||
const refreshToken = useRefreshToken(); | ||
|
||
return useQuery({ | ||
queryKey: [MAPS_QUERY_KEY, 'stylejson-url', refreshToken], | ||
queryFn: async () => { | ||
let result = await api.getMapStyleJsonUrl(); | ||
return result + `?refresh_token=${refreshToken}`; | ||
}, | ||
}); | ||
} | ||
|
||
export function useSelectCustomMapFile() { | ||
return useMutation({ | ||
mutationFn: () => { | ||
return selectFile({ | ||
extensionFilters: ['smp'], | ||
}); | ||
}, | ||
}); | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think based on how simple this function is, we should just have a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. addressed via dcac9e0 Think ideally this stuff in |
||
|
||
export function useImportCustomMapFile() { | ||
const queryClient = useQueryClient(); | ||
const {refresh} = useRefreshTokenActions(); | ||
|
||
return useMutation({ | ||
mutationFn: async (opts: {uri: string}) => { | ||
return FileSystem.moveAsync({ | ||
from: opts.uri, | ||
to: DEFAULT_CUSTOM_MAP_FILE_PATH, | ||
}); | ||
}, | ||
onSuccess: () => { | ||
refresh(); | ||
queryClient.invalidateQueries({ | ||
queryKey: [MAPS_QUERY_KEY], | ||
}); | ||
Comment on lines
+68
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't fully understand the need for the refresh token? From a surface perspective it looks like it is doing the same thing as invalidating the query There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah the tricky part here is that the invalidation we need is controlled by the consuming map view, not react query. for example:
In order to actually force the mapview to fully fetch the response of the same url and ignore the cache, we introduce the refresh token in the form of a url query string, which counts as a new url, even though the query string goes unused by the server that we're calling. we did something similar in Mapeo Mobile for other http resources that would have the same issue (e.g. here) |
||
}, | ||
}); | ||
} | ||
|
||
export function useRemoveCustomMapFile() { | ||
const queryClient = useQueryClient(); | ||
const {refresh} = useRefreshTokenActions(); | ||
|
||
return useMutation({ | ||
mutationFn: () => { | ||
return FileSystem.deleteAsync(DEFAULT_CUSTOM_MAP_FILE_PATH, { | ||
idempotent: true, | ||
}); | ||
}, | ||
onSuccess: () => { | ||
refresh(); | ||
queryClient.invalidateQueries({ | ||
queryKey: [MAPS_QUERY_KEY], | ||
}); | ||
}, | ||
}); | ||
} | ||
|
||
/** | ||
* Returns `null` if no viable map is found. Throws an error if a detected map is invalid. | ||
*/ | ||
export function useGetCustomMapInfo() { | ||
const api = useApi(); | ||
|
||
return useQuery({ | ||
queryKey: [MAPS_QUERY_KEY, 'custom', 'info'], | ||
queryFn: async () => { | ||
const styleUrl = await api.getMapStyleJsonUrl(); | ||
|
||
const infoUrl = new URL('/maps/custom/info', styleUrl).href; | ||
|
||
const response = await fetch(infoUrl); | ||
|
||
if (response.status === 404) { | ||
return null; | ||
} | ||
|
||
if (!response.ok) { | ||
throw new Error(`Cannot get custom map info: ${response.statusText}`); | ||
} | ||
|
||
return v.parse(CustomMapInfoSchema, await response.json()); | ||
}, | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export function bytesToMegabytes(bytes: number) { | ||
return bytes / 2 ** 20; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm introducing this dep for response body validation, which is used when interfacing with the map server