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 0387a54665645c79c60ca7ea9902ab08c0645a41
parent 073708091d8f38420412a7bfe24b30a0d31032a7
Author: MTRNord <mtrnord1@gmail.com>
Date:   Mon,  1 May 2023 21:32:47 +0200

Initial impl of custom sdk without redux and with sliding sync

Diffstat:
Mpackage-lock.json | 168++++---------------------------------------------------------------------------
Mpackage.json | 6++----
Dsrc/app/api/api.ts | 151------------------------------------------------------------------------------
Dsrc/app/api/apiTypes.ts | 50--------------------------------------------------
Dsrc/app/hooks.ts | 7-------
Msrc/app/protectedRoute.tsx | 10++++------
Asrc/app/sdk/api/apiTypes.ts | 236+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/sdk/client.ts | 371+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/sdk/room.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/app/store.ts | 47-----------------------------------------------
Msrc/components/input/basic/input.tsx | 2+-
Msrc/components/login/login.tsx | 57+++++++++++++--------------------------------------------
Msrc/components/roomList/roomList.tsx | 1+
Msrc/components/roomList/roomListItem/roomListItem.tsx | 17+++++++++++++++--
Msrc/main.tsx | 17++++++++---------
Msrc/pages/MainPage.tsx | 8+++-----
16 files changed, 719 insertions(+), 486 deletions(-)

diff --git a/package-lock.json b/package-lock.json @@ -9,8 +9,6 @@ "version": "0.1.0", "dependencies": { "@lexical/react": "^0.10.0", - "@piotr-cz/redux-persist-idb-storage": "^1.1.3", - "@reduxjs/toolkit": "^1.9.5", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.4.3", @@ -19,15 +17,15 @@ "@types/react": "^18.0.37", "@types/react-dom": "^18.0.11", "dompurify": "^3.0.2", + "idb": "^7.1.1", + "immer": "^10.0.1", "lexical": "^0.10.0", "lucide-react": "^0.171.0", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", "react": "^18.2.0", "react-dom": "^18.2.0", "react-intersection-observer": "^9.4.3", - "react-redux": "^8.0.5", "react-router-dom": "^6.10.0", - "reduxjs-toolkit-persist": "^7.2.1", "typescript": "^5.0.2", "web-vitals": "^2.1.4" }, @@ -3084,40 +3082,6 @@ "node": ">= 8" } }, - "node_modules/@piotr-cz/redux-persist-idb-storage": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@piotr-cz/redux-persist-idb-storage/-/redux-persist-idb-storage-1.1.3.tgz", - "integrity": "sha512-aAx40LFsPmg93SHKHUUHtSM50iEFp4uZJrvrkG0oT4yOLyjUAPEqLIcPrsYDuWJpyJrYt8T7SNieinQxp+boEQ==", - "dependencies": { - "idb": "^3.0.0" - }, - "peerDependencies": { - "redux-persist": ">=3.5.0" - } - }, - "node_modules/@reduxjs/toolkit": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.5.tgz", - "integrity": "sha512-Rt97jHmfTeaxL4swLRNPD/zV4OxTes4la07Xc4hetpUW/vc75t5m1ANyxG6ymnEQ2FsLQsoMlYB2vV1sO3m8tQ==", - "dependencies": { - "immer": "^9.0.21", - "redux": "^4.2.1", - "redux-thunk": "^2.4.2", - "reselect": "^4.1.8" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18", - "react-redux": "^7.2.1 || ^8.0.2" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, "node_modules/@remix-run/router": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.5.0.tgz", @@ -5515,15 +5479,6 @@ "@types/node": "*" } }, - "node_modules/@types/hoist-non-react-statics": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", - "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", - "dependencies": { - "@types/react": "*", - "hoist-non-react-statics": "^3.3.0" - } - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", @@ -5697,11 +5652,6 @@ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==", "dev": true }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", - "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" - }, "node_modules/@types/yargs": { "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", @@ -8893,19 +8843,6 @@ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "dev": true }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -8993,9 +8930,9 @@ } }, "node_modules/idb": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/idb/-/idb-3.0.2.tgz", - "integrity": "sha512-+FLa/0sTXqyux0o6C+i2lOR0VoS60LU/jzUo5xjfY6+7sEEgy4Gz1O7yFBXvjd7N0NyIGWIRg8DcQSLEG+VSPw==" + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" }, "node_modules/ieee754": { "version": "1.2.1", @@ -9041,9 +8978,9 @@ } }, "node_modules/immer": { - "version": "9.0.21", - "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", - "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.1.tgz", + "integrity": "sha512-zg++jJLsKKTwXGeSYIw0HgChSYQGtu0UDTnbKx5aGLYgte4CwTmH9eJDYyQ6FheyUtBe+lQW9FrGxya1G+Dtmg==", "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -12262,49 +12199,6 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, - "node_modules/react-redux": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.5.tgz", - "integrity": "sha512-Q2f6fCKxPFpkXt1qNRZdEDLlScsDWyrgSj0mliK59qU6W5gvBiKkdMEG2lJzhd1rCctf0hb6EtePPLZ2e0m1uw==", - "dependencies": { - "@babel/runtime": "^7.12.1", - "@types/hoist-non-react-statics": "^3.3.1", - "@types/use-sync-external-store": "^0.0.3", - "hoist-non-react-statics": "^3.3.2", - "react-is": "^18.0.0", - "use-sync-external-store": "^1.0.0" - }, - "peerDependencies": { - "@types/react": "^16.8 || ^17.0 || ^18.0", - "@types/react-dom": "^16.8 || ^17.0 || ^18.0", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0", - "react-native": ">=0.59", - "redux": "^4" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - }, - "redux": { - "optional": true - } - } - }, - "node_modules/react-redux/node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" - }, "node_modules/react-refresh": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", @@ -12555,39 +12449,6 @@ "node": ">=8" } }, - "node_modules/redux": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", - "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", - "dependencies": { - "@babel/runtime": "^7.9.2" - } - }, - "node_modules/redux-persist": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", - "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", - "peer": true, - "peerDependencies": { - "redux": ">4.0.0" - } - }, - "node_modules/redux-thunk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", - "integrity": "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==", - "peerDependencies": { - "redux": "^4" - } - }, - "node_modules/reduxjs-toolkit-persist": { - "version": "7.2.1", - "resolved": "https://registry.npmjs.org/reduxjs-toolkit-persist/-/reduxjs-toolkit-persist-7.2.1.tgz", - "integrity": "sha512-1gu9Qid0qXlsM4a2D8eHx/YOwa7I0lgAXqM0KYQoX2RA+KMezkq5wkJKEMLoUkpqVuWYHRdU7auS2/jFVsR0gQ==", - "peerDependencies": { - "@reduxjs/toolkit": "^1.6.1" - } - }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -12721,11 +12582,6 @@ "node": ">=0.10.0" } }, - "node_modules/reselect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", - "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" - }, "node_modules/resolve": { "version": "1.22.2", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.2.tgz", @@ -14474,14 +14330,6 @@ "react-dom": "16.8.0 - 18" } }, - "node_modules/use-sync-external-store": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", - "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", diff --git a/package.json b/package.json @@ -5,8 +5,6 @@ "type": "module", "dependencies": { "@lexical/react": "^0.10.0", - "@piotr-cz/redux-persist-idb-storage": "^1.1.3", - "@reduxjs/toolkit": "^1.9.5", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^14.4.3", @@ -15,15 +13,15 @@ "@types/react": "^18.0.37", "@types/react-dom": "^18.0.11", "dompurify": "^3.0.2", + "idb": "^7.1.1", + "immer": "^10.0.1", "lexical": "^0.10.0", "lucide-react": "^0.171.0", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", "react": "^18.2.0", "react-dom": "^18.2.0", "react-intersection-observer": "^9.4.3", - "react-redux": "^8.0.5", "react-router-dom": "^6.10.0", - "reduxjs-toolkit-persist": "^7.2.1", "typescript": "^5.0.2", "web-vitals": "^2.1.4" }, diff --git a/src/app/api/api.ts b/src/app/api/api.ts @@ -1,151 +0,0 @@ -import { BaseQueryFn, FetchArgs, FetchBaseQueryError, createApi, fetchBaseQuery, retry, } from '@reduxjs/toolkit/query/react' -import { RootState } from '../store'; - -import { ILoginFlows, ILoginParams, ILoginResponse, IRateLimitError, IWellKnown } from './apiTypes'; -import { createAction, createReducer } from '@reduxjs/toolkit'; -// async function login(baseUrl: string, userId: string, password: string): Promise<MatrixClient> { -// //const client = await initMatrixClient(baseUrl, userId, undefined, undefined, password); - -// window.localStorage.setItem("accessToken", client.getAccessToken()!); -// window.localStorage.setItem("baseUrl", client.baseUrl); -// window.localStorage.setItem("userId", client.getUserId()!); -// window.localStorage.setItem("deviceId", client.getDeviceId()!); - -// return client; -// } - -export const setHost = createAction<string, 'auth/host'>('auth/host'); -export const setAccessToken = createAction<string, 'auth/access_token'>('auth/access_token'); -export const setLoggedIn = createAction<boolean, 'auth/logged_in'>('auth/logged_in'); -interface IAuthState { - host?: string; - accessToken?: string; - logged_in: boolean; -} -const initialAuthState: IAuthState = { - logged_in: false, -} -export const auth = createReducer(initialAuthState, (builder) => builder - .addCase(setHost, (state, action) => { state.host = action.payload }) - .addCase(setAccessToken, (state, action) => { state.accessToken = action.payload }) - .addCase(setLoggedIn, (state, action) => { state.logged_in = action.payload }) -); - -const baseQuery: BaseQueryFn<string | FetchArgs, - unknown, - FetchBaseQueryError> = async (args, api, extraOptions) => { - const baseUrl = (api.getState() as RootState).auth.host; - // gracefully handle scenarios where data to generate the URL is missing - if (!baseUrl) { - return { - error: { - status: 400, - statusText: 'Bad Request', - data: 'Invalid Matrix Host', - }, - }; - } - - return rawBaseQuery(baseUrl)(args, api, extraOptions); - }; - -const baseQueryWithRetry = retry( - async (args: string | FetchArgs, api, extraOptions) => { - const result = await baseQuery(args, api, extraOptions); - if (result.error?.status === 429) { - const retryAfterMs = (result.error.data as IRateLimitError).retry_after_ms; - if (retryAfterMs) { - await new Promise((resolve) => setTimeout(resolve, retryAfterMs)); - } - } - return result; - }, { maxRetries: 3 }); - -export const matrixApi = createApi({ - reducerPath: 'matrixApi', - baseQuery: baseQueryWithRetry, - refetchOnReconnect: true, - tagTypes: ['Login', 'Flows', "WellKnown"], - endpoints: (builder) => ({ - getWellKnown: builder.query<IWellKnown, undefined>({ - query: () => `/.well-known/matrix/client`, - providesTags: ['WellKnown'], - async onQueryStarted(_arg, { dispatch, queryFulfilled }) { - try { - const { data, meta } = await queryFulfilled; - const response = (meta as { request: Request, response: Response }).response; - if (response.status === 200) { - if (data?.["m.homeserver"]?.base_url) { - dispatch(setHost(data["m.homeserver"].base_url)) - } - } - } catch (err) { - // `onError` side-effect - } - }, - }), - getLoginFlows: builder.query<ILoginFlows, undefined>({ - query: () => `/_matrix/client/v3/login`, - providesTags: ['Flows'], - }), - doLogin: builder.mutation<ILoginResponse, ILoginParams>({ - query: (data) => ({ - url: `/_matrix/client/r0/login`, - method: 'POST', - body: data, - }), - async onQueryStarted(_arg, { dispatch, queryFulfilled }) { - try { - const { data, meta } = await queryFulfilled; - const response = (meta as { request: Request, response: Response }).response; - - if (response.status === 200) { - dispatch(setLoggedIn(true)) - dispatch(setAccessToken(data.access_token)) - if (data.well_known?.["m.homeserver"]?.base_url) { - dispatch(setHost(data.well_known["m.homeserver"].base_url)) - } - } else { - dispatch(setLoggedIn(false)) - } - } catch (err) { - // `onError` side-effect - dispatch(setLoggedIn(false)) - } - }, - invalidatesTags: ['Login'], - }), - sync: builder.query<any[], void>({ - keepUnusedDataFor: 2147483647, - // The query is not relevant here as the data will be provided via streaming updates. - // A queryFn returning an empty array is used, with contents being populated via - // streaming updates below as they are received. - queryFn: () => ({ data: [] }), - async onCacheEntryAdded(_arg, { updateCachedData, cacheEntryRemoved, getState }) { - // TODO: /sync loop - /* - updateCachedData((draft) => { - draft.push(JSON.parse(event.data)) - }) - */ - await cacheEntryRemoved - // Cleanup? - }, - }), - }), -}); - -const rawBaseQuery = (baseUrl: string) => fetchBaseQuery({ - baseUrl, - prepareHeaders: (headers, { getState }) => { - const token = (getState() as RootState).auth.accessToken; - !!token && headers.set('Authorization', `Bearer ${token}`); - - return headers; - }, -}); - - -// Export hooks for usage in functional components, which are -// auto-generated based on the defined endpoints -export const { useLazyGetLoginFlowsQuery, useDoLoginMutation, useLazyGetWellKnownQuery, useSyncQuery } = matrixApi diff --git a/src/app/api/apiTypes.ts b/src/app/api/apiTypes.ts @@ -1,49 +0,0 @@ -export interface ILoginFlow { - type: string; -} -export interface ILoginFlows { - flows: ILoginFlow[]; -} -export interface IRateLimitError { - errcode: string; - error?: string; - retry_after_ms: number; -} - -export interface ILoginResponse { - access_token: string; - device_id: string; - home_server?: string; - user_id: string; - expires_in_ms?: number; - refresh_token?: string; - well_known?: IWellKnown -} - -export interface ILoginParams { - address?: string; - device_id?: string; - identifier?: { - type: "m.id.user" | "m.id.thirdparty" | "m.id.phone"; - user?: string; - medium?: string; - address?: string; - country?: string; - phone?: string; - }; - initial_device_display_name?: string; - password?: string; - user?: string; - refresh_token?: string; - token?: string; - type: "m.login.password" | "m.login.token"; -} - -export interface IWellKnown { - "m.homeserver": { - base_url: string; - } - "m.identity_server"?: { - base_url: string; - } -} -\ No newline at end of file diff --git a/src/app/hooks.ts b/src/app/hooks.ts @@ -1,6 +0,0 @@ -import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; -import type { RootState, AppDispatch } from './store'; - -// Use throughout your app instead of plain `useDispatch` and `useSelector` -export const useAppDispatch = () => useDispatch<AppDispatch>(); -export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector; -\ No newline at end of file diff --git a/src/app/protectedRoute.tsx b/src/app/protectedRoute.tsx @@ -1,12 +1,10 @@ -import { ReactElement } from "react"; +import { ReactElement, useContext } from "react"; import { Navigate } from "react-router-dom"; -import { useAppSelector } from "./hooks"; +import { MatrixContext } from "./sdk/client"; export const ProtectedRoute = ({ children }: { children: ReactElement<any, any> }) => { - const client = useAppSelector(state => state.auth.host); - - // TODO: relaunch a client if we can and skip login - if (!client) { + const matrixClient = useContext(MatrixContext); + if (!matrixClient.isLoggedIn) { // user is not authenticated return <Navigate to="login" />; } diff --git a/src/app/sdk/api/apiTypes.ts b/src/app/sdk/api/apiTypes.ts @@ -0,0 +1,235 @@ +export interface ILoginFlow { + type: string; +} +export interface ILoginFlows { + flows: ILoginFlow[]; +} + +export interface IErrorResp { + errcode: string; + error?: string; +} + +export interface IRateLimitError extends IErrorResp { + retry_after_ms: number; +} + +export interface ILoginResponse { + access_token: string; + device_id: string; + home_server?: string; + user_id: string; + expires_in_ms?: number; + refresh_token?: string; + well_known?: IWellKnown +} + +export interface ILoginParams { + address?: string; + device_id?: string; + identifier?: { + type: "m.id.user" | "m.id.thirdparty" | "m.id.phone"; + user?: string; + medium?: string; + address?: string; + country?: string; + phone?: string; + }; + initial_device_display_name?: string; + password?: string; + user?: string; + refresh_token?: string; + token?: string; + type: "m.login.password" | "m.login.token"; +} + +export interface IWellKnown { + "m.homeserver": { + base_url: string; + } + "m.identity_server"?: { + base_url: string; + } + "org.matrix.msc3575.proxy"?: { + url: string; + } +} + +export interface IAccountData { + events?: any[]; +} + +export interface IClientEventWithoutRoomId { + content: any; + event_id: string; + origin_server_ts: number; + sender: string; + // FIXME: Is this really meant to be optional? + state_key?: string; + type: string; + unsigned?: { + age?: number; + prev_content?: any; + redacted_because?: IClientEventWithoutRoomId; + transaction_id?: string; + } +} + +export interface ITimeline { + events: IClientEventWithoutRoomId[]; + limited: boolean; + prev_batch: string; +} + +// Old sync +export interface ISyncResponse { + account_data?: IAccountData; + device_lists?: { + changed?: string[]; + left?: string[]; + }; + device_one_time_keys_count?: { + [key: string]: number; + }; + next_batch: string; + presence?: { + events?: any[]; + }; + rooms?: { + invite?: { + [key: string]: { + invite_state?: { + events?: { + content: any; + sender: string; + state_key: string; + type: string; + // Future proofing + [key: string]: any; + }[]; + }; + }; + }; + join?: { + [key: string]: { + account_data?: IAccountData; + ephemeral?: { + events?: any[]; + }; + state?: { + events: IClientEventWithoutRoomId[]; + } + summary?: { + "m.heroes": string[]; + "m.joined_member_count": number; + "m.invited_member_count": number; + }; + timeline?: ITimeline; + unread_notifications?: { + highlight_count: number; + notification_count: number; + }; + unread_thread_notifications?: { + [key: string]: { + highlight_count: number; + notification_count: number; + }; + }; + }; + }; + leave?: { + [key: string]: { + state?: { + events: IClientEventWithoutRoomId[]; + }; + account_data?: IAccountData; + timeline?: ITimeline; + }; + }; + knock?: { + [key: string]: { + knock_state?: { + events?: { + content: any; + sender: string; + state_key: string; + type: string; + }[]; + }; + }; + }; + }; + to_device?: { + events?: any[]; + }; +} + +// Sliding sync +export interface ISlidingSyncResp { + lists?: { + [key: string]: List; + }; + rooms?: { + [key: string]: RoomJson; + }; + extensions?: Extensions; + pos: string; + txn_id: string; +} + +export interface Extensions { } + +export interface List { + ops?: (SYNC_OP | INSERT_OP | INVALIDATE_OP | DELETE_OP)[]; + count: number; +} + +export function isSyncOp(op: any): op is SYNC_OP { + return op.op === "SYNC"; +} + +export function isInsertOp(op: any): op is INSERT_OP { + return op.op === "INSERT"; +} + +export function isInvalidateOp(op: any): op is INVALIDATE_OP { + return op.op === "INVALIDATE"; +} + +export function isDeleteOp(op: any): op is DELETE_OP { + return op.op === "DELETE"; +} + +export interface DELETE_OP { + op: string; + index: number; +} + +export interface INVALIDATE_OP { + op: string; + range: number[]; +} + +export interface INSERT_OP { + op: string; + index: number; + room_id: string; +} + +export interface SYNC_OP { + op: string; + range: number[]; + room_ids: string[]; +} + +export interface RoomJson { + name: string, + // List of events + timeline: any[], + notification_count: number, + highlight_count: number, + initial: boolean, + joined_count: number, + invited_count: number, + prev_batch: string, +} +\ No newline at end of file diff --git a/src/app/sdk/client.ts b/src/app/sdk/client.ts @@ -0,0 +1,370 @@ +import { createContext, useEffect, useState } from "react"; +import { IErrorResp, ILoginFlows, ILoginResponse, IRateLimitError, ISlidingSyncResp, IWellKnown, isDeleteOp, isInsertOp, isInvalidateOp, isSyncOp } from "./api/apiTypes"; +import { Room } from "./room"; +import EventEmitter from "events"; + +export interface MatrixClientEvents { + // Used to notify about changes to the room list + 'rooms': (rooms: Room[]) => void; + //'delete': (changedCount: number) => void; +} +export declare interface MatrixClient { + on<U extends keyof MatrixClientEvents>( + event: U, listener: MatrixClientEvents[U] + ): this; + + emit<U extends keyof MatrixClientEvents>( + event: U, ...args: Parameters<MatrixClientEvents[U]> + ): boolean; +} + + +export class MatrixClient extends EventEmitter { + private static _instance: MatrixClient; + // TODO: This needs to be stored + private access_token?: string; + // TODO: This needs to be stored + private device_id?: string; + // TODO: This needs to be stored + private mxid?: string; + // TODO: This needs to be stored + // Hostname including "https://" + private hostname?: string; + // TODO: This needs to be stored + private slidingSyncHostname?: string; + private syncing = false; + private roomsInView: string[] = []; + // TODO: This needs to be stored + private roomToRoom: Map<number, Room> = new Map(); + // TODO: This needs to be stored + private syncPos?: string; + // TODO: This needs to be stored + private initialSync = true; + + public get isLoggedIn(): boolean { + return this.access_token !== undefined; + } + + public static get Instance() { + // Do you need arguments? Make it a regular static method instead. + return this._instance || (this._instance = new this()); + } + + private set setHostname(hostname: string) { + if (!hostname.startsWith("https://")) { + throw Error("Hostname must start with 'https://'"); + } + this.hostname = hostname; + } + + public async startSync() { + if (!this.isLoggedIn) { + throw Error("Not logged in"); + } + if (this.syncing) { + return; + } + this.syncing = true; + while (this.syncing) { + try { + await this.sync(); + } catch (e) { + console.error(e); + return; + } + } + } + + public stopSync() { + this.syncing = false; + } + + private async sync() { + if (!this.isLoggedIn) { + throw Error("Not logged in"); + } + if (!this.slidingSyncHostname) { + throw Error("Hostname must be set first"); + } + + // This is the initial sync case for each list + let lists_ranges = { + "overview": [[0, 10]] + }; + let timeline_limit = 1; + if (!this.initialSync) { + // Set higher timeline limit for subsequent syncs + timeline_limit = 10; + // Calculate overlap between this.roomsInView and this.roomToRoomID and then + // calculate the ranges for each list + const rawRangeInView = [...this.roomToRoom.entries()] + .filter(([_, room]) => this.roomsInView.includes(room.roomID)) + .map(([rangeID]) => rangeID) + .sort(); + + // Increment range by 1 to make sure we always get a little more than we need + // [1,2,3,4,7,8,9,10,11] -> [2,3,4,5,8,9,10,11,12] + rawRangeInView.push(rawRangeInView[rawRangeInView.length - 1] + 1); + + // Turn an input like [1,2,3,4,7,8,9,10,11] to [[1,4], [7,11]] + const rangesInView = rawRangeInView.reduce((acc, cur, i, arr) => { + if (i === 0) { + // [1,2,3,4] -> [[1,1]] + acc.push([cur, cur]); + return acc; + } + // Cur = 2, arr = [1,2,3,4], arr[i - 1] + 1 = 2 then + if (cur === arr[i - 1] + 1) { + // [1,2,3,4,7] -> [[1,2]] + acc[acc.length - 1][1] = cur; + return acc; + } + // Else [1,2,3,4,7] -> [[1,2], [7,7]] + acc.push([cur, cur]); + return acc; + }, [] as [number, number][]); + + lists_ranges = { + "overview": rangesInView + }; + } + + + let url = `${this.slidingSyncHostname}/_matrix/client/unstable/org.matrix.msc3575/sync?timeout=30000`; + if (this.syncPos) { + url = `${this.slidingSyncHostname}/_matrix/client/unstable/org.matrix.msc3575/sync?timeout=30000&pos=${this.syncPos}` + } + + + console.log(url) + const resp = await fetch(url, { + method: "POST", + headers: { + "Authorization": `Bearer ${this.access_token}` + }, + body: JSON.stringify({ + // allows clients to know what request params reached the server, + // functionally similar to txn IDs on /send for events. + // TODO: check resp + txn_id: Date.now().toString(), + + // a delta token to remember information between sessions. + // See "Bandwidth optimisations for persistent clients" for more information. + // TODO: This isnt implemented anywhere yet + //delta_token: "opaque-server-provided-string", + + // Sliding Window API + lists: { + "overview": { + ranges: lists_ranges["overview"], + sort: ["by_notification_level", "by_recency", "by_name"], + required_state: [ + // needed to build sections + ["m.space.child", "*"], + ["m.space.parent", "*"], + // Room Avatar + ["m.room.avatar", "*"], + ], + timeline_limit: timeline_limit, + filters: {} + }, + }, + bump_event_types: ["m.room.message", "m.room.encrypted"], + + // Room Subscriptions API + room_subscriptions: {}, + unsubscribe_rooms: [] + }) + }); + if (!resp.ok) { + console.error(resp); + throw Error("Error syncing. See console for error."); + } + const json = await resp.json() as ISlidingSyncResp; + this.syncPos = json.pos; + for (const listKey in json.lists) { + const list = json.lists[listKey]; + if (list.ops) { + for (const op of list.ops) { + if (isSyncOp(op)) { + for (let i = op.range[0]; i <= op.range[1]; i++) { + // We shall first forget about these and "startover" + this.roomToRoom.delete(i); + + // We start to remember the Room now. + this.roomToRoom.set(i, new Room(op.room_ids[i])); + } + } else if (isInsertOp(op)) { + console.log("Got INSERT OP", op); + } else if (isDeleteOp(op)) { + this.roomToRoom.delete(op.index); + } else if (isInvalidateOp(op)) { + for (let i = op.range[0]; i <= op.range[1]; i++) { + // We shall first forget about these and "startover" + this.roomToRoom.delete(i); + } + } + } + } + } + for (const roomID in json.rooms) { + const room = json.rooms[roomID]; + const name = room.name; + const notification_count = room.notification_count; + const notification_highlight_count = room.highlight_count; + const joined_count = room.joined_count; + const invited_count = room.invited_count; + + // Add the room data to the Room from this.roomToRoomID + // Note that the map key does not mean the roomID but the sync position + // RoomID is part of the value object of the map + // value.roomID is the roomID + const roomObj = [...this.roomToRoom.values()].find(room => room.roomID === roomID); + + if (!roomObj) { + console.error("Could not find roomObj for roomID", roomID); + continue; + } + + roomObj.setName(name); + roomObj.setNotificationCount(notification_count); + roomObj.setNotificationHighlightCount(notification_highlight_count); + roomObj.setJoinedCount(joined_count); + roomObj.setInvitedCount(invited_count); + } + this.emit("rooms", [...this.roomToRoom.values()]); + } + + /** + * addInViewRoom + * + * Tells the sync that a room with a certain roomID is inView. + */ + public addInViewRoom(roomID: string) { + this.roomsInView.push(roomID); + } + + /** + * removeInViewRoom + * + * Tells the sync that a room isn't in the room anymore + */ + public removeInViewRoom(roomID: string) { + this.roomsInView = this.roomsInView.filter(room => room !== roomID); + } + + + private async getLoginFlows(): Promise<ILoginFlows> { + if (!this.hostname) { + throw Error("Hostname must be set first"); + } + const resp = await fetch(`${this.hostname}/_matrix/client/v3/login`); + if (!resp.ok) { + console.error(resp); + throw Error("Error requesting login flows. See console for error."); + } + const json = await resp.json() as ILoginFlows; + return json; + } + + private async getWellKnown(): Promise<IWellKnown> { + if (!this.hostname) { + throw Error("Hostname must be set first"); + } + const resp = await fetch(`${this.hostname}/.well-known/matrix/client`); + if (!resp.ok) { + console.error(resp); + throw Error("Error requesting login flows. See console for error."); + } + const json = await resp.json() as IWellKnown; + return json; + } + + public async passwordLogin(username: string, password: string, triesLeft = 5) { + this.setHostname = `https://${username.split(':')[1]}`; + + try { + const well_known = await this.getWellKnown(); + if (well_known["m.homeserver"]?.base_url) { + this.setHostname = well_known["m.homeserver"].base_url; + } + if (well_known["org.matrix.msc3575.proxy"]?.url) { + this.slidingSyncHostname = well_known["org.matrix.msc3575.proxy"].url; + } else { + // TODO: we might want to check for synapse support somehow + throw Error("No sliding sync proxy found"); + } + } catch (e: any) { + console.warn(`No well-known found for ${this.hostname}:\n${e}`); + } + + const loginFlows = await this.getLoginFlows(); + if ((loginFlows.flows.filter((flow) => flow.type === 'm.login.password')?.length || 0) == 0) { + throw Error("Password login is not supported by this homeserver"); + } + + const resp = await fetch(`${this.hostname}/_matrix/client/r0/login`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + type: "m.login.password", + identifier: { + type: 'm.id.user', + user: username, + }, + user: username, + password: password + }) + }); + if (!resp.ok) { + console.error(resp); + throw Error("Error logging in. See console for error."); + } + const json = await resp.json(); + if (isErrorResp(json)) { + throw Error(`Error logging in: ${json.errcode}: ${json.error}`); + } + if (isRateLimitError(json)) { + console.error(`Rate limited. Retrying in ${json.retry_after_ms}ms. ${triesLeft} tries left.`); + await this.passwordLogin(username, password, triesLeft - 1); + } + if (isLoginResponse(json)) { + this.access_token = json.access_token; + this.device_id = json.device_id; + this.mxid = json.user_id; + } + } +} + +function isRateLimitError(arg: any): arg is IRateLimitError { + return arg.retry_after_ms !== undefined; +} + +function isLoginResponse(arg: any): arg is ILoginResponse { + return arg.access_token !== undefined; +} + +function isErrorResp(arg: any): arg is IErrorResp { + return arg.errcode !== undefined; +} + +export const defaultMatrixClient = MatrixClient.Instance; +export const MatrixContext = createContext(defaultMatrixClient); + +// List of rooms +export function useRooms() { + const client = MatrixClient.Instance; + const [rooms, setRooms] = useState<Room[]>([]); + + useEffect(() => { + // This is a no-op if there is already a sync + client.startSync(); + // Listen for room updates + client.on("rooms", (rooms: Room[]) => { + setRooms(rooms); + }); + }, []) + return rooms; +} +\ No newline at end of file diff --git a/src/app/sdk/room.ts b/src/app/sdk/room.ts @@ -0,0 +1,56 @@ +export class Room { + private events: any[] = []; + private name: string = "Unknown Room"; + + private notification_count: number = 0; + private notification_highlight_count: number = 0; + private joined_count: number = 0; + private invited_count: number = 0; + + + constructor(public roomID: string) { } + + public addEvent(event: any) { + this.events.push(event); + } + + public setName(name: string) { + this.name = name; + } + + public getName(): string { + return this.name; + } + + public setNotificationCount(count: number) { + this.notification_count = count; + } + + public getNotificationCount(): number { + return this.notification_count; + } + + public setNotificationHighlightCount(count: number) { + this.notification_highlight_count = count; + } + + public getNotificationHighlightCount(): number { + return this.notification_highlight_count; + } + + public setJoinedCount(count: number) { + this.joined_count = count; + } + + public getJoinedCount(): number { + return this.joined_count; + } + + public setInvitedCount(count: number) { + this.invited_count = count; + } + + public getInvitedCount(): number { + return this.invited_count; + } +} +\ No newline at end of file diff --git a/src/app/store.ts b/src/app/store.ts @@ -1,46 +0,0 @@ -import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"; -import { matrixApi, auth } from "./api/api"; -import { setupListeners } from "@reduxjs/toolkit/dist/query/react"; -import { FLUSH, PAUSE, PERSIST, PURGE, REGISTER, REHYDRATE, persistCombineReducers, persistStore } from "reduxjs-toolkit-persist"; -import createIdbStorage from '@piotr-cz/redux-persist-idb-storage' - -const persistConfig = { - key: 'root', - version: 1, - storage: createIdbStorage({ name: 'cetirizine', storeName: 'cetirizine' }), - serialize: false, - deserialize: false, -} - -const persistedReducer = persistCombineReducers( - persistConfig, - { - auth: auth, - [matrixApi.reducerPath]: matrixApi.reducer, - } -); - -export const store = configureStore({ - reducer: persistedReducer, - middleware: (getDefaultMiddleware) => [ - ...getDefaultMiddleware({ - serializableCheck: { - ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], - }, - }), - matrixApi.middleware, - ], -}); - -setupListeners(store.dispatch) - -export const persistor = persistStore(store); - -export type AppDispatch = typeof store.dispatch; -export type RootState = ReturnType<typeof store.getState>; -export type AppThunk<ReturnType = void> = ThunkAction< - ReturnType, - RootState, - unknown, - Action<string> ->; -\ No newline at end of file diff --git a/src/components/input/basic/input.tsx b/src/components/input/basic/input.tsx @@ -31,7 +31,7 @@ const Input: FC<InputProps> = ({ placeholder, password = false, autoFocus = fals return ( <input disabled={readonly} - className='form-input rounded-lg disabled:bg-slate-200 disabled:cursor-not-allowed transition-colors ease-in-out delay-150' + className='text-base 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 @@ -1,58 +1,27 @@ -import { useState } from 'react'; -import { useAppDispatch } from '../../app/hooks'; -import { setHost, useDoLoginMutation, useLazyGetLoginFlowsQuery, useLazyGetWellKnownQuery } from '../../app/api/api'; +import { useContext, useState } from 'react'; import Button from '../button/button'; import Header from '../header/header'; import Input from '../input/basic/input'; import { Navigate } from 'react-router-dom'; -import { SerializedError } from '@reduxjs/toolkit'; -import { FetchBaseQueryError } from '@reduxjs/toolkit/dist/query'; +import { MatrixContext } from '../../app/sdk/client'; export function Login() { - const [login, { isLoading: loginPending, isSuccess, error: loginErrorRaw }] = useDoLoginMutation(); - const [triggerWellKnown] = useLazyGetWellKnownQuery(); - const [triggerLoginFlows] = useLazyGetLoginFlowsQuery(); - const [loginError, setLoginError] = useState<FetchBaseQueryError | SerializedError | undefined>(loginErrorRaw); + const matrixClient = useContext(MatrixContext); + const [loginPending, setLoginPending] = useState(false); + const [loginError, setLoginError] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); - const dispatch = useAppDispatch(); - - if (isSuccess) { + if (matrixClient.isLoggedIn) { return <Navigate to="/" />; } const startLogin = async () => { - dispatch(setHost(`https://${username.split(':')[1]}`)); - const { isError, error } = await triggerWellKnown(undefined); - if (isError) { - setLoginError(error); - return; - } - const { data } = await triggerLoginFlows(undefined, true); - if ((data?.flows?.filter((flow) => flow.type === 'm.login.password')?.length || 0) > 0) { - const payload = await login({ - type: 'm.login.password', - identifier: { - type: 'm.id.user', - user: username, - }, - user: username, - password: password - }); - if ('error' in payload) { - if (JSON.stringify(payload.error).includes('M_LIMIT_EXCEEDED')) { - setLoginError({ status: "CUSTOM_ERROR", error: 'Too many attempts, try again later' }); - return; - } - if (JSON.stringify(payload.error).includes('M_FORBIDDEN')) { - setLoginError({ status: "CUSTOM_ERROR", error: (payload.error as any).data.error }); - return; - } - setLoginError(payload.error); - return; - } - } else { - setLoginError({ status: "CUSTOM_ERROR", error: 'No password login flow found' }); + try { + setLoginPending(true); + await matrixClient.passwordLogin(username, password); + } catch (e: any) { + setLoginError(e); } + setLoginPending(false); } return ( <form className="flex flex-col rounded-md shadow p-4 bg-white gap-2 min-w-[30rem]" onSubmit={(e) => { @@ -60,7 +29,7 @@ export function Login() { startLogin(); }}> <Header>Login</Header> - {loginError ? <h2 className='text-red-500 font-normal text-sm'>{'error' in loginError ? loginError.error : ('data' in loginError ? JSON.stringify(loginError.data) : loginError.message)}</h2> : <div className='min-h-[1.25rem]'></div>} + {loginError ? <h2 className='text-red-500 font-normal text-sm'>{loginError}</h2> : <div className='min-h-[1.25rem]'></div>} <Input readonly={loginPending} value={username} diff --git a/src/components/roomList/roomList.tsx b/src/components/roomList/roomList.tsx @@ -83,6 +83,7 @@ const RoomListRooms: FC<RoomListRoomsProps> = ({ sectionID, rooms, onClick, acti const roomsRendered = rooms.map(room => { return ( <RoomListItem + roomId={room.roomID} hidden={hidden} key={room.roomID + sectionID} avatarUrl={room.avatarUrl} diff --git a/src/components/roomList/roomListItem/roomListItem.tsx b/src/components/roomList/roomListItem/roomListItem.tsx @@ -1,9 +1,14 @@ import { useInView } from "react-intersection-observer"; import Avatar from "../../avatar/avatar"; -import { FC } from "react"; +import { FC, useContext, useEffect } from "react"; +import { MatrixContext } from "../../../app/sdk/client"; type RoomListItemProps = { /** + * Room id + */ + roomId: string + /** * The URL of the Avatar image */ avatarUrl?: string @@ -33,12 +38,20 @@ type RoomListItemProps = { hidden: boolean }; -const RoomListItem: FC<RoomListItemProps> = ({ avatarUrl, displayname, dm = false, online = false, active = false, onClick, hidden }: RoomListItemProps) => { +const RoomListItem: FC<RoomListItemProps> = ({ roomId, avatarUrl, displayname, dm = false, online = false, active = false, onClick, hidden }: RoomListItemProps) => { const { ref, inView } = useInView({ triggerOnce: true, rootMargin: '200px 0px', skip: hidden, }); + const matrixClient = useContext(MatrixContext); + useEffect(() => { + if (inView) { + matrixClient.addInViewRoom(roomId) + } else { + matrixClient.removeInViewRoom(roomId) + } + }, [inView]) return ( <div ref={ref} onClick={onClick} className="w-full cursor-pointer"> { diff --git a/src/main.tsx b/src/main.tsx @@ -1,22 +1,21 @@ /// <reference types="./@types/global.d.ts" /> import React from 'react' import { createRoot } from 'react-dom/client'; -import { Provider } from 'react-redux'; -import { persistor, store } from './app/store'; import App from './App'; import './index.scss'; -import { PersistGate } from 'reduxjs-toolkit-persist/integration/react'; +import { enableMapSet, enablePatches } from "immer" +import { MatrixContext, defaultMatrixClient } from './app/sdk/client'; + +enableMapSet(); +enablePatches(); const container = document.getElementById('root')!; const root = createRoot(container); root.render( <React.StrictMode> - <Provider store={store}> - <PersistGate loading={<span>Loading...</span>} persistor={persistor}> - <App /> - </PersistGate> - - </Provider> + <MatrixContext.Provider value={defaultMatrixClient}> + <App /> + </MatrixContext.Provider> </React.StrictMode> ); \ No newline at end of file diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx @@ -3,11 +3,10 @@ import Avatar from '../components/avatar/avatar'; import ChatInput from '../components/input/chat/input'; import RoomList from '../components/roomList/roomList'; import './MainPage.scss'; -import { useSyncQuery } from '../app/api/api'; - +import { useRooms } from '../app/sdk/client'; export default function MainPage() { - const { } = useSyncQuery(); - // TODO: Calculate rooms and sections within a RTK Query + const matrixRooms = useRooms(); + console.log(matrixRooms.length); const sections = [ { sectionName: "Test Section", @@ -79,7 +78,6 @@ export default function MainPage() { </div> <div className='flex-1 flex flex-col'> <div className='flex-1'> - TODO: events and welcome page </div> <ChatInput namespace='Editor' onChange={() => { }} onError={(e) => console.error(e)} /> </div>