cetirizine

An experimental matrix client written in reactjs utilizing tailwind and storybook
git clone git://archive.git.mtrnord.blog/MTRNord/cetirizine.git
Log | Files | Refs | README | LICENSE

commit be3705f5fba2cd36cc5b27bab14ecb94c10ce789
parent 53417f00f4d524592ef1f91199854adf0105bbc7
Author: MTRNord <mtrnord1@gmail.com>
Date:   Sat, 22 Apr 2023 20:19:30 +0200

feat: Properly hookup login together with restoring the client after reloads if possible

Diffstat:
Msrc/app/api/api.ts | 48+++++++++++++++++++++++++++++++++++++-----------
Msrc/app/api/reducers.ts | 2+-
Msrc/app/store.ts | 21+++++++++++++++++++++
Msrc/components/button/button.tsx | 13+++++++++----
Msrc/components/input/basic/input.stories.tsx | 2--
Msrc/components/input/basic/input.tsx | 9+++++++--
Msrc/components/login/login.tsx | 16++++++++++++----
7 files changed, 87 insertions(+), 24 deletions(-)

diff --git a/src/app/api/api.ts b/src/app/api/api.ts @@ -1,23 +1,38 @@ import { all, call, fork, put, takeEvery } from "redux-saga/effects"; import { LOGIN_REQUEST_ACTION, LOGIN_SUCCESS_ACTION, LOGIN, LOGIN_ACTION, LOGIN_FAILURE_ACTION } from "./reducers"; import { IndexedDBCryptoStore, IndexedDBStore, MatrixClient, MemoryStore, createClient, setCryptoStoreFactory } from "matrix-js-sdk"; +import { AutoDiscovery } from 'matrix-js-sdk/lib/autodiscovery'; -function login(baseUrl: string, userId: string, password: string): Promise<MatrixClient> { - return initMatrixClient(baseUrl, userId, undefined, password); +async function login(baseUrl: string, userId: string, password: string): Promise<MatrixClient> { + const client = await initMatrixClient(baseUrl, userId, undefined, password); + + window.localStorage.setItem("accessToken", client.getAccessToken()!); + window.localStorage.setItem("baseUrl", client.baseUrl); + window.localStorage.setItem("userId", client.getUserId()!); + + return client; } function* onLoginSaga(action: LOGIN): any { + yield put(LOGIN_REQUEST_ACTION()); + if (!action.baseUrl.startsWith("https://")) { + yield put(LOGIN_FAILURE_ACTION("Homeserver url must start with https://")); + return; + } + if (!action.username) { + yield put(LOGIN_FAILURE_ACTION("Username must be a non empty string")); + return; + } + if (!action.password) { + yield put(LOGIN_FAILURE_ACTION("Password must be a non empty string")); + return; + } try { - yield put(LOGIN_REQUEST_ACTION()); - if (!action.baseUrl.startsWith("https://")) { - yield put(LOGIN_FAILURE_ACTION("Homeserver url must start with https://")); - return; - } - - const client = yield call(login, action.baseUrl, action.username, action.password); + const client: MatrixClient = yield call(login, action.baseUrl, action.username, action.password); yield put(LOGIN_SUCCESS_ACTION(client)); } catch (e) { yield put(LOGIN_FAILURE_ACTION((e as any).toString())); + return; } } @@ -29,7 +44,7 @@ export function* apiSagas() { yield all([fork(watchLoginSaga)]); } -async function initMatrixClient(baseURL: string, userId: string, accessToken?: string, password?: string): Promise<MatrixClient> { +export async function initMatrixClient(baseUrl: string, userId: string, accessToken?: string, password?: string): Promise<MatrixClient> { // just *accessing* indexedDB throws an exception in firefox with indexeddb disabled. let indexedDB: IDBFactory | undefined; try { @@ -44,8 +59,19 @@ async function initMatrixClient(baseURL: string, userId: string, accessToken?: s await store.startup(); } + const clientConfig = await AutoDiscovery.findClientConfig(baseUrl.replace("https://", '')); + + if (clientConfig["m.homeserver"].state === AutoDiscovery.FAIL_PROMPT) { + throw Error(clientConfig["m.homeserver"].error?.toString()) + } + if (clientConfig["m.homeserver"].state !== AutoDiscovery.FAIL_ERROR) { + if (clientConfig["m.homeserver"].base_url) { + baseUrl = clientConfig["m.homeserver"].base_url; + } + } + const matrixClient = createClient({ - baseUrl: baseURL, + baseUrl: baseUrl, accessToken: accessToken, userId: accessToken ? userId : undefined, useAuthorizationHeader: true, diff --git a/src/app/api/reducers.ts b/src/app/api/reducers.ts @@ -16,7 +16,7 @@ export const LOGIN_FAILURE_ACTION = createAction<string>('api/LOGIN_FAILURE'); export interface LOGIN extends Action { type: typeof LOGIN_ACTION, baseUrl: string, username: string, password: string }; -const initialLoginState: ApiLoginStatus = { loginPending: false, error: undefined, client: undefined }; +const initialLoginState: ApiLoginStatus = { loginPending: false }; export const apiLoginReducer = createReducer(initialLoginState, (builder) => { diff --git a/src/app/store.ts b/src/app/store.ts @@ -2,6 +2,7 @@ import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"; import createSagaMiddleware from "redux-saga"; import rootSaga from "./sagas/rootSaga"; import { apiLoginReducer } from "./api/reducers"; +import { initMatrixClient } from "./api/api"; const sagaMiddleware = createSagaMiddleware(); const middlewares = [sagaMiddleware]; @@ -14,6 +15,7 @@ export const store = configureStore({ ...getDefaultMiddleware(), ...middlewares, ], + preloadedState: await getInitialState() }); sagaMiddleware.run(rootSaga); @@ -25,3 +27,21 @@ export type AppThunk<ReturnType = void> = ThunkAction< unknown, Action<string> >; + +async function getInitialState() { + const localStorage = window.localStorage; + const accessToken = localStorage.getItem("accessToken"); + const baseUrl = localStorage.getItem("baseUrl"); + const userId = localStorage.getItem("userId"); + if (accessToken && baseUrl && userId) { + const client = await initMatrixClient(baseUrl, userId, accessToken); + return { + login: { + client, + loginPending: false + } + } + } else { + return undefined + } +} +\ No newline at end of file diff --git a/src/components/button/button.tsx b/src/components/button/button.tsx @@ -18,14 +18,19 @@ type ButtonProps = { * The button Label */ children: string + + /** + * If the button is readonly + */ + readonly: boolean }; -export default memo(function Button({ type = "button", style = "primary", onClick, children }: ButtonProps) { +export default memo(function Button({ type = "button", style = "primary", onClick, children, readonly }: ButtonProps) { if (style === "secondary") { - return <button onClick={onClick} className="button bg-orange-400 hover:bg-orange-500 ease-out duration-150" type={type}>{children}</button>; + return <button disabled={readonly} onClick={onClick} className="button bg-orange-400 hover:bg-orange-500 ease-out duration-150 disabled:bg-slate-200 disabled:cursor-not-allowed" type={type}>{children}</button>; } else if (style === "abort") { - return <button onClick={onClick} className="button bg-red-400 hover:bg-red-500 ease-out duration-150" type={type}>{children}</button>; + return <button disabled={readonly} onClick={onClick} className="button bg-red-400 hover:bg-red-500 ease-out duration-150 disabled:bg-slate-200 disabled:cursor-not-allowed" type={type}>{children}</button>; } else { - return <button onClick={onClick} className="button bg-green-400 hover:bg-green-500 ease-out duration-150" type={type}>{children}</button>; + return <button disabled={readonly} onClick={onClick} className="button bg-green-400 hover:bg-green-500 ease-out duration-150 disabled:bg-slate-200 disabled:cursor-not-allowed" type={type}>{children}</button>; } }); \ No newline at end of file diff --git a/src/components/input/basic/input.stories.tsx b/src/components/input/basic/input.stories.tsx @@ -49,7 +49,5 @@ export const Default: Story = { placeholder: "Placeholder", password: false, autoFocus: false, - value: "", - onChange: (_) => { } } }; \ No newline at end of file diff --git a/src/components/input/basic/input.tsx b/src/components/input/basic/input.tsx @@ -18,15 +18,20 @@ type InputProps = { */ value: string /** + * If the input is readonly + */ + readonly: boolean + /** * Handler for the onChange event */ onChange: (e: ChangeEvent<HTMLInputElement>) => void; }; -export default memo(function Input({ placeholder, password = false, autoFocus = false, value, onChange }: InputProps) { +export default memo(function Input({ placeholder, password = false, autoFocus = false, value, readonly, onChange }: InputProps) { return ( <input - className='form-input rounded-lg' + disabled={readonly} + className='form-input rounded-lg disabled:bg-slate-200 disabled:cursor-not-allowed transition-colors ease-in-out delay-150' value={value} type={password ? "password" : "text"} autoFocus={autoFocus} diff --git a/src/components/login/login.tsx b/src/components/login/login.tsx @@ -9,39 +9,47 @@ import { LOGIN_ACTION } from '../../app/api/reducers'; export function Login() { const loginError = useAppSelector((state: RootState) => getLoginError(state)); + const loginPending = useAppSelector((state: RootState) => state.login.loginPending); const [homeserver, setHomeserver] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const dispatch = useAppDispatch(); return ( - <div className="flex flex-col rounded-md shadow p-4 bg-white gap-2"> + <form className="flex flex-col rounded-md shadow p-4 bg-white gap-2 min-w-[30rem]" onSubmit={(e) => { + e.preventDefault(); + dispatch({ type: LOGIN_ACTION, baseUrl: homeserver, username: username, password: password }); + }}> <Header>Login</Header> - {loginError ? <h2 className='text-red-500 font-normal text-sm'>{loginError}</h2> : <></>} + {loginError ? <h2 className='text-red-500 font-normal text-sm'>{loginError}</h2> : <div className='min-h-[1.25rem]'></div>} <Input + readonly={loginPending} value={homeserver} autoFocus={true} placeholder="Homeserver" onChange={e => setHomeserver(e.target.value)} /> <Input + readonly={loginPending} value={username} placeholder="Username" onChange={e => setUsername(e.target.value)} /> <Input + readonly={loginPending} value={password} password={true} placeholder="Password" onChange={e => setPassword(e.target.value)} /> <Button + readonly={loginPending} style="primary" - type="button" + type="submit" onClick={() => dispatch({ type: LOGIN_ACTION, baseUrl: homeserver, username: username, password: password })} > Login </Button> - </div> + </form> ); } \ No newline at end of file