Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
33 changes: 19 additions & 14 deletions src/hooks/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,16 @@ import { useSyncExternalStore } from 'react'

import {
addServerPeerMutationOptions,
attachmentUrlQueryOptions,
connectSyncServersMutationOptions,
createBlobMutationOptions,
createProjectMutationOptions,
disconnectSyncServersMutationOptions,
documentCreatedByQueryOptions,
exportGeoJSONMutationOptions,
exportZipFileMutationOptions,
iconUrlQueryOptions,
importProjectConfigMutationOptions,
leaveProjectMutationOptions,
mediaServerPortQueryOptions,
projectByIdQueryOptions,
projectMemberByIdQueryOptions,
projectMembersQueryOptions,
Expand All @@ -36,6 +35,7 @@ import {
updateProjectSettingsMutationOptions,
} from '../lib/react-query/projects.js'
import { SyncStore, type SyncState } from '../lib/sync.js'
import { getBlobUrl, getIconUrl } from '../lib/urls.js'
import { useClientApi } from './client.js'

/**
Expand Down Expand Up @@ -237,16 +237,11 @@ export function useIconUrl({
} & (IconApi.BitmapOpts | IconApi.SvgOpts)) {
const { data: projectApi } = useSingleProject({ projectId })

const { data, error, isRefetching } = useSuspenseQuery(
iconUrlQueryOptions({
...mimeBasedOpts,
projectApi,
projectId,
iconId,
}),
)
const { data: port, error, isRefetching } = useMediaServerPort({ projectApi })
const baseUrl = `http://127.0.0.1:${port}`
const iconUrl = getIconUrl(baseUrl, iconId, mimeBasedOpts)

return { data, error, isRefetching }
return { data: iconUrl, error, isRefetching }
}

/**
Expand Down Expand Up @@ -309,11 +304,21 @@ export function useAttachmentUrl({
}) {
const { data: projectApi } = useSingleProject({ projectId })

const { data: port, error, isRefetching } = useMediaServerPort({ projectApi })
const baseUrl = `http://127.0.0.1:${port}`
const blobUrl = getBlobUrl(baseUrl, blobId)

return { data: blobUrl, error, isRefetching }
}

/**
* @internal
* Hack to retrieve the media server port.
*/
function useMediaServerPort({ projectApi }: { projectApi: MapeoProjectApi }) {
const { data, error, isRefetching } = useSuspenseQuery(
attachmentUrlQueryOptions({
mediaServerPortQueryOptions({
projectApi,
projectId,
blobId,
}),
)

Expand Down
78 changes: 24 additions & 54 deletions src/lib/react-query/projects.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type {
BlobApi,
EditableProjectSettings,
IconApi,
} from '@comapeo/core' with { 'resolution-mode': 'import' }
import type {
MapeoClientApi,
Expand Down Expand Up @@ -55,24 +54,6 @@ export function getMemberByIdQueryKey({
return [ROOT_QUERY_KEY, 'projects', projectId, 'members', deviceId] as const
}

export function getIconUrlQueryKey({
projectId,
iconId,
...mimeBasedOpts
}: {
projectId: string
iconId: string
} & (IconApi.BitmapOpts | IconApi.SvgOpts)) {
return [
ROOT_QUERY_KEY,
'projects',
projectId,
'icons',
iconId,
mimeBasedOpts,
] as const
}

export function getDocumentCreatedByQueryKey({
projectId,
originalVersionId,
Expand All @@ -89,14 +70,13 @@ export function getDocumentCreatedByQueryKey({
] as const
}

export function getAttachmentUrlQueryKey({
projectId,
blobId,
}: {
projectId: string
blobId: BlobApi.BlobId
}) {
return [ROOT_QUERY_KEY, 'projects', projectId, 'attachments', blobId] as const
/**
* We call this within a project hook, because that's the only place the API is
* exposed right now, but it is the same for all projects, so no need for
* scoping the query key to the project
*/
export function getMediaServerPortQueryKey() {
return [ROOT_QUERY_KEY, 'media_server_port'] as const
}

export function projectsQueryOptions({
Expand Down Expand Up @@ -200,25 +180,6 @@ export function projectOwnRoleQueryOptions({
})
}

export function iconUrlQueryOptions({
projectApi,
projectId,
iconId,
...mimeBasedOpts
}: {
projectApi: MapeoProjectApi
projectId: string
iconId: Parameters<MapeoProjectApi['$icons']['getIconUrl']>[0]
} & (IconApi.BitmapOpts | IconApi.SvgOpts)) {
return queryOptions({
...baseQueryOptions(),
queryKey: getIconUrlQueryKey({ ...mimeBasedOpts, projectId, iconId }),
queryFn: async () => {
return projectApi.$icons.getIconUrl(iconId, mimeBasedOpts)
},
})
}

export function documentCreatedByQueryOptions({
projectApi,
projectId,
Expand All @@ -240,22 +201,31 @@ export function documentCreatedByQueryOptions({
})
}

export function attachmentUrlQueryOptions({
export function mediaServerPortQueryOptions({
projectApi,
projectId,
blobId,
}: {
projectApi: MapeoProjectApi
projectId: string
blobId: BlobApi.BlobId
}) {
const fakeBlobId: BlobApi.BlobId = {
type: 'photo',
variant: 'original',
name: 'name',
driveId: 'drive-id',
}
return queryOptions({
...baseQueryOptions(),
queryKey: getAttachmentUrlQueryKey({ projectId, blobId }),
// HACK: The server doesn't yet expose a method to get its port, so we use
// the existing $blobs.getUrl() to get the port with a fake BlobId. The port
// is the same regardless of the blobId, so it's not necessary to include it
// as a dep for the query key.
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: getMediaServerPortQueryKey(),
queryFn: async () => {
// TODO: Might need a refresh token? (similar to map style url)
return projectApi.$blobs.getUrl(blobId)
const url = await projectApi.$blobs.getUrl(fakeBlobId)
return new URL(url).port
},
staleTime: 'static',
gcTime: Infinity,
})
}

Expand Down
92 changes: 92 additions & 0 deletions src/lib/urls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// TODO: Move these into a separate "@comapeo/asset-server" module which can
// export them to be imported directly in a client.

import type { BlobApi, IconApi } from '@comapeo/core'

const MIME_TO_EXTENSION = {
'image/png': '.png',
'image/svg+xml': '.svg',
}

/**
* Get a url for a blob based on its BlobId
*/
export function getBlobUrl(baseUrl: string, blobId: BlobApi.BlobId) {
const { driveId, type, variant, name } = blobId

if (!baseUrl.endsWith('/')) {
baseUrl += '/'
}

return baseUrl + `${driveId}/${type}/${variant}/${name}`
}

/**
* @param {string} iconId
* @param {BitmapOpts | SvgOpts} opts
*
* @returns {Promise<string>}
*/
export function getIconUrl(
baseUrl: string,
iconId: string,
opts: IconApi.BitmapOpts | IconApi.SvgOpts,
) {
if (!baseUrl.endsWith('/')) {
baseUrl += '/'
}

const mimeExtension = MIME_TO_EXTENSION[opts.mimeType]

const pixelDensity =
opts.mimeType === 'image/svg+xml' ||
// if the pixel density is 1, we can omit the density suffix in the resulting url
// and assume the pixel density is 1 for applicable mime types when using the url
opts.pixelDensity === 1
? undefined
: opts.pixelDensity

return (
baseUrl +
constructIconPath({
pixelDensity,
size: opts.size,
extension: mimeExtension,
iconId,
})
)
}

type IconPathOptions = {
iconId: string
size: string
pixelDensity?: number
extension: string
}

/**
* General purpose path builder for an icon
*/
function constructIconPath({
size,
pixelDensity,
iconId,
extension,
}: IconPathOptions): string {
if (iconId.length === 0 || size.length === 0 || extension.length === 0) {
throw new Error('iconId, size, and extension cannot be empty strings')
}

let result = `${iconId}/${size}`

if (typeof pixelDensity === 'number') {
if (pixelDensity < 1) {
throw new Error('pixelDensity must be a positive number')
}
result += `@${pixelDensity}x`
}

result += extension.startsWith('.') ? extension : '.' + extension

return result
}