import { combineEpics, ActionsObservable } from 'redux-observable' import { Observable, Subscription, Subject, Observer } from 'rxjs' import { Socket } from 'phoenix-socket' import { Store } from 'redux' import { AuthenticatePayload, AuthenticateSuccessPayload, JoinChannelPayload, ConnectPayload } from './interfaces' import * as actions from './actions' import * as constants from './constants' import * as selectors from './selectors' import * as api from './api' import { accessToken } from '../auth/selectors' import { submitLoginSuccess } from '../auth/actions' import { socketErrorState } from '../analytics/actions' const AUTO_JOIN_CHANNELS = { [constants.SocketCluster.MESSAGES]: [constants.SocketChannel.USER, constants.SocketChannel.ORG_PRIVATE], [constants.SocketCluster.PRESENCE]: [constants.SocketChannel.ORG_PUBLIC], } const generateAuthParams = (store: Store, clusterName: string) => { const state = store.getState() return { apiBaseUrl: selectors.getApiBaseUrl(state, { clusterName }), authParams: selectors.getAuthParams(state), } } const makeClusterEpic = (clusterName: string) => { const authenticateEpic = (action$: ActionsObservable, store: Store) => action$ .ofType(actions.authenticate.toString()) .pluck('payload') .filter((payload: AuthenticatePayload) => payload.clusterName === clusterName) .mergeMap((): any => { const state = store.getState() if (accessToken(state)) { return Observable.of(generateAuthParams(store, clusterName)) } else { return action$ .ofType(submitLoginSuccess.toString()) .map(() => { return generateAuthParams(store, clusterName) }) .first() } }) .switchMap((payload: any) => { return api.authenticate(payload) .map((data: any) => actions.authenticateSuccess({ clusterName, ...data, })) .catch((error) => { console.warn('Failed to authenticate to websocket API -', error.xhr && error.xhr.response) return Observable.of(actions.authenticateFailure(error)) }) }) const connectEpic = (action$: ActionsObservable, store: Store) => action$ .ofType(actions.authenticateSuccess.toString()) .pluck('payload') .filter((payload: AuthenticateSuccessPayload) => payload.clusterName === clusterName) .switchMap(({ sessionToken }) => { const joinChannel$ = action$ .ofType(actions.joinChannel.toString()) .pluck('payload') .filter((payload: JoinChannelPayload) => payload.clusterName === clusterName) return Observable.create((observer: Observer) => { const socket = new Socket(`${selectors.getWssBaseUrl(store.getState(), { clusterName })}/ws`, { params: { session_token: sessionToken }, }) const subscription = new Subscription() subscription.add(joinChannel$.subscribe({ next: ({ channelName }) => { const channel = socket.channel(selectors.getChannelName(store.getState(), { clusterName, channelName })) const onMessageSubject = new Subject() channel.onMessage = (event: any, payload: any, ref: any) => { onMessageSubject.next({ event, payload, ref, }) return payload } const leaveChannel$ = action$ .ofType(constants.LEAVE_CHANNEL) .filter(({ payload }) => payload.clusterName === clusterName) .filter(({ payload }) => payload.channelName === channelName) observer.next(actions.joinChannelPending({ clusterName, channelName, channel, onMessageSubject, leaveChannel$, })) channel .join() .receive('ok', (response: any) => { observer.next(actions.joinChannelSuccess({ clusterName, channelName, channel, leaveChannel$, })) }) .receive('error', (error: any) => { observer.next(actions.joinChannelFailure({ clusterName, channelName, channel, error, })) }) .receive('timeout', () => { observer.next(actions.joinChannelFailure({ clusterName, channelName, channel, error: 'The request has timed out. please try again later', })) }) }, })) socket.connect() observer.next(actions.connect({ clusterName, socket, })) return () => { socket.disconnect(() => { subscription.unsubscribe() }) } }) .takeUntil(action$.ofType(constants.DISCONNECT)) }) const checkChannelsOnSocket = (action$: ActionsObservable) => action$ .ofType(actions.connect.toString()) .pluck('payload') .switchMap(({ socket }) => { return Observable.interval(30000) .mergeMap(() => { if (socket.connectionState() === 'open' && socket.channels.length === 0) { return [socketErrorState(), actions.authenticate({ clusterName: constants.SocketCluster.MESSAGES })] } else { return Observable.empty() } }) .takeUntil(action$.ofType(constants.DISCONNECT)) }) const joinChannelEpic = (action$: ActionsObservable, store: Store) => action$ .ofType(constants.CONNECT) .pluck('payload') .filter((payload: ConnectPayload) => payload.clusterName === clusterName) .flatMap(() => { return AUTO_JOIN_CHANNELS[clusterName] .map((channelName: string) => actions.joinChannel({ clusterName, channelName })) }) return combineEpics( authenticateEpic, connectEpic, joinChannelEpic, checkChannelsOnSocket, ) } export const makeJoinChannelPendingEpic = (clusterName: string, channelName: string) => { return (action$: ActionsObservable) => { return action$ .ofType(actions.joinChannelPending.toString()) .pluck('payload') .filter((payload: any) => payload.clusterName === clusterName) .filter((payload: any) => payload.channelName === channelName) } } export const makeJoinChannelSuccessEpic = (clusterName: string, channelName: string) => { return (action$: ActionsObservable) => { return action$ .ofType(actions.joinChannelSuccess.toString()) .pluck('payload') .filter((payload: any) => payload.clusterName === clusterName) .filter((payload: any) => payload.channelName === channelName) } } export const epic = combineEpics( makeClusterEpic(constants.SocketCluster.MESSAGES), makeClusterEpic(constants.SocketCluster.PRESENCE), )