import JoltClient from '@planningcenter/jolt-client' import { CustomMessage, FetchAuthToken, } from '@planningcenter/jolt-client/dist/types/JoltConnection' import { FetchSubscribeToken, JoltSubscription, } from '@planningcenter/jolt-client/dist/types/JoltSubscription' import { QueryClient, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useEffect, useMemo } from 'react' import { AppState, AppStateStatus } from 'react-native' import { useChatContext } from '../contexts/chat_context' import { ApiResource } from '../types' import type { JoltSubscriptionPattern } from '../types/jolt_events' import { Client, Uri } from '../utils' import { throwResponseError } from '../utils/response_error' import { FIVE_SECONDS } from '../utils/time' interface JoltResponse { type: 'JoltToken' id: string wssUrl: string } export const useJoltClient = (): JoltClient | undefined => { const { session } = useChatContext() const sessionAccessToken = session.token?.access_token const queryClient = useQueryClient() const uri = useMemo(() => new Uri({ session }), [session]) const apiClient = useMemo( () => new Client({ root: uri.api(`/chat/v2`), defaultHeaders: uri.headers, version: '2018-11-01', }), [uri] ) const fetchJoltToken = async () => { return queryClient.fetchQuery({ queryKey: ['jolt-token'], queryFn: () => { return apiClient .post>({ url: '/me/jolt_authorize', data: { data: { type: 'JoltToken', attributes: {}, }, }, }) .catch(throwResponseError) }, staleTime: FIVE_SECONDS, }) } /** The wssUrl is static and doesn't change so we can cache it to infinity and beyond */ const { data: wssUrl } = useSuspenseQuery({ queryKey: ['wss-url'], queryFn: () => fetchJoltToken().then(res => res.data.wssUrl), staleTime: Infinity, gcTime: Infinity, refetchOnWindowFocus: false, }) const fetchAuthTokenFn: FetchAuthToken = () => { return queryClient.fetchQuery({ queryKey: ['jolt-auth-token'], queryFn: () => fetchJoltToken().then(res => res.data.id), }) } const fetchSubscribeToken: FetchSubscribeToken = (channel: string, connectionId: string) => { return apiClient .post>({ url: '/me/jolt_subscribe', data: { data: { type: 'JoltSubscribeToken', attributes: { channel, cid: connectionId }, }, }, }) .then(res => res.data.id) .catch((res: unknown) => { console.error('failed to subscribe to Jolt channel', res) return '' }) } const fetchSubscribeTokenFn: FetchSubscribeToken = ( channel: string, connectionId: string, options ) => { return queryClient.fetchQuery({ queryKey: ['jolt-subscribe-token', channel, connectionId], queryFn: () => fetchSubscribeToken(channel, connectionId, options), }) } const { data: joltClient } = useQuery({ enabled: Boolean(wssUrl), queryKey: ['jolt-client', wssUrl, sessionAccessToken], queryFn: async () => { if (!wssUrl) return undefined return new JoltClient( wssUrl, { fetchAuthTokenFn, fetchSubscribeTokenFn, }, { logToConsole: false } ) }, refetchOnWindowFocus: false, staleTime: Infinity, gcTime: Infinity, }) return joltClient } const pingSubscribedJoltClients = (queryClient: QueryClient) => { queryClient .getQueryCache() .findAll({ queryKey: ['jolt-client'] }) .forEach(query => { const joltClient = query.state.data as JoltClient | undefined if (!joltClient || joltClient.subscriptions.length === 0) return try { joltClient.ping() } catch {} }) } const foregroundPingListeners = new Map void }>() const subscribeToForegroundPing = (queryClient: QueryClient) => { const existing = foregroundPingListeners.get(queryClient) if (existing) { existing.mounts += 1 return } let previousState: AppStateStatus = AppState.currentState const subscription = AppState.addEventListener('change', nextState => { if (nextState === 'active' && previousState !== 'active') { pingSubscribedJoltClients(queryClient) } previousState = nextState }) foregroundPingListeners.set(queryClient, { mounts: 1, remove: () => subscription.remove() }) } const unsubscribeFromForegroundPing = (queryClient: QueryClient) => { const existing = foregroundPingListeners.get(queryClient) if (!existing) return existing.mounts -= 1 if (existing.mounts > 0) return existing.remove() foregroundPingListeners.delete(queryClient) } export const useJoltForegroundPing = () => { const queryClient = useQueryClient() useEffect(() => { subscribeToForegroundPing(queryClient) return () => unsubscribeFromForegroundPing(queryClient) }, [queryClient]) } export function useJoltChannel( channelName: string, enabled: boolean = true ): JoltSubscription | undefined | null { const jolt = useJoltClient() const queryClient = useQueryClient() const ready = Boolean(jolt) && enabled const handleSubscribe = useCallback(async () => { if (!jolt) return null // If the subscription already exists, we don't need to subscribe again const alreadySubscribed = jolt.subscriptions.find(c => c.channel === channelName) if (alreadySubscribed) return alreadySubscribed return jolt.subscribe(channelName) }, [channelName, jolt]) const { data: subscription } = useQuery({ queryKey: ['jolt-subscription', channelName], queryFn: handleSubscribe, refetchOnWindowFocus: false, enabled: ready, }) const handleUnsubscribe = useCallback(() => { queryClient.removeQueries({ queryKey: ['jolt-subscription', channelName], exact: true, }) jolt?.unsubscribe(channelName) }, [queryClient, channelName, jolt]) useEffect(() => { return handleUnsubscribe }, [handleUnsubscribe]) return subscription } type UserCallbackFn = (_event: T) => void export function useJoltEvent( channel: JoltSubscription | undefined | null, eventName: JoltSubscriptionPattern, callback: UserCallbackFn ) { useEffect(() => { if (!channel) return () => {} return channel.bind(eventName, e => callback(e as T)) }, [channel, eventName, callback]) }