Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 130 additions & 2 deletions src/hooks/maps.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from '@tanstack/react-query'

import { mapStyleJsonUrlQueryOptions } from '../lib/react-query/maps.js'
import {
acceptMapShareMutationOptions,
getMapShareByIdQueryOptions,
getMapSharesQueryOptions,
mapStyleJsonUrlQueryOptions,
rejectMapShareMutationOptions,
requestCancelMapShareMutationOptions,
sendMapShareMutationOptions,
} from '../lib/react-query/maps.js'
import { useClientApi } from './client.js'
import { useSingleProject } from './projects.js'

/**
* Get a URL that points to a StyleJSON resource served by the embedded HTTP server.
Expand Down Expand Up @@ -42,3 +55,118 @@ export function useMapStyleUrl({

return { data, error, isRefetching }
}

/**
* Get all map shares that the device has received.
*
* @example
* ```ts
* function Example() {
* const { data } = useManyMapShares()
* }
* ```
*/
export function useManyMapShares() {
const clientApi = useClientApi()
const { data, error, isRefetching } = useSuspenseQuery(
getMapSharesQueryOptions({ clientApi }),
)

return { data, error, isRefetching }
}

/**
* Get a single map share based on its ID.
*
* @param opts.shareId ID of map share
*
* @example
* ```ts
* function Example() {
* const { data } = useSingleMapShare({ shareId: '...' })
* }
* ```
*/
export function useSingleMapShare({ shareId }: { shareId: string }) {
const clientApi = useClientApi()

const { data, error, isRefetching } = useSuspenseQuery(
getMapShareByIdQueryOptions({ clientApi, shareId }),
)

return { data, error, isRefetching }
}

/**
* Accept and download a map share that has been received. The mutate promise
* resolves once the map _starts_ downloading, before it finishes downloading.
* The hooks useManyMapShares and useSingleMapShare can be used to track
* download progress.
*/
export function useAcceptMapShare() {
const queryClient = useQueryClient()
const clientApi = useClientApi()

const { error, mutate, mutateAsync, reset, status } = useMutation(
acceptMapShareMutationOptions({ clientApi, queryClient }),
)

return status === 'error'
? { error, mutate, mutateAsync, reset, status }
: { error: null, mutate, mutateAsync, reset, status }
}

/**
* Reject a map share that has been received.
*/
export function useRejectMapShare() {
const queryClient = useQueryClient()
const clientApi = useClientApi()

const { error, mutate, mutateAsync, reset, status } = useMutation(
rejectMapShareMutationOptions({ clientApi, queryClient }),
)

return status === 'error'
? { error, mutate, mutateAsync, reset, status }
: { error: null, mutate, mutateAsync, reset, status }
}

/**
* Share a map with a device. The mutation method resolves when the share is
* accepted (the recipient starts downloading the map), or if they reject the
* share or they already have that map on their device (this reply is
* automatic).
*
* @param opts.projectId Public ID of project to send the invite on behalf of.
*/
export function useSendMapShare({ projectId }: { projectId: string }) {
const queryClient = useQueryClient()
const { data: projectApi } = useSingleProject({ projectId })

const { error, mutate, mutateAsync, reset, status } = useMutation(
sendMapShareMutationOptions({ projectApi, projectId, queryClient }),
)

return status === 'error'
? { error, mutate, mutateAsync, reset, status }
: { error: null, mutate, mutateAsync, reset, status }
}

/**
* Request a cancellation of a map share that was previously sent.
*
* @param opts.projectId Public ID of project to request the map share cancellation for.
*/
export function useRequestCancelMapShare({ projectId }: { projectId: string }) {
const queryClient = useQueryClient()
const { data: projectApi } = useSingleProject({ projectId })

const { error, mutate, mutateAsync, reset, status } = useMutation(
requestCancelMapShareMutationOptions({ projectApi, queryClient }),
)

return status === 'error'
? { error, mutate, mutateAsync, reset, status }
: { error: null, mutate, mutateAsync, reset, status }
}
10 changes: 9 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,15 @@ export {
useSendInvite,
useSingleInvite,
} from './hooks/invites.js'
export { useMapStyleUrl } from './hooks/maps.js'
export {
useMapStyleUrl,
useAcceptMapShare,
useManyMapShares,
useRejectMapShare,
useRequestCancelMapShare,
useSendMapShare,
useSingleMapShare,
} from './hooks/maps.js'
export {
useAddServerPeer,
useAttachmentUrl,
Expand Down
227 changes: 224 additions & 3 deletions src/lib/react-query/maps.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,111 @@
import type { MapeoClientApi } from '@comapeo/ipc' with { 'resolution-mode': 'import' }
import { queryOptions } from '@tanstack/react-query'
/* eslint-disable @typescript-eslint/no-unused-vars */
import type {
MapeoClientApi,
MapeoProjectApi,
} from '@comapeo/ipc' with { 'resolution-mode': 'import' }
import {
queryOptions,
type QueryClient,
type UseMutationOptions,
} from '@tanstack/react-query'

import { baseQueryOptions, ROOT_QUERY_KEY } from './shared.js'
import {
baseMutationOptions,
baseQueryOptions,
ROOT_QUERY_KEY,
} from './shared.js'

type MapShareState =
/** Map share has been received and is awaiting a response */
| 'pending'
/** Map share has been rejected */
| 'rejected'
/** Map share is currently being downloaded */
| 'downloading'
/** Map share has been cancelled by the sharer */
| 'cancelled'
/** Map has been downloaded */
| 'completed'
/** An error occurred while receiving the map share */
| 'error'

type MapShareBase = {
/** The ID of the device that sent the map share */
senderDeviceId: string
/** The name of the device that sent the map share */
senderDeviceName: string
/** The ID of the map share */
shareId: string
/** The name of the map being shared */
mapName: string
/** The ID of the map being shared */
mapId: string
/** The timestamp when the map share invite was received */
receivedAt: number
/** The bounding box of the map data being shared */
bounds: [number, number, number, number]
/** The minimum zoom level of the map data being shared */
minzoom: number
/** The maximum zoom level of the map data being shared */
maxzoom: number
/** Estimated size of the map data being shared in bytes */
estimatedSizeBytes: number
}

type MapShareResponse =
| {
decision: 'ACCEPT' | 'UNRECOGNIZED'
shareId: string
}
| {
decision: 'REJECT'
shareId: string
reason: 'DISK_SPACE' | 'USER_REJECTED' | 'ALREADY' | 'UNRECOGNIZED'
}

type MapShare = MapShareBase &
(
| {
state: Exclude<MapShareState, 'downloading' | 'error'>
}
| {
state: 'downloading'
/** Total bytes downloaded so far (compare with estimatedSizeBytes for progress) */
bytesDownloaded: number
}
| {
state: 'error'
/** Error that occurred while receiving the map share */
error: Error
}
)

const MOCK_MAP_SHARE = {
senderDeviceId: 'device-123',
senderDeviceName: 'Device 123',
shareId: 'share-456',
mapName: 'Sample Map',
mapId: 'map-789',
receivedAt: Date.now(),
bounds: [0, 0, 10, 10],
minzoom: 0,
maxzoom: 14,
estimatedSizeBytes: 1024 * 1024,
state: 'pending' as const,
} satisfies MapShare

export function getMapsQueryKey() {
return [ROOT_QUERY_KEY, 'maps'] as const
}

export function getMapSharesQueryKey() {
return [ROOT_QUERY_KEY, 'maps', 'shares'] as const
}

export function getMapSharesByIdQueryKey({ shareId }: { shareId: string }) {
return [ROOT_QUERY_KEY, 'maps', 'shares', shareId] as const
}

export function getStyleJsonUrlQueryKey({
refreshToken,
}: {
Expand Down Expand Up @@ -36,3 +135,125 @@ export function mapStyleJsonUrlQueryOptions({
},
})
}

export function getMapSharesQueryOptions({
clientApi,
}: {
clientApi: MapeoClientApi
}) {
return queryOptions({
...baseQueryOptions(),
queryKey: getMapSharesQueryKey(),
queryFn: async (): Promise<Array<MapShare>> => {
return [MOCK_MAP_SHARE]
},
})
}

export function getMapShareByIdQueryOptions({
clientApi,
shareId,
}: {
clientApi: MapeoClientApi
shareId: string
}) {
return queryOptions({
...baseQueryOptions(),
queryKey: getMapSharesByIdQueryKey({ shareId }),
queryFn: async (): Promise<MapShare> => {
return MOCK_MAP_SHARE
},
})
}

export function acceptMapShareMutationOptions({
clientApi,
queryClient,
}: {
clientApi: MapeoClientApi
queryClient: QueryClient
}) {
return {
...baseMutationOptions(),
mutationFn: async ({ shareId }) => {
await new Promise((res) => setTimeout(res, 1000))
console.log('Accepted map share', shareId)
return shareId
},
} satisfies UseMutationOptions<string, Error, { shareId: string }>
}

export function rejectMapShareMutationOptions({
clientApi,
queryClient,
}: {
clientApi: MapeoClientApi
queryClient: QueryClient
}) {
return {
...baseMutationOptions(),
mutationFn: async ({ shareId }) => {
await new Promise((res) => setTimeout(res, 1000))
console.log('Rejected map share', shareId)
},
} satisfies UseMutationOptions<void, Error, { shareId: string }>
}

export function sendMapShareMutationOptions({
projectApi,
projectId,
queryClient,
}: {
projectApi: MapeoProjectApi
projectId: string
queryClient: QueryClient
}) {
return {
...baseMutationOptions(),
mutationFn: async ({ deviceId, mapId }) => {
await new Promise((res) => setTimeout(res, 5000))
console.log(
`Sent map share for map ${mapId} to device ${deviceId} on project ${projectId}`,
)
const outcomes: Array<MapShareResponse> = [
{ decision: 'ACCEPT', shareId: 'share-1' },
{ decision: 'REJECT', shareId: 'share-2', reason: 'DISK_SPACE' },
{ decision: 'REJECT', shareId: 'share-3', reason: 'USER_REJECTED' },
]
return (
outcomes[Math.floor(Math.random() * outcomes.length)] || {
decision: 'ACCEPT',
shareId: 'share-1',
}
)
},
} satisfies UseMutationOptions<
MapShareResponse,
Error,
{
deviceId: string
mapId: string
}
>
}

export function requestCancelMapShareMutationOptions({
projectApi,
queryClient,
}: {
projectApi: MapeoProjectApi
queryClient: QueryClient
}) {
return {
...baseMutationOptions(),
mutationFn: async ({ shareId }) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like there could be a parameter mismatch here? projectId? shareId? or like in invites, deviceId? Which should it be?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes good question, I'll think about this more tomorrow.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I think it's possible to implement either shareId or deviceId, where if we used deviceId it would cancel all active map downloads from a device. I think it's better to do shareId like we have right now.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I'm further into the implementation I have an answer for this. The code is only going to allow one download per map share (although retries will be allowed in the case of failure). The cancel on the sender side will be with the shareId.

await new Promise((res) => setTimeout(res, 1000))
console.log('Requested cancellation of map share', shareId)
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getMapSharesQueryKey(),
})
},
} satisfies UseMutationOptions<void, Error, { shareId: string }>
}