diff --git a/package.json b/package.json index 792a413..ad67d5f 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@quentin-sommer/react-useragent": "^3.1.0", "classnames": "^2.2.6", "formik": "^2.1.4", "matrix-cypher": "^0.1.12", diff --git a/src/App.tsx b/src/App.tsx index 748d405..e2d9347 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,34 +14,34 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React from 'react'; -import SingleColumn from "./layouts/SingleColumn"; -import CreateLinkTile from "./components/CreateLinkTile"; -import MatrixTile from "./components/MatrixTile"; -import Tile from "./components/Tile"; -import LinkRouter from "./pages/LinkRouter"; +import SingleColumn from './layouts/SingleColumn'; +import CreateLinkTile from './components/CreateLinkTile'; +import MatrixTile from './components/MatrixTile'; +import Tile from './components/Tile'; +import LinkRouter from './pages/LinkRouter'; -import "./App.scss"; +import GlobalContext from './contexts/GlobalContext'; /* eslint-disable no-restricted-globals */ const App: React.FC = () => { let page = ( <> -
{" "} +
{' '} ); if (location.hash) { console.log(location.hash); - if (location.hash.startsWith("#/")) { + if (location.hash.startsWith('#/')) { page = ; } else { - console.log("asdfadf"); + console.log('asdfadf'); page = ( - Links should be in the format {location.host}/#/{"<"} - matrix-resource-identifier{">"} + Links should be in the format {location.host}/#/{'<'} + matrix-resource-identifier{'>'} ); } @@ -50,7 +50,7 @@ const App: React.FC = () => { return (
- {page} + {page}
diff --git a/src/clients/types.ts b/src/clients/types.ts index 8daea91..2df503b 100644 --- a/src/clients/types.ts +++ b/src/clients/types.ts @@ -20,10 +20,10 @@ import { SafeLink } from '../parser/types'; * A collection of descriptive tags that can be added to * a clients description. */ -export enum Tag { - IOS = 'IOS', - ANDROID = 'ANDROID', - DESKTOP = 'DESKTOP', +export enum Platform { + iOS = 'iOS', + Android = 'ANDROID', + Desktop = 'DESKTOP', } /* @@ -45,6 +45,12 @@ export enum ClientKind { TEXT_CLIENT = 'TEXT_CLIENT', } +export enum ClientId { + Element = 'element.io', + ElementDevelop = 'develop.element.io', + WeeChat = 'weechat', +} + /* * The descriptive details of a client */ @@ -54,8 +60,10 @@ export interface ClientDescription { homepage: string; logo: string; description: string; - tags: Tag[]; + platform: Platform; maturity: Maturity; + clientId: ClientId; + experimental: boolean; } /* @@ -72,7 +80,7 @@ export interface LinkedClient extends ClientDescription { */ export interface TextClient extends ClientDescription { kind: ClientKind.TEXT_CLIENT; - toInviteString(parsedLink: SafeLink): string; + toInviteString(parsedLink: SafeLink): JSX.Element; } /* diff --git a/src/contexts/ClientContext.ts b/src/contexts/ClientContext.ts index cb530ce..52da9f2 100644 --- a/src/contexts/ClientContext.ts +++ b/src/contexts/ClientContext.ts @@ -15,54 +15,96 @@ limitations under the License. */ import React from 'react'; +import { object, string, boolean, TypeOf } from 'zod'; -import { prefixFetch, Client, discoverServer } from 'matrix-cypher'; +import { ClientId } from '../clients/types'; +import { persistReducer } from '../utils/localStorage'; -type State = { - clientURL: string; - client: Client; -}[]; +const STATE_SCHEMA = object({ + clientId: string().nullable(), + showOnlyDeviceClients: boolean(), + rememberSelection: boolean(), + showExperimentalClients: boolean(), +}); + +type State = TypeOf; // Actions are a discriminated union. -export enum ActionTypes { - AddClient = 'ADD_CLIENT', - RemoveClient = 'REMOVE_CLIENT', +export enum ActionType { + SetClient = 'SET_CLIENT', + ToggleRememberSelection = 'TOGGLE_REMEMBER_SELECTION', + ToggleShowOnlyDeviceClients = 'TOGGLE_SHOW_ONLY_DEVICE_CLIENTS', + ToggleShowExperimentalClients = 'TOGGLE_SHOW_EXPERIMENTAL_CLIENTS', } -export interface AddClient { - action: ActionTypes.AddClient; - clientURL: string; +interface SetClient { + action: ActionType.SetClient; + clientId: ClientId; } -export interface RemoveClient { - action: ActionTypes.RemoveClient; - clientURL: string; +interface ToggleRememberSelection { + action: ActionType.ToggleRememberSelection; } -export type Action = AddClient | RemoveClient; +interface ToggleShowOnlyDeviceClients { + action: ActionType.ToggleShowOnlyDeviceClients; +} -export const INITIAL_STATE: State = []; -export const reducer = async (state: State, action: Action): Promise => { - switch (action.action) { - case ActionTypes.AddClient: - return state.filter((x) => x.clientURL !== action.clientURL); +interface ToggleShowExperimentalClients { + action: ActionType.ToggleShowExperimentalClients; +} - case ActionTypes.RemoveClient: - if (!state.filter((x) => x.clientURL === action.clientURL)) { - const resolvedURL = await discoverServer(action.clientURL); - state.push({ - clientURL: resolvedURL, - client: prefixFetch(resolvedURL), - }); - } - } - return state; +export type Action = + | SetClient + | ToggleRememberSelection + | ToggleShowOnlyDeviceClients + | ToggleShowExperimentalClients; + +const INITIAL_STATE: State = { + clientId: null, + rememberSelection: false, + showOnlyDeviceClients: true, + showExperimentalClients: false, }; -// The null is a hack to make the type checker happy -// create context does not need an argument -const { Provider, Consumer } = React.createContext(null); +export const [initialState, reducer] = persistReducer( + 'default-client', + INITIAL_STATE, + STATE_SCHEMA, + (state: State, action: Action): State => { + switch (action.action) { + case ActionType.SetClient: + return { + ...state, + clientId: action.clientId, + }; + case ActionType.ToggleRememberSelection: + return { + ...state, + rememberSelection: !state.rememberSelection, + }; + case ActionType.ToggleShowOnlyDeviceClients: + return { + ...state, + showOnlyDeviceClients: !state.showOnlyDeviceClients, + }; + case ActionType.ToggleShowExperimentalClients: + return { + ...state, + showExperimentalClients: !state.showExperimentalClients, + }; + default: + return state; + } + } +); + +// The defualt reducer needs to be overwritten with the one above +// after it's been put through react's useReducer +export const ClientContext = React.createContext< + [State, React.Dispatch] +>([initialState, (): void => {}]); // Quick rename to make importing easier -export const ClientProvider = Provider; -export const ClientConsumer = Consumer; +export const ClientProvider = ClientContext.Provider; +export const ClientConsumer = ClientContext.Consumer; diff --git a/src/contexts/GlobalContext.tsx b/src/contexts/GlobalContext.tsx new file mode 100644 index 0000000..20b4401 --- /dev/null +++ b/src/contexts/GlobalContext.tsx @@ -0,0 +1,36 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useReducer } from 'react'; +import { UserAgentProvider } from '@quentin-sommer/react-useragent'; + +import { + ClientProvider, + reducer as clientReducer, + initialState as clientInitialState, +} from './ClientContext'; + +interface IProps { + children: React.ReactNode; +} + +export default ({ children }: IProps): JSX.Element => ( + + + {children} + + +); diff --git a/src/contexts/HSContext.ts b/src/contexts/HSContext.ts new file mode 100644 index 0000000..e6afb95 --- /dev/null +++ b/src/contexts/HSContext.ts @@ -0,0 +1,113 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { string, object, union, literal, TypeOf } from 'zod'; + +import { persistReducer } from '../utils/localStorage'; + +//import { prefixFetch, Client, discoverServer } from 'matrix-cypher'; + +enum HSOptions { + // The homeserver contact policy hasn't + // been set yet. + Unset = 'UNSET', + // Matrix.to should only contact a single provided homeserver + TrustedClientOnly = 'TRUSTED_CLIENT_ONLY', + // Matrix.to may contact any homeserver it requires + Any = 'ANY', + // Matrix.to may not contact any homeservers + None = 'NONE', +} + +const STATE_SCHEMA = union([ + object({ + option: literal(HSOptions.Unset), + }), + object({ + option: literal(HSOptions.None), + }), + object({ + option: literal(HSOptions.Any), + }), + object({ + option: literal(HSOptions.TrustedClientOnly), + hs: string(), + }), +]); + +type State = TypeOf; + +// TODO: rename actions to something with more meaning out of context +export enum ActionTypes { + SetHS = 'SET_HS', + SetAny = 'SET_ANY', + SetNone = 'SET_NONE', +} + +export interface SetHS { + action: ActionTypes.SetHS; + HSURL: string; +} + +export interface SetAny { + action: ActionTypes.SetAny; +} + +export interface SetNone { + action: ActionTypes.SetNone; +} + +export type Action = SetHS | SetAny | SetNone; + +export const INITIAL_STATE: State = { + option: HSOptions.Unset, +}; + +export const [initialState, reducer] = persistReducer( + 'home-server-options', + INITIAL_STATE, + STATE_SCHEMA, + (state: State, action: Action): State => { + switch (action.action) { + case ActionTypes.SetNone: + return { + option: HSOptions.None, + }; + case ActionTypes.SetAny: + return { + option: HSOptions.Any, + }; + case ActionTypes.SetHS: + return { + option: HSOptions.TrustedClientOnly, + hs: action.HSURL, + }; + default: + return state; + } + } +); + +// The defualt reducer needs to be overwritten with the one above +// after it's been put through react's useReducer +const { Provider, Consumer } = React.createContext< + [State, React.Dispatch] +>([initialState, (): void => {}]); + +// Quick rename to make importing easier +export const HSProvider = Provider; +export const HSConsumer = Consumer; diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts new file mode 100644 index 0000000..b93a2fa --- /dev/null +++ b/src/utils/localStorage.ts @@ -0,0 +1,59 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { + Schema, +} from 'zod'; +import React from 'react'; + +/* + * Initialises local storage to initial value if + * a value matching the schema is not in storage. + */ +export function persistReducer( + stateKey: string, + initialState: T, + schema: Schema, + reducer: React.Reducer, +): [T, React.Reducer] { + let currentState = initialState; + // Try to load state from local storage + const stateInStorage = localStorage.getItem(stateKey); + if (stateInStorage) { + try { + // Validate state type + const parsedState = JSON.parse(stateInStorage); + if (parsedState as T) { + currentState = schema.parse(parsedState); + } + } catch (e) { + // if invalid delete state + localStorage.setItem(stateKey, JSON.stringify(initialState)); + } + } else { + localStorage.setItem(stateKey, JSON.stringify(initialState)); + } + + return [ + currentState, + (state: T, action: A) => { + // state passed to this reducer is the source of truth + const newState = reducer(state, action); + localStorage.setItem(stateKey, JSON.stringify(newState)); + return newState; + }, + ]; +}