Compare commits
4 commits
main
...
jryans/ux-
Author | SHA1 | Date | |
---|---|---|---|
|
5656059342 | ||
|
75d7cfa2a9 | ||
|
7a79e68d5e | ||
|
0a3b8c2123 |
9 changed files with 197 additions and 130 deletions
|
@ -16,6 +16,7 @@ limitations under the License.
|
|||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import GlobalContext from './contexts/GlobalContext';
|
||||
import SingleColumn from './layouts/SingleColumn';
|
||||
import CreateLinkTile from './components/CreateLinkTile';
|
||||
import MatrixTile from './components/MatrixTile';
|
||||
|
@ -24,8 +25,6 @@ import LinkRouter from './pages/LinkRouter';
|
|||
|
||||
import './App.scss';
|
||||
|
||||
import GlobalContext from './contexts/GlobalContext';
|
||||
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
const App: React.FC = () => {
|
||||
|
|
|
@ -17,6 +17,16 @@ limitations under the License.
|
|||
@import '../color-scheme';
|
||||
|
||||
.homeserverOptions {
|
||||
.actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-top: 2em;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
display: grid;
|
||||
row-gap: 20px;
|
||||
|
||||
|
@ -50,8 +60,16 @@ limitations under the License.
|
|||
}
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
row-gap: 25px;
|
||||
|
||||
.serverChoices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.serverChoices label {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 8px 0;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,16 +15,18 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { Formik, Form } from 'formik';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import { string } from 'zod';
|
||||
|
||||
import Tile from './Tile';
|
||||
import HSContext, { TempHSContext, ActionType } from '../contexts/HSContext';
|
||||
import icon from '../imgs/telecom-mast.svg';
|
||||
import Button from './Button';
|
||||
import TextButton from './TextButton';
|
||||
import Input from './Input';
|
||||
import StyledCheckbox from './StyledCheckbox';
|
||||
import { SafeLink } from '../parser/types';
|
||||
import { getHSFromIdentifier } from "../utils/getHS";
|
||||
|
||||
import './HomeserverOptions.scss';
|
||||
|
||||
|
@ -34,95 +36,119 @@ interface IProps {
|
|||
|
||||
interface FormValues {
|
||||
HSUrl: string;
|
||||
HSOtherUrl: string;
|
||||
}
|
||||
|
||||
interface SubmitEvent extends Event {
|
||||
submitter: HTMLFormElement;
|
||||
}
|
||||
|
||||
function validateURL(values: FormValues): Partial<FormValues> {
|
||||
const errors: Partial<FormValues> = {};
|
||||
try {
|
||||
string().url().parse(values.HSUrl);
|
||||
} catch {
|
||||
errors.HSUrl =
|
||||
'This must be a valid homeserver URL, starting with https://';
|
||||
if (values.HSUrl === "other") {
|
||||
try {
|
||||
string().url().parse(hsToURL(values.HSOtherUrl));
|
||||
} catch {
|
||||
errors.HSOtherUrl =
|
||||
'This must be a valid homeserver URL';
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function hsToURL(hs: string): string {
|
||||
if (!hs.startsWith("http://") && !hs.startsWith("https://")) {
|
||||
return "https://" + hs;
|
||||
}
|
||||
return hs;
|
||||
}
|
||||
|
||||
function getChosenHS(values: FormValues) {
|
||||
return values.HSUrl === "other" ? values.HSOtherUrl : values.HSUrl;
|
||||
}
|
||||
|
||||
function getHSDomain(hs: string) {
|
||||
try {
|
||||
// TODO: take port as well
|
||||
return new URL(hsToURL(hs)).hostname;
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const HomeserverOptions: React.FC<IProps> = ({ link }: IProps) => {
|
||||
const HSStateDispatcher = useContext(HSContext)[1];
|
||||
const TempHSStateDispatcher = useContext(TempHSContext)[1];
|
||||
|
||||
const [rememberSelection, setRemeberSelection] = useState(false);
|
||||
const [askEveryTime, setAskEveryTime] = useState(false);
|
||||
const [showHSPicker, setShowHSPicker] = useState(false);
|
||||
|
||||
// Select which disaptcher to use based on whether we're writing
|
||||
// the choice to localstorage
|
||||
const dispatcher = rememberSelection
|
||||
? HSStateDispatcher
|
||||
: TempHSStateDispatcher;
|
||||
const dispatcher = askEveryTime
|
||||
? TempHSStateDispatcher
|
||||
: HSStateDispatcher;
|
||||
|
||||
const hsInput = (
|
||||
<Formik
|
||||
initialValues={{
|
||||
HSUrl: '',
|
||||
}}
|
||||
validate={validateURL}
|
||||
onSubmit={({ HSUrl }): void =>
|
||||
dispatcher({ action: ActionType.SetHS, HSURL: HSUrl })
|
||||
}
|
||||
>
|
||||
{({ values, errors }): JSX.Element => (
|
||||
<Form>
|
||||
<Input
|
||||
muted={!values.HSUrl}
|
||||
type="text"
|
||||
name="HSUrl"
|
||||
placeholder="Preferred homeserver URL"
|
||||
/>
|
||||
{values.HSUrl && !errors.HSUrl ? (
|
||||
<Button secondary type="submit">
|
||||
Use {values.HSUrl}
|
||||
</Button>
|
||||
) : null}
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
let topSection;
|
||||
const identifierHS = getHSFromIdentifier(link.identifier) || "";
|
||||
const homeservers = [identifierHS, ... link.arguments.vias];
|
||||
|
||||
const chosenHS = "home.server";
|
||||
const continueWithout = <TextButton onClick={(e): void => dispatcher({ action: ActionType.SetNone})}>continue without a preview</TextButton>;
|
||||
let instructions;
|
||||
if (showHSPicker) {
|
||||
instructions = <p>View this link using {chosenHS} to preview content or {continueWithout}.</p>
|
||||
} else {
|
||||
const useAnotherServer = <TextButton onClick={(e): void => setShowHSPicker(true)}>use another server</TextButton>;
|
||||
instructions = <p>View this link using {chosenHS} to preview content, or you can {useAnotherServer} or {continueWithout}.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<Tile className="homeserverOptions">
|
||||
<div className="homeserverOptionsDescription">
|
||||
<div>
|
||||
<h3>
|
||||
About
|
||||
<span className="matrixIdentifier">
|
||||
{link.identifier}
|
||||
</span>
|
||||
</h3>
|
||||
<p>
|
||||
A homeserver will show you metadata about the link, like
|
||||
a description. Homeservers will be able to relate your
|
||||
IP to things you've opened invites for in matrix.to.
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
src={icon}
|
||||
alt="Icon making it clear that connections may be made with external services"
|
||||
/>
|
||||
</div>
|
||||
<StyledCheckbox
|
||||
checked={rememberSelection}
|
||||
onChange={(e): void => setRemeberSelection(e.target.checked)}
|
||||
>
|
||||
Remember my choice
|
||||
</StyledCheckbox>
|
||||
<Button
|
||||
secondary
|
||||
onClick={(): void => {
|
||||
dispatcher({ action: ActionType.SetAny });
|
||||
{instructions}
|
||||
<Formik
|
||||
initialValues={{
|
||||
HSUrl: identifierHS,
|
||||
HSOtherUrl: '',
|
||||
}}
|
||||
>
|
||||
Use any homeserver
|
||||
</Button>
|
||||
{hsInput}
|
||||
validate={validateURL}
|
||||
onSubmit={(values): void => {
|
||||
dispatcher({ action: ActionType.SetHS, HSURL: getHSDomain(getChosenHS(values)) || "" });
|
||||
}}>
|
||||
{({ values, errors }): JSX.Element => {
|
||||
let hsOptions;
|
||||
if (showHSPicker) {
|
||||
const radios = homeservers.map(hs => {
|
||||
return <label key={hs}><Field
|
||||
type="radio"
|
||||
name="HSUrl"
|
||||
value={hs}
|
||||
/>{hs}</label>;
|
||||
});
|
||||
const otherHSField = values.HSUrl === "other" ?
|
||||
<Input
|
||||
required={true}
|
||||
type="text"
|
||||
name="HSOtherUrl"
|
||||
placeholder="Preferred homeserver URL"
|
||||
/> : undefined;
|
||||
hsOptions = <div role="group" className="serverChoices">
|
||||
{radios}
|
||||
<label><Field type="radio" name="HSUrl" value="other" />Other {otherHSField}</label>
|
||||
</div>;
|
||||
}
|
||||
return <Form>
|
||||
{hsOptions}
|
||||
<div className="actions">
|
||||
<StyledCheckbox
|
||||
checked={askEveryTime}
|
||||
onChange={(e): void => setAskEveryTime(e.target.checked)}
|
||||
>Ask every time</StyledCheckbox>
|
||||
<Button type="submit">Continue</Button>
|
||||
</div>
|
||||
</Form>;
|
||||
}}
|
||||
</Formik>;
|
||||
</Tile>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -23,6 +23,7 @@ import './Input.scss';
|
|||
interface IProps extends React.InputHTMLAttributes<HTMLElement> {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
@ -22,8 +22,9 @@ limitations under the License.
|
|||
color: $link;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-size: inherit;
|
||||
padding: 8px 0;
|
||||
margin: -8px 0;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
|
|
@ -25,6 +25,8 @@ export enum HSOptions {
|
|||
Unset = 'UNSET',
|
||||
// Matrix.to should only contact a single provided homeserver
|
||||
TrustedHSOnly = 'TRUSTED_CLIENT_ONLY',
|
||||
// Matrix.to may not contact any homeserver
|
||||
None = 'NONE',
|
||||
// Matrix.to may contact any homeserver it requires
|
||||
Any = 'ANY',
|
||||
}
|
||||
|
@ -33,6 +35,9 @@ const STATE_SCHEMA = union([
|
|||
object({
|
||||
option: literal(HSOptions.Unset),
|
||||
}),
|
||||
object({
|
||||
option: literal(HSOptions.None),
|
||||
}),
|
||||
object({
|
||||
option: literal(HSOptions.Any),
|
||||
}),
|
||||
|
@ -48,6 +53,7 @@ export type State = TypeOf<typeof STATE_SCHEMA>;
|
|||
export enum ActionType {
|
||||
SetHS = 'SET_HS',
|
||||
SetAny = 'SET_ANY',
|
||||
SetNone = 'SET_NONE',
|
||||
Clear = 'CLEAR',
|
||||
}
|
||||
|
||||
|
@ -56,6 +62,10 @@ export interface SetHS {
|
|||
HSURL: string;
|
||||
}
|
||||
|
||||
export interface SetNone {
|
||||
action: ActionType.SetNone;
|
||||
}
|
||||
|
||||
export interface SetAny {
|
||||
action: ActionType.SetAny;
|
||||
}
|
||||
|
@ -64,7 +74,7 @@ export interface Clear {
|
|||
action: ActionType.Clear;
|
||||
}
|
||||
|
||||
export type Action = SetHS | SetAny | Clear;
|
||||
export type Action = SetHS | SetAny | SetNone | Clear;
|
||||
|
||||
export const INITIAL_STATE: State = {
|
||||
option: HSOptions.Unset,
|
||||
|
@ -76,6 +86,10 @@ export const unpersistedReducer = (_state: State, action: Action): State => {
|
|||
return {
|
||||
option: HSOptions.Any,
|
||||
};
|
||||
case ActionType.SetNone:
|
||||
return {
|
||||
option: HSOptions.None,
|
||||
};
|
||||
case ActionType.SetHS:
|
||||
return {
|
||||
option: HSOptions.TrustedHSOnly,
|
||||
|
|
|
@ -30,18 +30,21 @@ body,
|
|||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
font-style: normal;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
|
||||
color: $font;
|
||||
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root,
|
||||
button.textButton {
|
||||
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
|
|
|
@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import HSContext, { HSOptions } from '../contexts/HSContext';
|
||||
import Tile from '../components/Tile';
|
||||
import LinkPreview from '../components/LinkPreview';
|
||||
import InvitingClientTile from '../components/InvitingClientTile';
|
||||
import { parseHash } from '../parser/parser';
|
||||
import { LinkKind } from '../parser/types';
|
||||
import HomeserverOptions from '../components/HomeserverOptions';
|
||||
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
|
@ -31,42 +33,39 @@ interface IProps {
|
|||
const LinkRouter: React.FC<IProps> = ({ link }: IProps) => {
|
||||
// our room id's will be stored in the hash
|
||||
const parsedLink = parseHash(link);
|
||||
const [hsState] = useContext(HSContext);
|
||||
|
||||
let feedback: JSX.Element;
|
||||
let client: JSX.Element = <></>;
|
||||
switch (parsedLink.kind) {
|
||||
case LinkKind.ParseFailed:
|
||||
feedback = (
|
||||
<Tile>
|
||||
<p>
|
||||
That URL doesn't seem right. Links should be in the
|
||||
format:
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
{location.host}/#/{'<'}matrix-resourceidentifier{'>'}
|
||||
</p>
|
||||
</Tile>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
if (parsedLink.arguments.client) {
|
||||
client = (
|
||||
<InvitingClientTile
|
||||
clientName={parsedLink.arguments.client}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
feedback = (
|
||||
<>
|
||||
<LinkPreview link={parsedLink} />
|
||||
{client}
|
||||
</>
|
||||
);
|
||||
if (parsedLink.kind === LinkKind.ParseFailed) {
|
||||
return (
|
||||
<Tile>
|
||||
<p>
|
||||
That URL doesn't seem right. Links should be in the format:
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
{location.host}/#/{'<'}matrix-resourceidentifier{'>'}
|
||||
</p>
|
||||
</Tile>
|
||||
);
|
||||
}
|
||||
|
||||
return feedback;
|
||||
if (hsState.option === HSOptions.Unset) {
|
||||
return <HomeserverOptions link={parsedLink} />;
|
||||
}
|
||||
|
||||
let client: JSX.Element = <></>;
|
||||
if (parsedLink.arguments.client) {
|
||||
client = (
|
||||
<InvitingClientTile clientName={parsedLink.arguments.client} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LinkPreview link={parsedLink} />
|
||||
{client}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkRouter;
|
||||
|
|
|
@ -22,6 +22,19 @@ import HSContext, {
|
|||
} from '../contexts/HSContext';
|
||||
import { SafeLink } from '../parser/types';
|
||||
|
||||
export function getHSFromIdentifier(identifier: string) {
|
||||
try {
|
||||
const match = identifier.match(/^.*:(?<server>.*)$/);
|
||||
if (match && match.groups) {
|
||||
return match.groups.server;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Could parse user identifier: ${identifier}`);
|
||||
console.error(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
function selectedClient({ link, identifier, hsOptions }: {
|
||||
link?: SafeLink,
|
||||
identifier?: string;
|
||||
|
@ -35,23 +48,16 @@ function selectedClient({ link, identifier, hsOptions }: {
|
|||
] : [];
|
||||
const identifierHS: string[] = [];
|
||||
|
||||
try {
|
||||
if (identifier) {
|
||||
const match = identifier.match(/^.*:(?<server>.*)$/);
|
||||
if (match && match.groups) {
|
||||
const server = match.groups.server;
|
||||
if (server) {
|
||||
identifierHS.push(server);
|
||||
}
|
||||
}
|
||||
if (identifier) {
|
||||
const server = getHSFromIdentifier(identifier);
|
||||
if (server) {
|
||||
identifierHS.push(server);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Could parse user identifier: ${identifier}`);
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
switch (hsOptions.option) {
|
||||
case HSOptions.Unset:
|
||||
case HSOptions.None:
|
||||
return [];
|
||||
case HSOptions.TrustedHSOnly:
|
||||
return [hsOptions.hs];
|
||||
|
|
Loading…
Reference in a new issue