Compare commits

...

4 commits

Author SHA1 Message Date
Bruno Windels
5656059342 server selection WIP 2020-11-25 18:18:20 +01:00
Bruno Windels
75d7cfa2a9 make text buttons looks exactly like links, also bigger click area 2020-11-24 18:55:57 +01:00
Bruno Windels
7a79e68d5e Merge branch 'main' into jryans/ux-refresh 2020-11-24 13:06:29 +01:00
J. Ryan Stinnett
0a3b8c2123 Add new consent prompt to homeserver options as first step
This adds the new style consent text defaulting to matrix.org alongside the
older style. More work is still needed here to polish this and then remove the
older one.
2020-11-18 10:02:27 +00:00
9 changed files with 197 additions and 130 deletions

View file

@ -16,6 +16,7 @@ limitations under the License.
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import GlobalContext from './contexts/GlobalContext';
import SingleColumn from './layouts/SingleColumn'; import SingleColumn from './layouts/SingleColumn';
import CreateLinkTile from './components/CreateLinkTile'; import CreateLinkTile from './components/CreateLinkTile';
import MatrixTile from './components/MatrixTile'; import MatrixTile from './components/MatrixTile';
@ -24,8 +25,6 @@ import LinkRouter from './pages/LinkRouter';
import './App.scss'; import './App.scss';
import GlobalContext from './contexts/GlobalContext';
/* eslint-disable no-restricted-globals */ /* eslint-disable no-restricted-globals */
const App: React.FC = () => { const App: React.FC = () => {

View file

@ -17,6 +17,16 @@ limitations under the License.
@import '../color-scheme'; @import '../color-scheme';
.homeserverOptions { .homeserverOptions {
.actions {
display: flex;
width: 100%;
margin-top: 2em;
> * {
flex: 1;
}
}
display: grid; display: grid;
row-gap: 20px; row-gap: 20px;
@ -50,8 +60,16 @@ limitations under the License.
} }
} }
form {
display: grid; .serverChoices {
row-gap: 25px; display: flex;
flex-direction: column;
}
.serverChoices label {
display: flex;
gap: 10px;
margin: 8px 0;
align-items: center;
} }
} }

View file

@ -15,16 +15,18 @@ limitations under the License.
*/ */
import React, { useContext, useState } from 'react'; import React, { useContext, useState } from 'react';
import { Formik, Form } from 'formik'; import { Formik, Form, Field } from 'formik';
import { string } from 'zod'; import { string } from 'zod';
import Tile from './Tile'; import Tile from './Tile';
import HSContext, { TempHSContext, ActionType } from '../contexts/HSContext'; import HSContext, { TempHSContext, ActionType } from '../contexts/HSContext';
import icon from '../imgs/telecom-mast.svg'; import icon from '../imgs/telecom-mast.svg';
import Button from './Button'; import Button from './Button';
import TextButton from './TextButton';
import Input from './Input'; import Input from './Input';
import StyledCheckbox from './StyledCheckbox'; import StyledCheckbox from './StyledCheckbox';
import { SafeLink } from '../parser/types'; import { SafeLink } from '../parser/types';
import { getHSFromIdentifier } from "../utils/getHS";
import './HomeserverOptions.scss'; import './HomeserverOptions.scss';
@ -34,95 +36,119 @@ interface IProps {
interface FormValues { interface FormValues {
HSUrl: string; HSUrl: string;
HSOtherUrl: string;
}
interface SubmitEvent extends Event {
submitter: HTMLFormElement;
} }
function validateURL(values: FormValues): Partial<FormValues> { function validateURL(values: FormValues): Partial<FormValues> {
const errors: Partial<FormValues> = {}; const errors: Partial<FormValues> = {};
if (values.HSUrl === "other") {
try { try {
string().url().parse(values.HSUrl); string().url().parse(hsToURL(values.HSOtherUrl));
} catch { } catch {
errors.HSUrl = errors.HSOtherUrl =
'This must be a valid homeserver URL, starting with https://'; 'This must be a valid homeserver URL';
}
} }
return errors; 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 HomeserverOptions: React.FC<IProps> = ({ link }: IProps) => {
const HSStateDispatcher = useContext(HSContext)[1]; const HSStateDispatcher = useContext(HSContext)[1];
const TempHSStateDispatcher = useContext(TempHSContext)[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 // Select which disaptcher to use based on whether we're writing
// the choice to localstorage // the choice to localstorage
const dispatcher = rememberSelection const dispatcher = askEveryTime
? HSStateDispatcher ? TempHSStateDispatcher
: TempHSStateDispatcher; : HSStateDispatcher;
const hsInput = ( let topSection;
<Formik const identifierHS = getHSFromIdentifier(link.identifier) || "";
initialValues={{ const homeservers = [identifierHS, ... link.arguments.vias];
HSUrl: '',
}} const chosenHS = "home.server";
validate={validateURL} const continueWithout = <TextButton onClick={(e): void => dispatcher({ action: ActionType.SetNone})}>continue without a preview</TextButton>;
onSubmit={({ HSUrl }): void => let instructions;
dispatcher({ action: ActionType.SetHS, HSURL: HSUrl }) 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>
} }
>
{({ 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>
);
return ( return (
<Tile className="homeserverOptions"> <Tile className="homeserverOptions">
<div className="homeserverOptionsDescription"> {instructions}
<div> <Formik
<h3> initialValues={{
About&nbsp; HSUrl: identifierHS,
<span className="matrixIdentifier"> HSOtherUrl: '',
{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 });
}} }}
> validate={validateURL}
Use any homeserver onSubmit={(values): void => {
</Button> dispatcher({ action: ActionType.SetHS, HSURL: getHSDomain(getChosenHS(values)) || "" });
{hsInput} }}>
{({ 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> </Tile>
); );
}; };

View file

@ -23,6 +23,7 @@ import './Input.scss';
interface IProps extends React.InputHTMLAttributes<HTMLElement> { interface IProps extends React.InputHTMLAttributes<HTMLElement> {
name: string; name: string;
type: string; type: string;
required?: boolean;
muted?: boolean; muted?: boolean;
} }

View file

@ -22,8 +22,9 @@ limitations under the License.
color: $link; color: $link;
font-style: normal; font-style: normal;
font-weight: normal; font-weight: normal;
font-size: 14px; font-size: inherit;
line-height: 24px; padding: 8px 0;
margin: -8px 0;
&:hover { &:hover {
cursor: pointer; cursor: pointer;

View file

@ -25,6 +25,8 @@ export enum HSOptions {
Unset = 'UNSET', Unset = 'UNSET',
// Matrix.to should only contact a single provided homeserver // Matrix.to should only contact a single provided homeserver
TrustedHSOnly = 'TRUSTED_CLIENT_ONLY', TrustedHSOnly = 'TRUSTED_CLIENT_ONLY',
// Matrix.to may not contact any homeserver
None = 'NONE',
// Matrix.to may contact any homeserver it requires // Matrix.to may contact any homeserver it requires
Any = 'ANY', Any = 'ANY',
} }
@ -33,6 +35,9 @@ const STATE_SCHEMA = union([
object({ object({
option: literal(HSOptions.Unset), option: literal(HSOptions.Unset),
}), }),
object({
option: literal(HSOptions.None),
}),
object({ object({
option: literal(HSOptions.Any), option: literal(HSOptions.Any),
}), }),
@ -48,6 +53,7 @@ export type State = TypeOf<typeof STATE_SCHEMA>;
export enum ActionType { export enum ActionType {
SetHS = 'SET_HS', SetHS = 'SET_HS',
SetAny = 'SET_ANY', SetAny = 'SET_ANY',
SetNone = 'SET_NONE',
Clear = 'CLEAR', Clear = 'CLEAR',
} }
@ -56,6 +62,10 @@ export interface SetHS {
HSURL: string; HSURL: string;
} }
export interface SetNone {
action: ActionType.SetNone;
}
export interface SetAny { export interface SetAny {
action: ActionType.SetAny; action: ActionType.SetAny;
} }
@ -64,7 +74,7 @@ export interface Clear {
action: ActionType.Clear; action: ActionType.Clear;
} }
export type Action = SetHS | SetAny | Clear; export type Action = SetHS | SetAny | SetNone | Clear;
export const INITIAL_STATE: State = { export const INITIAL_STATE: State = {
option: HSOptions.Unset, option: HSOptions.Unset,
@ -76,6 +86,10 @@ export const unpersistedReducer = (_state: State, action: Action): State => {
return { return {
option: HSOptions.Any, option: HSOptions.Any,
}; };
case ActionType.SetNone:
return {
option: HSOptions.None,
};
case ActionType.SetHS: case ActionType.SetHS:
return { return {
option: HSOptions.TrustedHSOnly, option: HSOptions.TrustedHSOnly,

View file

@ -30,18 +30,21 @@ body,
#root { #root {
height: 100%; height: 100%;
width: 100%; width: 100%;
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
font-style: normal;
font-size: 14px; font-size: 14px;
line-height: 24px; line-height: 24px;
color: $font; color: $font;
overflow: auto; overflow: auto;
} }
html,
body,
#root,
button.textButton {
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
font-style: normal;
}
h1 { h1 {
font-weight: bold; font-weight: bold;
font-size: 24px; font-size: 24px;

View file

@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License. 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 Tile from '../components/Tile';
import LinkPreview from '../components/LinkPreview'; import LinkPreview from '../components/LinkPreview';
import InvitingClientTile from '../components/InvitingClientTile'; import InvitingClientTile from '../components/InvitingClientTile';
import { parseHash } from '../parser/parser'; import { parseHash } from '../parser/parser';
import { LinkKind } from '../parser/types'; import { LinkKind } from '../parser/types';
import HomeserverOptions from '../components/HomeserverOptions';
/* eslint-disable no-restricted-globals */ /* eslint-disable no-restricted-globals */
@ -31,16 +33,13 @@ interface IProps {
const LinkRouter: React.FC<IProps> = ({ link }: IProps) => { const LinkRouter: React.FC<IProps> = ({ link }: IProps) => {
// our room id's will be stored in the hash // our room id's will be stored in the hash
const parsedLink = parseHash(link); const parsedLink = parseHash(link);
const [hsState] = useContext(HSContext);
let feedback: JSX.Element; if (parsedLink.kind === LinkKind.ParseFailed) {
let client: JSX.Element = <></>; return (
switch (parsedLink.kind) {
case LinkKind.ParseFailed:
feedback = (
<Tile> <Tile>
<p> <p>
That URL doesn't seem right. Links should be in the That URL doesn't seem right. Links should be in the format:
format:
</p> </p>
<br /> <br />
<p> <p>
@ -48,25 +47,25 @@ const LinkRouter: React.FC<IProps> = ({ link }: IProps) => {
</p> </p>
</Tile> </Tile>
); );
break; }
default:
if (hsState.option === HSOptions.Unset) {
return <HomeserverOptions link={parsedLink} />;
}
let client: JSX.Element = <></>;
if (parsedLink.arguments.client) { if (parsedLink.arguments.client) {
client = ( client = (
<InvitingClientTile <InvitingClientTile clientName={parsedLink.arguments.client} />
clientName={parsedLink.arguments.client}
/>
); );
} }
feedback = ( return (
<> <>
<LinkPreview link={parsedLink} /> <LinkPreview link={parsedLink} />
{client} {client}
</> </>
); );
}
return feedback;
}; };
export default LinkRouter; export default LinkRouter;

View file

@ -22,6 +22,19 @@ import HSContext, {
} from '../contexts/HSContext'; } from '../contexts/HSContext';
import { SafeLink } from '../parser/types'; 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 }: { function selectedClient({ link, identifier, hsOptions }: {
link?: SafeLink, link?: SafeLink,
identifier?: string; identifier?: string;
@ -35,23 +48,16 @@ function selectedClient({ link, identifier, hsOptions }: {
] : []; ] : [];
const identifierHS: string[] = []; const identifierHS: string[] = [];
try {
if (identifier) { if (identifier) {
const match = identifier.match(/^.*:(?<server>.*)$/); const server = getHSFromIdentifier(identifier);
if (match && match.groups) {
const server = match.groups.server;
if (server) { if (server) {
identifierHS.push(server); identifierHS.push(server);
} }
} }
}
} catch (e) {
console.error(`Could parse user identifier: ${identifier}`);
console.error(e);
}
switch (hsOptions.option) { switch (hsOptions.option) {
case HSOptions.Unset: case HSOptions.Unset:
case HSOptions.None:
return []; return [];
case HSOptions.TrustedHSOnly: case HSOptions.TrustedHSOnly:
return [hsOptions.hs]; return [hsOptions.hs];