Files

257 lines
7.5 KiB
TypeScript

import {useCallback} from 'react'
import {
type $Typed,
AtpAgent,
ChatBskyGroupDefs,
type ChatBskyGroupGetJoinLinkPreviews,
} from '@atproto/api'
import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {STALE} from '#/state/queries/index'
import {createQueryKey, type StructuredQueryKey} from '#/state/queries/util'
import {useAgent} from '#/state/session'
/**
* The three preview shapes we currently support. Excludes the `{$type: string}`
* open-union fallback for unrecognized future variants - use
* `ChatInvitePreview` for that.
*/
export type KnownChatInvitePreview =
| $Typed<ChatBskyGroupDefs.JoinLinkPreviewView>
| $Typed<ChatBskyGroupDefs.DisabledJoinLinkPreviewView>
| $Typed<ChatBskyGroupDefs.InvalidJoinLinkPreviewView>
/**
* The full open-union shape, including the `{$type: string}` fallback for
* future variants.
*/
export type ChatInvitePreview = KnownChatInvitePreview | {$type: string}
/**
* Narrows a preview to one of the three known variants, filtering out the
* `{$type: string}` open-union fallback for unrecognized future shapes.
*/
export function isKnownJoinLinkPreview(
preview: unknown,
): preview is KnownChatInvitePreview {
return (
ChatBskyGroupDefs.isJoinLinkPreviewView(preview) ||
ChatBskyGroupDefs.isDisabledJoinLinkPreviewView(preview) ||
ChatBskyGroupDefs.isInvalidJoinLinkPreviewView(preview)
)
}
const joinLinkPreviewQueryKeyRoot = 'join-link-preview'
export const createJoinLinkPreviewQueryKey = (args: {
codes: string[]
hasSession: boolean
}) =>
createQueryKey(joinLinkPreviewQueryKeyRoot, args, {
persistedVersion: 1,
})
/**
* Invalidate any join link preview queries whose `codes` include the given
* code. Use this when a link's state changes (e.g. it's disabled) so cached
* previews refetch and reflect the new state.
*/
export function invalidateJoinLinkPreviewsForCode(
queryClient: QueryClient,
code: string,
) {
return queryClient.invalidateQueries({
predicate: query => {
const [root, args] = query.queryKey as Partial<
StructuredQueryKey<{codes?: string[]}>
>
return (
root === joinLinkPreviewQueryKeyRoot &&
Array.isArray(args?.codes) &&
args.codes.includes(code)
)
},
})
}
/**
* Optimistically set whether the viewer has requested to join the link with the
* given code, across any cached join link preview queries. Used right after a
* successful join request (requested = true) or withdrawal (requested = false)
* so the UI ("Requested" vs "Request to join") updates immediately, without
* waiting on a server refetch that can lag behind the write.
*/
export function setJoinLinkPreviewRequestedForCode(
queryClient: QueryClient,
code: string,
requested: boolean,
) {
queryClient.setQueriesData<ChatBskyGroupGetJoinLinkPreviews.OutputSchema>(
{
predicate: query => {
const [root, args] = query.queryKey as Partial<
StructuredQueryKey<{codes?: string[]}>
>
return (
root === joinLinkPreviewQueryKeyRoot &&
Array.isArray(args?.codes) &&
args.codes.includes(code)
)
},
},
old => {
if (!old) return old
return {
...old,
joinLinkPreviews: old.joinLinkPreviews.map(preview => {
if (
ChatBskyGroupDefs.isJoinLinkPreviewView(preview) &&
preview.code === code
) {
return {
...preview,
viewer: {
...preview.viewer,
requestedAt: requested ? new Date().toISOString() : undefined,
},
}
}
return preview
}),
}
},
)
}
/**
* Invalidate any join link preview queries that resolved to the given convo.
* The code isn't always known to the viewer (e.g. when they're a regular
* member), so we match on the convoId carried by the resolved preview instead.
* Use this when the viewer's membership changes (e.g. they leave or are removed)
* so cached previews refetch and reflect their new viewer state.
*/
export function invalidateJoinLinkPreviewsForConvo(
queryClient: QueryClient,
convoId: string,
) {
return queryClient.invalidateQueries({
predicate: query => {
const [root] = query.queryKey
if (root !== joinLinkPreviewQueryKeyRoot) return false
const data = query.state.data as
| ChatBskyGroupGetJoinLinkPreviews.OutputSchema
| undefined
return (
data?.joinLinkPreviews.some(
preview =>
ChatBskyGroupDefs.isJoinLinkPreviewView(preview) &&
preview.convoId === convoId,
) ?? false
)
},
})
}
async function fetchJoinLinkPreviews({
agent,
codes,
hasSession,
}: {
agent: AtpAgent
codes: string[]
hasSession: boolean
}) {
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
const res = hasSession
? await agent.chat.bsky.group.getJoinLinkPreviews(
{codes},
{headers: DM_SERVICE_HEADERS},
)
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
return res.data
}
export function useJoinLinkPreviewsQuery({
codes,
hasSession,
staleTime = STALE.MINUTES.ONE,
initialData,
}: {
codes?: string[]
hasSession: boolean
staleTime?: number
/**
* Seed the query with an already-known preview (e.g. a DM message embed
* already carries the resolved view), avoiding a duplicate fetch.
*/
initialData?: ChatBskyGroupGetJoinLinkPreviews.OutputSchema
}) {
const agent = useAgent()
return useQuery({
queryKey: createJoinLinkPreviewQueryKey({codes: codes ?? [], hasSession}),
queryFn: async () => {
if (!codes) throw new Error('No invite code')
try {
return await fetchJoinLinkPreviews({agent, codes, hasSession})
} catch (error) {
logger.error('Failed to fetch join link preview', {safeMessage: error})
throw error
}
},
enabled: codes != null && codes.length > 0,
staleTime,
initialData,
})
}
export function usePrefetchJoinLinkPreviews() {
const agent = useAgent()
const queryClient = useQueryClient()
return ({codes, hasSession}: {codes: string[]; hasSession: boolean}) => {
return queryClient.prefetchQuery({
queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}),
queryFn: () => fetchJoinLinkPreviews({agent, codes, hasSession}),
staleTime: STALE.SECONDS.FIFTEEN,
})
}
}
/**
* Imperatively fetch (or read from cache) a single join link preview by code.
* Used when sending a DM invite embed so we can build an optimistic view.
* Returns undefined if the preview can't be resolved.
*/
export function useGetJoinLinkPreview() {
const agent = useAgent()
const queryClient = useQueryClient()
return useCallback(
async ({
code,
hasSession,
}: {
code: string
hasSession: boolean
}): Promise<KnownChatInvitePreview | undefined> => {
try {
const data = await queryClient.fetchQuery({
queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}),
queryFn: () =>
fetchJoinLinkPreviews({agent, codes: [code], hasSession}),
staleTime: STALE.SECONDS.FIFTEEN,
})
const found = data.joinLinkPreviews[0]
return isKnownJoinLinkPreview(found) ? found : undefined
} catch (error) {
logger.error('Failed to fetch join link preview', {safeMessage: error})
return undefined
}
},
[agent, queryClient],
)
}