Skip to content

Commit baa3454

Browse files
authored
feat: get attachment and icon URLs without async requests (#97)
* feat: get attachment and icon URLs without async requests * move fakeBlobId to top-level const
1 parent 146dd04 commit baa3454

File tree

3 files changed

+136
-68
lines changed

3 files changed

+136
-68
lines changed

src/hooks/projects.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,16 @@ import { useSyncExternalStore } from 'react'
1212

1313
import {
1414
addServerPeerMutationOptions,
15-
attachmentUrlQueryOptions,
1615
connectSyncServersMutationOptions,
1716
createBlobMutationOptions,
1817
createProjectMutationOptions,
1918
disconnectSyncServersMutationOptions,
2019
documentCreatedByQueryOptions,
2120
exportGeoJSONMutationOptions,
2221
exportZipFileMutationOptions,
23-
iconUrlQueryOptions,
2422
importProjectConfigMutationOptions,
2523
leaveProjectMutationOptions,
24+
mediaServerPortQueryOptions,
2625
projectByIdQueryOptions,
2726
projectMemberByIdQueryOptions,
2827
projectMembersQueryOptions,
@@ -36,6 +35,7 @@ import {
3635
updateProjectSettingsMutationOptions,
3736
} from '../lib/react-query/projects.js'
3837
import { SyncStore, type SyncState } from '../lib/sync.js'
38+
import { getBlobUrl, getIconUrl } from '../lib/urls.js'
3939
import { useClientApi } from './client.js'
4040

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

240-
const { data, error, isRefetching } = useSuspenseQuery(
241-
iconUrlQueryOptions({
242-
...mimeBasedOpts,
243-
projectApi,
244-
projectId,
245-
iconId,
246-
}),
247-
)
240+
const { data: port, error, isRefetching } = useMediaServerPort({ projectApi })
241+
const baseUrl = `http://127.0.0.1:${port}`
242+
const iconUrl = getIconUrl(baseUrl, iconId, mimeBasedOpts)
248243

249-
return { data, error, isRefetching }
244+
return { data: iconUrl, error, isRefetching }
250245
}
251246

252247
/**
@@ -309,11 +304,21 @@ export function useAttachmentUrl({
309304
}) {
310305
const { data: projectApi } = useSingleProject({ projectId })
311306

307+
const { data: port, error, isRefetching } = useMediaServerPort({ projectApi })
308+
const baseUrl = `http://127.0.0.1:${port}`
309+
const blobUrl = getBlobUrl(baseUrl, blobId)
310+
311+
return { data: blobUrl, error, isRefetching }
312+
}
313+
314+
/**
315+
* @internal
316+
* Hack to retrieve the media server port.
317+
*/
318+
function useMediaServerPort({ projectApi }: { projectApi: MapeoProjectApi }) {
312319
const { data, error, isRefetching } = useSuspenseQuery(
313-
attachmentUrlQueryOptions({
320+
mediaServerPortQueryOptions({
314321
projectApi,
315-
projectId,
316-
blobId,
317322
}),
318323
)
319324

src/lib/react-query/projects.ts

Lines changed: 25 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type {
22
BlobApi,
33
EditableProjectSettings,
4-
IconApi,
54
} from '@comapeo/core' with { 'resolution-mode': 'import' }
65
import type {
76
MapeoClientApi,
@@ -55,24 +54,6 @@ export function getMemberByIdQueryKey({
5554
return [ROOT_QUERY_KEY, 'projects', projectId, 'members', deviceId] as const
5655
}
5756

58-
export function getIconUrlQueryKey({
59-
projectId,
60-
iconId,
61-
...mimeBasedOpts
62-
}: {
63-
projectId: string
64-
iconId: string
65-
} & (IconApi.BitmapOpts | IconApi.SvgOpts)) {
66-
return [
67-
ROOT_QUERY_KEY,
68-
'projects',
69-
projectId,
70-
'icons',
71-
iconId,
72-
mimeBasedOpts,
73-
] as const
74-
}
75-
7657
export function getDocumentCreatedByQueryKey({
7758
projectId,
7859
originalVersionId,
@@ -89,14 +70,13 @@ export function getDocumentCreatedByQueryKey({
8970
] as const
9071
}
9172

92-
export function getAttachmentUrlQueryKey({
93-
projectId,
94-
blobId,
95-
}: {
96-
projectId: string
97-
blobId: BlobApi.BlobId
98-
}) {
99-
return [ROOT_QUERY_KEY, 'projects', projectId, 'attachments', blobId] as const
73+
/**
74+
* We call this within a project hook, because that's the only place the API is
75+
* exposed right now, but it is the same for all projects, so no need for
76+
* scoping the query key to the project
77+
*/
78+
export function getMediaServerPortQueryKey() {
79+
return [ROOT_QUERY_KEY, 'media_server_port'] as const
10080
}
10181

10282
export function projectsQueryOptions({
@@ -200,25 +180,6 @@ export function projectOwnRoleQueryOptions({
200180
})
201181
}
202182

203-
export function iconUrlQueryOptions({
204-
projectApi,
205-
projectId,
206-
iconId,
207-
...mimeBasedOpts
208-
}: {
209-
projectApi: MapeoProjectApi
210-
projectId: string
211-
iconId: Parameters<MapeoProjectApi['$icons']['getIconUrl']>[0]
212-
} & (IconApi.BitmapOpts | IconApi.SvgOpts)) {
213-
return queryOptions({
214-
...baseQueryOptions(),
215-
queryKey: getIconUrlQueryKey({ ...mimeBasedOpts, projectId, iconId }),
216-
queryFn: async () => {
217-
return projectApi.$icons.getIconUrl(iconId, mimeBasedOpts)
218-
},
219-
})
220-
}
221-
222183
export function documentCreatedByQueryOptions({
223184
projectApi,
224185
projectId,
@@ -242,22 +203,32 @@ export function documentCreatedByQueryOptions({
242203
})
243204
}
244205

245-
export function attachmentUrlQueryOptions({
206+
// Used as a placeholder so that we can read the server port from the $blobs.getUrl() method
207+
const FAKE_BLOB_ID: BlobApi.BlobId = {
208+
type: 'photo',
209+
variant: 'original',
210+
name: 'name',
211+
driveId: 'drive-id',
212+
}
213+
214+
export function mediaServerPortQueryOptions({
246215
projectApi,
247-
projectId,
248-
blobId,
249216
}: {
250217
projectApi: MapeoProjectApi
251-
projectId: string
252-
blobId: BlobApi.BlobId
253218
}) {
254219
return queryOptions({
255220
...baseQueryOptions(),
256-
queryKey: getAttachmentUrlQueryKey({ projectId, blobId }),
221+
// HACK: The server doesn't yet expose a method to get its port, so we use
222+
// the existing $blobs.getUrl() to get the port with a fake BlobId. The port
223+
// is the same regardless of the blobId, so it's not necessary to include it
224+
// as a dep for the query key.
225+
queryKey: getMediaServerPortQueryKey(),
257226
queryFn: async () => {
258-
// TODO: Might need a refresh token? (similar to map style url)
259-
return projectApi.$blobs.getUrl(blobId)
227+
const url = await projectApi.$blobs.getUrl(FAKE_BLOB_ID)
228+
return new URL(url).port
260229
},
230+
staleTime: 'static',
231+
gcTime: Infinity,
261232
})
262233
}
263234

src/lib/urls.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// TODO: Move these into a separate "@comapeo/asset-server" module which can
2+
// export them to be imported directly in a client.
3+
4+
import type { BlobApi, IconApi } from '@comapeo/core'
5+
6+
const MIME_TO_EXTENSION = {
7+
'image/png': '.png',
8+
'image/svg+xml': '.svg',
9+
}
10+
11+
/**
12+
* Get a url for a blob based on its BlobId
13+
*/
14+
export function getBlobUrl(baseUrl: string, blobId: BlobApi.BlobId) {
15+
const { driveId, type, variant, name } = blobId
16+
17+
if (!baseUrl.endsWith('/')) {
18+
baseUrl += '/'
19+
}
20+
21+
return baseUrl + `${driveId}/${type}/${variant}/${name}`
22+
}
23+
24+
/**
25+
* @param {string} iconId
26+
* @param {BitmapOpts | SvgOpts} opts
27+
*
28+
* @returns {Promise<string>}
29+
*/
30+
export function getIconUrl(
31+
baseUrl: string,
32+
iconId: string,
33+
opts: IconApi.BitmapOpts | IconApi.SvgOpts,
34+
) {
35+
if (!baseUrl.endsWith('/')) {
36+
baseUrl += '/'
37+
}
38+
39+
const mimeExtension = MIME_TO_EXTENSION[opts.mimeType]
40+
41+
const pixelDensity =
42+
opts.mimeType === 'image/svg+xml' ||
43+
// if the pixel density is 1, we can omit the density suffix in the resulting url
44+
// and assume the pixel density is 1 for applicable mime types when using the url
45+
opts.pixelDensity === 1
46+
? undefined
47+
: opts.pixelDensity
48+
49+
return (
50+
baseUrl +
51+
constructIconPath({
52+
pixelDensity,
53+
size: opts.size,
54+
extension: mimeExtension,
55+
iconId,
56+
})
57+
)
58+
}
59+
60+
type IconPathOptions = {
61+
iconId: string
62+
size: string
63+
pixelDensity?: number
64+
extension: string
65+
}
66+
67+
/**
68+
* General purpose path builder for an icon
69+
*/
70+
function constructIconPath({
71+
size,
72+
pixelDensity,
73+
iconId,
74+
extension,
75+
}: IconPathOptions): string {
76+
if (iconId.length === 0 || size.length === 0 || extension.length === 0) {
77+
throw new Error('iconId, size, and extension cannot be empty strings')
78+
}
79+
80+
let result = `${iconId}/${size}`
81+
82+
if (typeof pixelDensity === 'number') {
83+
if (pixelDensity < 1) {
84+
throw new Error('pixelDensity must be a positive number')
85+
}
86+
result += `@${pixelDensity}x`
87+
}
88+
89+
result += extension.startsWith('.') ? extension : '.' + extension
90+
91+
return result
92+
}

0 commit comments

Comments
 (0)