strimertul/frontend/src/store/api/reducer.ts

428 lines
12 KiB
TypeScript

/* eslint-disable no-param-reassign */
import {
AsyncThunk,
CaseReducer,
createAction,
createAsyncThunk,
createSlice,
Dispatch,
PayloadAction,
UnknownAction,
} from '@reduxjs/toolkit';
import KilovoltWS, { KilovoltMessage } from '@strimertul/kilovolt-client';
import type { kvError } from '@strimertul/kilovolt-client/types/messages';
import { AuthenticateKVClient, IsServerReady } from '@wailsapp/go/main/App';
import { delay } from '~/lib/time';
import {
APIState,
ConnectionStatus,
HTTPConfig,
LoyaltyPointsEntry,
LoyaltyRedeem,
LoyaltyStorage,
TwitchChatConfig,
TwitchConfig,
TwitchChatCustomCommands,
TwitchChatTimersConfig,
TwitchChatAlertsConfig,
LoyaltyConfig,
LoyaltyReward,
LoyaltyGoal,
UISettings,
} from './types';
import { ThunkConfig } from '..';
type ThunkAPIState = { api: APIState };
interface AppThunkAPI {
dispatch: Dispatch;
getState: () => ThunkAPIState;
}
function makeGetSetThunks<T>(key: string) {
const getter = createAsyncThunk<T, void, { state: ThunkAPIState }>(
`api/get/${key}`,
async (_, { getState }) => {
const { api } = getState();
return api.client.getJSON<T>(key);
},
);
const setter = createAsyncThunk<KilovoltMessage, T, { state: ThunkAPIState }>(
`api/set/${key}`,
async (data: T, { getState, dispatch }: AppThunkAPI) => {
const { api } = getState();
const result = await api.client.putJSON(key, data);
if ('ok' in result) {
if (result.ok) {
// Re-load value from KV
// Need to do type fuckery to avoid cyclic redundancy
// (unless there's a better way that I'm missing)
void dispatch(getter() as unknown as UnknownAction);
}
}
return result;
},
);
return { getter, setter };
}
function makeModule<T>(
key: string,
selector: (state: APIState) => T,
stateSetter: CaseReducer<APIState>,
) {
return {
...makeGetSetThunks<T>(key),
key,
selector,
stateSetter,
asyncSetter: createAction<T>(`asyncSetter/${key}`),
};
}
// eslint-disable-next-line @typescript-eslint/ban-types
let setupClientReconnect: AsyncThunk<void, KilovoltWS, {}>;
// eslint-disable-next-line @typescript-eslint/ban-types
let kvErrorReceived: AsyncThunk<void, kvError, {}>;
// Storage
const loyaltyPointsPrefix = 'loyalty/points/';
const loyaltyRewardsKey = 'loyalty/rewards';
// RPCs
const loyaltyCreateRedeemKey = 'loyalty/@create-redeem';
const loyaltyRemoveRedeemKey = 'loyalty/@remove-redeem';
export const createWSClient = createAsyncThunk(
'api/createClient',
async (options: { address: string; password?: string }, { dispatch }) => {
// Wait for server to be ready
let ready = false;
while (!ready) {
// eslint-disable-next-line no-await-in-loop
ready = await IsServerReady();
if (ready) {
break;
}
// eslint-disable-next-line no-await-in-loop
await delay(1000);
}
// Connect to websocket
const client = new KilovoltWS(options.address, {
password: options.password,
});
client.on('error', (err: CustomEvent<kvError>) => {
void dispatch(kvErrorReceived(err.detail));
});
await client.connect();
await dispatch(setupClientReconnect(client));
return client;
},
);
export const getUserPoints = createAsyncThunk<
LoyaltyStorage,
void,
ThunkConfig
>('api/getUserPoints', async (_, { getState }) => {
const { api } = getState();
const keys = await api.client.getKeysByPrefix(loyaltyPointsPrefix);
const userpoints: LoyaltyStorage = {};
Object.entries(keys).forEach(([k, v]) => {
userpoints[k.substring(loyaltyPointsPrefix.length)] = JSON.parse(
v,
) as LoyaltyPointsEntry;
});
return userpoints;
});
export const setUserPoints = createAsyncThunk<
KilovoltMessage,
{ user: string; points: number; relative: boolean },
ThunkConfig
>('api/setUserPoints', async ({ user, points, relative }, { getState }) => {
const { api } = getState();
const entry: LoyaltyPointsEntry = { points };
if (relative) {
entry.points += api.loyalty.users[user]?.points ?? 0;
}
return api.client.putJSON(loyaltyPointsPrefix + user, entry);
});
export const modules = {
httpConfig: makeModule(
'http/config',
(state) => state.moduleConfigs?.httpConfig,
(state, { payload }) => {
state.moduleConfigs.httpConfig = payload as HTTPConfig;
},
),
twitchConfig: makeModule(
'twitch/config',
(state) => state.moduleConfigs?.twitchConfig,
(state, { payload }) => {
state.moduleConfigs.twitchConfig = payload as TwitchConfig;
},
),
twitchChatConfig: makeModule(
'twitch/chat/config',
(state) => state.moduleConfigs?.twitchChatConfig,
(state, { payload }) => {
state.moduleConfigs.twitchChatConfig = payload as TwitchChatConfig;
},
),
twitchChatCommands: makeModule(
'twitch/chat/custom-commands',
(state) => state.twitchChat?.commands,
(state, { payload }) => {
state.twitchChat.commands = payload as TwitchChatCustomCommands;
},
),
twitchChatTimers: makeModule(
'twitch/timers/config',
(state) => state.twitchChat?.timers,
(state, { payload }) => {
state.twitchChat.timers = payload as TwitchChatTimersConfig;
},
),
twitchChatAlerts: makeModule(
'twitch/alerts/config',
(state) => state.twitchChat?.alerts,
(state, { payload }) => {
state.twitchChat.alerts = payload as TwitchChatAlertsConfig;
},
),
loyaltyConfig: makeModule(
'loyalty/config',
(state) => state.moduleConfigs?.loyaltyConfig,
(state, { payload }) => {
state.moduleConfigs.loyaltyConfig = payload as LoyaltyConfig;
},
),
loyaltyRewards: makeModule(
loyaltyRewardsKey,
(state) => state.loyalty.rewards,
(state, { payload }) => {
state.loyalty.rewards = payload as LoyaltyReward[];
},
),
loyaltyGoals: makeModule(
'loyalty/goals',
(state) => state.loyalty.goals,
(state, { payload }) => {
state.loyalty.goals = payload as LoyaltyGoal[];
},
),
loyaltyRedeemQueue: makeModule(
'loyalty/redeem-queue',
(state) => state.loyalty.redeemQueue,
(state, { payload }) => {
state.loyalty.redeemQueue = payload as LoyaltyRedeem[];
},
),
uiConfig: makeModule(
'ui/settings',
(state) => state.uiConfig,
(state, { payload }) => {
state.uiConfig = payload as UISettings;
},
),
};
export const createRedeem = createAsyncThunk<
KilovoltMessage,
LoyaltyRedeem,
ThunkConfig
>('api/createRedeem', async (redeem: LoyaltyRedeem, { getState }) => {
const { api } = getState();
return api.client.putJSON(loyaltyCreateRedeemKey, redeem);
});
export const removeRedeem = createAsyncThunk<
KilovoltMessage,
LoyaltyRedeem,
ThunkConfig
>('api/removeRedeem', async (redeem: LoyaltyRedeem, { getState }) => {
const { api } = getState();
return api.client.putJSON(loyaltyRemoveRedeemKey, redeem);
});
const moduleChangeReducers = Object.fromEntries(
Object.entries(modules).map(([key, mod]) => [
`${key}Changed`,
mod.stateSetter,
]),
) as Record<
`${keyof typeof modules}Changed`,
(state: APIState, action: PayloadAction<unknown>) => never
>;
const initialState: APIState = {
client: null,
connectionStatus: ConnectionStatus.NotConnected,
kvError: null,
initialLoadComplete: false,
loyalty: {
users: null,
rewards: null,
goals: null,
redeemQueue: null,
},
twitchChat: {
commands: null,
timers: null,
alerts: null,
},
moduleConfigs: {
httpConfig: null,
twitchConfig: null,
twitchChatConfig: null,
loyaltyConfig: null,
},
uiConfig: null,
requestStatus: {},
};
const apiReducer = createSlice({
name: 'api',
initialState,
reducers: {
...moduleChangeReducers,
initialLoadCompleted(state) {
state.initialLoadComplete = true;
},
connectionStatusChanged(
state,
{ payload }: PayloadAction<ConnectionStatus>,
) {
state.connectionStatus = payload;
},
kvErrorReceived(state, { payload }: PayloadAction<kvError>) {
state.kvError = payload;
},
loyaltyUserPointsChanged(
state,
{
payload: { user, entry },
}: PayloadAction<{ user: string; entry: LoyaltyPointsEntry }>,
) {
state.loyalty.users[user] = entry;
},
requestKeysRemoved(state, { payload }: PayloadAction<string[]>) {
payload.forEach((key) => {
delete state.requestStatus[key];
});
},
},
extraReducers: (builder) => {
builder.addCase(createWSClient.fulfilled, (state, { payload }) => {
state.client = payload;
state.connectionStatus = ConnectionStatus.Connected;
});
builder.addCase(getUserPoints.fulfilled, (state, { payload }) => {
state.loyalty.users = payload;
});
Object.values(modules).forEach((mod) => {
builder.addCase(mod.getter.pending, (state) => {
state.requestStatus[`load-${mod.key}`] = {
type: 'pending',
updated: new Date(),
};
});
builder.addCase(mod.getter.fulfilled, (state, action) => {
state.requestStatus[`load-${mod.key}`] = {
type: 'success',
updated: new Date(),
};
mod.stateSetter(state, action);
});
builder.addCase(mod.getter.rejected, (state, { error }) => {
state.requestStatus[`load-${mod.key}`] = {
type: 'error',
error: error.message,
updated: new Date(),
};
});
builder.addCase(mod.setter.pending, (state) => {
state.requestStatus[`save-${mod.key}`] = {
type: 'pending',
updated: new Date(),
};
});
builder.addCase(mod.setter.fulfilled, (state) => {
state.requestStatus[`save-${mod.key}`] = {
type: 'success',
updated: new Date(),
};
});
builder.addCase(mod.setter.rejected, (state, { error }) => {
state.requestStatus[`save-${mod.key}`] = {
type: 'error',
error: error.message,
updated: new Date(),
};
});
builder.addCase(mod.asyncSetter, mod.stateSetter);
});
},
});
setupClientReconnect = createAsyncThunk(
'api/setupClientReconnect',
(client: KilovoltWS, { dispatch }) => {
client.on('close', () => {
setTimeout(() => {
console.info('Attempting reconnection');
client.reconnect();
}, 5000);
dispatch(
apiReducer.actions.connectionStatusChanged(
ConnectionStatus.NotConnected,
),
);
});
client.on('open', () => {
dispatch(
apiReducer.actions.connectionStatusChanged(ConnectionStatus.Connected),
);
});
},
);
kvErrorReceived = createAsyncThunk(
'api/kvErrorReceived',
(error: kvError, { dispatch }) => {
switch (error.error) {
case 'authentication required':
case 'authentication failed':
dispatch(
apiReducer.actions.connectionStatusChanged(
ConnectionStatus.AuthenticationNeeded,
),
);
break;
default:
// Unsupported error
dispatch(apiReducer.actions.kvErrorReceived(error));
}
},
);
export const useAuthBypass = createAsyncThunk<void, void, ThunkConfig>(
'api/authBypass',
async (_: void, { getState, dispatch }) => {
const { api } = getState();
const response = await api.client.send({ command: '_uid' });
if ('ok' in response && response.ok && 'data' in response) {
const uid = response.data as string;
await AuthenticateKVClient(uid.toString());
dispatch(
apiReducer.actions.connectionStatusChanged(ConnectionStatus.Connected),
);
}
},
);
export default apiReducer;