Merge pull request #110 from matrix-org/matrixtwo/designreview

Implement design review changes
This commit is contained in:
Jorik Schellekens 2020-09-16 14:58:50 +01:00 committed by GitHub
commit 47ffc841f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
65 changed files with 1507 additions and 210 deletions

View File

@ -6,7 +6,6 @@
"@quentin-sommer/react-useragent": "^3.1.0",
"classnames": "^2.2.6",
"formik": "^2.1.4",
"matrix-cypher": "^0.1.12",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-scripts": "3.4.1",

View File

@ -20,6 +20,7 @@ limitations under the License.
background-color: $app-background;
background-image: url('./imgs/background.svg');
background-repeat: no-repeat;
background-size: stretch;
background-position: 50% -20%;
}
@ -32,7 +33,7 @@ limitations under the License.
.topSpacer {
@include spacer;
height: 20vh;
height: 10vh;
}
.bottomSpacer {

View File

@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { useState, useEffect } 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 Footer from './components/Footer';
import './App.scss';
@ -32,13 +33,25 @@ const App: React.FC = () => {
let page = (
<>
<CreateLinkTile />
<hr />
</>
);
if (location.hash) {
if (location.hash.startsWith('#/')) {
page = <LinkRouter link={location.hash.slice(2)} />;
const [hash, setHash] = useState(location.hash);
console.log(hash);
useEffect(() => {
// Some hacky uri decoding
if (location.href.split('/').length > 4) {
location.href = decodeURIComponent(location.href);
}
window.onhashchange = () => setHash(location.hash);
console.log('why');
}, []);
if (hash) {
if (hash.startsWith('#/')) {
page = <LinkRouter link={hash.slice(2)} />;
} else {
page = (
<Tile>
@ -50,12 +63,18 @@ const App: React.FC = () => {
}
return (
<GlobalContext>
<SingleColumn>
<div className="topSpacer" />
<GlobalContext>{page}</GlobalContext>
<MatrixTile />
{page}
<div>
<MatrixTile isLink={!!location.hash} />
<br />
<Footer />
</div>
<div className="bottomSpacer" />
</SingleColumn>
</GlobalContext>
);
};

View File

@ -56,6 +56,7 @@ const Element: LinkedClient = {
);
}
},
linkSupport: () => true,
};
export const ElementDevelop: LinkedClient = {
@ -90,5 +91,6 @@ export const ElementDevelop: LinkedClient = {
);
}
},
linkSupport: () => true,
};
export default Element;

69
src/clients/Fractal.tsx Normal file
View File

@ -0,0 +1,69 @@
/*
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 { TextClient, Maturity, ClientKind, ClientId, Platform } from './types';
import { LinkKind } from '../parser/types';
import logo from '../imgs/fractal.png';
const Fractal: TextClient = {
kind: ClientKind.TEXT_CLIENT,
name: 'Fractal',
logo: logo,
author: 'Daniel Garcia Moreno',
homepage: 'https://github.com/poljar/weechat-matrix',
maturity: Maturity.BETA,
experimental: false,
platform: Platform.Desktop,
clientId: ClientId.Fractal,
toInviteString: (link) => {
switch (link.kind) {
case LinkKind.Alias:
case LinkKind.RoomId:
case LinkKind.UserId:
return <span>Click the '+' button in the top right</span>;
default:
return <span>Weechat doesn't support this kind of link</span>;
}
},
copyString: (link) => {
switch (link.kind) {
case LinkKind.Alias:
case LinkKind.RoomId:
case LinkKind.UserId:
return `${link.identifier}`;
default:
return '';
}
},
linkSupport: (link) => {
switch (link.kind) {
case LinkKind.Alias:
case LinkKind.RoomId:
case LinkKind.UserId:
return true;
default:
return false;
}
},
description: 'Command-line Matrix interface using Weechat',
};
export default Fractal;

85
src/clients/Nheko.tsx Normal file
View File

@ -0,0 +1,85 @@
/*
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 { TextClient, Maturity, ClientKind, ClientId, Platform } from './types';
import { LinkKind } from '../parser/types';
import logo from '../imgs/nheko.svg';
const Nheko: TextClient = {
kind: ClientKind.TEXT_CLIENT,
name: 'Nheko',
logo: logo,
author: 'mujx, red_sky, deepbluev7, Konstantinos Sideris',
homepage: 'https://github.com/Nheko-Reborn/nheko',
maturity: Maturity.BETA,
experimental: false,
platform: Platform.Desktop,
clientId: ClientId.Nheko,
toInviteString: (link) => {
switch (link.kind) {
case LinkKind.Alias:
case LinkKind.RoomId:
return (
<span>
Type{' '}
<code>
/join <b>{link.identifier}</b>
</code>
</span>
);
case LinkKind.UserId:
return (
<span>
Type{' '}
<code>
/invite <b>{link.identifier}</b>
</code>
</span>
);
default:
return <span>Nheko doesn't support this kind of link</span>;
}
},
copyString: (link) => {
switch (link.kind) {
case LinkKind.Alias:
case LinkKind.RoomId:
return `/join ${link.identifier}`;
case LinkKind.UserId:
return `/invite ${link.identifier}`;
default:
return '';
}
},
linkSupport: (link) => {
switch (link.kind) {
case LinkKind.Alias:
case LinkKind.RoomId:
case LinkKind.UserId:
return true;
default:
return false;
}
},
description:
'A native desktop app for Matrix that feels more like a mainstream chat app.',
};
export default Nheko;

View File

@ -68,6 +68,17 @@ const Weechat: TextClient = {
return '';
}
},
linkSupport: (link) => {
switch (link.kind) {
case LinkKind.Alias:
case LinkKind.RoomId:
case LinkKind.UserId:
return true;
default:
return false;
}
},
description: 'Command-line Matrix interface using Weechat',
};

View File

@ -18,11 +18,13 @@ import { Client } from './types';
import Element, { ElementDevelop } from './Element.io';
import Weechat from './Weechat';
import Nheko from './Nheko';
import Fractal from './Fractal';
/*
* All the supported clients of matrix.to
*/
const clients: Client[] = [Element, Weechat, ElementDevelop];
const clients: Client[] = [Element, Weechat, Nheko, Fractal, ElementDevelop];
/*
* A map from sharer string to client.
@ -33,6 +35,8 @@ export const clientMap: { [key: string]: Client } = {
[Element.clientId]: Element,
[Weechat.clientId]: Weechat,
[ElementDevelop.clientId]: ElementDevelop,
[Nheko.clientId]: Nheko,
[Fractal.clientId]: Fractal,
};
/*

View File

@ -49,6 +49,8 @@ export enum ClientId {
Element = 'element.io',
ElementDevelop = 'develop.element.io',
WeeChat = 'weechat',
Nheko = 'nheko',
Fractal = 'fractal',
}
/*
@ -64,6 +66,7 @@ export interface ClientDescription {
maturity: Maturity;
clientId: ClientId;
experimental: boolean;
linkSupport: (link: SafeLink) => boolean;
}
/*

View File

@ -14,11 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
@import "../color-scheme";
@import '../color-scheme';
.avatar {
border-radius: 100%;
border: 1px solid $borders;
height: 50px;
width: 50px;
height: 60px;
width: 60px;
}
.avatarNoCrop {
border-radius: 0;
}

View File

@ -16,10 +16,10 @@ limitations under the License.
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import { Room, User } from 'matrix-cypher';
import { Room, User } from '../matrix-cypher';
import { getMediaQueryFromMCX } from '../utils/cypher-wrapper';
import logo from '../imgs/matrix-logo.svg';
import logo from '../imgs/chat-icon.svg';
import './Avatar.scss';

View File

@ -14,12 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
@import "../color-scheme";
@import '../color-scheme';
.button {
width: 100%;
padding: 1rem;
height: 48px;
border-radius: 2rem;
border: 0;
@ -28,6 +29,31 @@ limitations under the License.
font-size: 14px;
font-weight: 500;
&:hover {
cursor: pointer;
}
position: relative;
.buttonIcon {
position: absolute;
height: 24px;
width: 24px;
left: 18px;
top: 12px;
}
}
.buttonSecondary {
background-color: $background;
color: $foreground;
border: 1px solid $foreground;
}
.errorButton:hover {
cursor: not-allowed;
}
.buttonHighlight {

View File

@ -27,3 +27,7 @@ export const WithText: React.FC = () => (
{text('label', 'Hello Story Book')}
</Button>
);
export const Secondary: React.FC = () => (
<Button secondary>Secondary button</Button>
);

View File

@ -22,6 +22,9 @@ import './Button.scss';
interface IProps extends React.ButtonHTMLAttributes<Element> {
// Briefly display these instead of the children onClick
flashChildren?: React.ReactNode;
secondary?: boolean;
icon?: string;
flashIcon?: string;
}
/**
@ -31,7 +34,16 @@ const Button: React.FC<
IProps & React.RefAttributes<HTMLButtonElement>
> = React.forwardRef(
(
{ onClick, children, flashChildren, className, ...props }: IProps,
{
onClick,
children,
flashChildren,
className,
secondary,
icon,
flashIcon,
...props
}: IProps,
ref: React.Ref<HTMLButtonElement>
) => {
const [wasClicked, setWasClicked] = React.useState(false);
@ -51,8 +63,15 @@ const Button: React.FC<
const classNames = classnames('button', className, {
buttonHighlight: wasClicked,
buttonSecondary: secondary,
});
const iconSrc = wasClicked && flashIcon ? flashIcon : icon;
const buttonIcon = icon ? (
<img className="buttonIcon" src={iconSrc} alt="" />
) : null;
return (
<button
className={classNames}
@ -60,6 +79,7 @@ const Button: React.FC<
ref={ref}
{...props}
>
{buttonIcon}
{content}
</button>
);

View File

@ -63,6 +63,10 @@ const ClientList: React.FC<IProps> = ({ link, rememberSelection }: IProps) => {
showClient = false;
}
if (!client.linkSupport(link)) {
showClient = false;
}
return showClient;
};

View File

@ -38,7 +38,7 @@ const ClientSelection: React.FC<IProps> = ({ link }: IProps) => {
}}
checked={rememberSelection}
>
Remember my selection for future invites in this browser
Remember choice for future invites in this browser
</StyledCheckbox>
<StyledCheckbox
onChange={(): void => {
@ -79,7 +79,6 @@ const ClientSelection: React.FC<IProps> = ({ link }: IProps) => {
return (
<div className="advanced">
{options}
<h4>Clients you can accept this invite with</h4>
<ClientList link={link} rememberSelection={rememberSelection} />
{clearSelection}
</div>

View File

@ -19,7 +19,7 @@ limitations under the License.
.clientTile {
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-start;
min-height: 150px;
width: 100%;
@ -28,7 +28,10 @@ limitations under the License.
> img {
flex-shrink: 0;
height: 130px;
height: 116px;
width: 116px;
margin-right: 14px;
border-radius: 16px;
}
> div {
@ -42,16 +45,17 @@ limitations under the License.
}
p {
margin-right: 20px;
margin-right: 8px;
text-align: left;
}
.button {
margin: 5px;
height: 40px;
width: 130px;
margin-top: 16px;
}
}
border: 1px solid $borders;
border-radius: 8px;
padding: 15px;
@ -59,8 +63,8 @@ limitations under the License.
// For the chevron
position: relative;
&::hover {
background-color: $grey;
&:hover {
background-color: $app-background;
}
}
@ -68,12 +72,4 @@ limitations under the License.
position: relative;
width: 100%;
&::after {
// TODO: add chevron top right
position: absolute;
right: 10px;
top: 5px;
content: '>';
}
}

View File

@ -29,7 +29,6 @@ limitations under the License.
display: grid;
row-gap: 24px;
align-self: center;
padding: 0 30px;
}
> a {
@ -39,4 +38,56 @@ limitations under the License.
h1 {
word-break: break-all;
}
.createLinkReset {
height: 40px;
width: 40px;
border-radius: 100%;
border: 1px solid lighten($grey, 50%);
background: $background;
padding: 6px;
position: relative;
> div {
// This is a terrible case of faking it till
// we make it. It will break. I'm so sorry
position: absolute;
display: none;
width: max-content;
top: -35px;
left: -17px;
border-radius: 30px;
padding: 5px 15px;
background: $background;
word-wrap: none;
}
img {
height: 100%;
width: 100%;
border: 0;
filter: invert(12%);
}
&:hover {
border: 0;
background: $foreground;
cursor: pointer;
> div {
display: block;
}
}
}
}

View File

@ -19,11 +19,13 @@ import { Formik, Form } from 'formik';
import Tile from './Tile';
import Button from './Button';
import TextButton from './TextButton';
import Input from './Input';
import { parseHash } from '../parser/parser';
import { LinkKind } from '../parser/types';
import linkIcon from '../imgs/link.svg';
import copyIcon from '../imgs/copy.svg';
import tickIcon from '../imgs/tick.svg';
import refreshIcon from '../imgs/refresh.svg';
import './CreateLinkTile.scss';
interface ILinkNotCreatedTileProps {
@ -38,11 +40,16 @@ interface FormValues {
function validate(values: FormValues): Partial<FormValues> {
const errors: Partial<FormValues> = {};
if (values.identifier === '') {
errors.identifier = '';
return errors;
}
const parse = parseHash(values.identifier);
if (parse.kind === LinkKind.ParseFailed) {
errors.identifier =
"That link doesn't look right. Double check the details.";
"That identifier doesn't look right. Double check the details.";
}
return errors;
@ -71,15 +78,28 @@ const LinkNotCreatedTile: React.FC<ILinkNotCreatedTileProps> = (
values.identifier
);
}}
validateOnChange={false}
>
{(formik): JSX.Element => (
<Form>
<Input
name={'identifier'}
type={'text'}
placeholder="#room:example.com, @user:example.com"
autoFocus
/>
<Button type="submit">Get Link</Button>
<Button
type="submit"
icon={linkIcon}
disabled={!!formik.errors.identifier}
className={
formik.errors.identifier ? 'errorButton' : ''
}
>
Create Link
</Button>
</Form>
)}
</Formik>
</Tile>
);
@ -102,14 +122,20 @@ const LinkCreatedTile: React.FC<ILinkCreatedTileProps> = (props) => {
return (
<Tile className="createLinkTile">
<TextButton onClick={(): void => props.setLink('')}>
Create another link
</TextButton>
<button
className="createLinkReset"
onClick={(): void => props.setLink('')}
>
<div>New link</div>
<img src={refreshIcon} />
</button>
<a href={props.link}>
<h1>{props.link}</h1>
</a>
<Button
flashChildren={'Copied'}
icon={copyIcon}
flashIcon={tickIcon}
onClick={(): void => {
navigator.clipboard.writeText(props.link);
}}

View File

@ -19,4 +19,8 @@ limitations under the License.
border-radius: 0;
border: 0;
}
h1 {
word-break: break-all;
}
}

View File

@ -15,7 +15,7 @@ limitations under the License.
*/
import React from 'react';
import { Room, Event } from 'matrix-cypher';
import { Room, Event } from '../matrix-cypher';
import RoomPreview from './RoomPreview';

View File

@ -0,0 +1,33 @@
/*
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 '../color-scheme';
.footer {
display: grid;
grid-auto-flow: column;
justify-content: center;
column-gap: 5px;
* {
color: $font;
}
.textButton {
margin: 0;
padding: 0;
}
}

65
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,65 @@
/*
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, { useContext } from 'react';
import HSContext, {
HSOptions,
ActionType as HSACtionType,
} from '../contexts/HSContext';
import ClientContext, {
ActionType as ClientActionType,
} from '../contexts/ClientContext';
import TextButton from './TextButton';
import './Footer.scss';
const Footer: React.FC = () => {
const [hsState, hsDispatch] = useContext(HSContext);
const [clientState, clientDispatch] = useContext(ClientContext);
const clear =
hsState.option !== HSOptions.Unset || clientState.clientId !== null ? (
<>
{' · '}
<TextButton
onClick={(): void => {
hsDispatch({
action: HSACtionType.Clear,
});
clientDispatch({
action: ClientActionType.ClearClient,
});
}}
>
Clear preferences
</TextButton>
</>
) : null;
return (
<div className="footer">
<a href="https://github.com/matrix-org/matrix.to">GitHub</a>
{' · '}
<a href="https://github.com/matrix-org/matrix.to/tree/matrix-two/src/clients">
Add your client
</a>
{clear}
</div>
);
};
export default Footer;

View File

@ -46,6 +46,7 @@ limitations under the License.
width: 62px;
padding: 11px;
border-radius: 100%;
margin-left: 14px;
}
}

View File

@ -17,6 +17,7 @@ limitations under the License.
import React from 'react';
import HomeserverOptions from './HomeserverOptions';
import { LinkKind } from '../parser/types';
export default {
title: 'HomeserverOptions',
@ -29,4 +30,13 @@ export default {
},
};
export const Default: React.FC = () => <HomeserverOptions />;
export const Default: React.FC = () => (
<HomeserverOptions
link={{
identifier: '#banter:matrix.org',
arguments: { vias: [] },
kind: LinkKind.Alias,
originalLink: 'This is all made up',
}}
/>
);

View File

@ -23,12 +23,14 @@ import HSContext, { TempHSContext, ActionType } from '../contexts/HSContext';
import icon from '../imgs/telecom-mast.svg';
import Button from './Button';
import Input from './Input';
import Toggle from './Toggle';
import StyledCheckbox from './StyledCheckbox';
import { SafeLink } from '../parser/types';
import './HomeserverOptions.scss';
interface IProps {}
interface IProps {
link: SafeLink;
}
interface FormValues {
HSUrl: string;
@ -39,21 +41,25 @@ function validateURL(values: FormValues): Partial<FormValues> {
try {
string().url().parse(values.HSUrl);
} catch {
errors.HSUrl = 'This must be a valid url';
errors.HSUrl =
'This must be a valid homeserver URL, starting with https://';
}
return errors;
}
const HomeserverOptions: React.FC<IProps> = () => {
const HomeserverOptions: React.FC<IProps> = ({ link }: IProps) => {
const HSStateDispatcher = useContext(HSContext)[1];
const TempHSStateDispatcher = useContext(TempHSContext)[1];
const [rememberSelection, setRemeberSelection] = useState(false);
const [usePrefered, setUsePrefered] = useState(false);
// Select which disaptcher to use based on whether we're writing
// the choice to localstorage
const dispatcher = rememberSelection
? HSStateDispatcher
: TempHSStateDispatcher;
const hsInput = usePrefered ? (
const hsInput = (
<Formik
initialValues={{
HSUrl: '',
@ -63,23 +69,33 @@ const HomeserverOptions: React.FC<IProps> = () => {
dispatcher({ action: ActionType.SetHS, HSURL: HSUrl })
}
>
{({ values, errors }): JSX.Element => (
<Form>
<Input
muted={!values.HSUrl}
type="text"
name="HSUrl"
placeholder="https://example.com"
placeholder="Preferred homeserver URL"
/>
<Button type="submit">Set HS</Button>
{values.HSUrl && !errors.HSUrl ? (
<Button secondary type="submit">
Use {values.HSUrl}
</Button>
) : null}
</Form>
)}
</Formik>
) : null;
);
return (
<Tile className="homeserverOptions">
<div className="homeserverOptionsDescription">
<div>
<h3>About {link.identifier}</h3>
<p>
Let's locate a homeserver to show you more information.
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
@ -94,18 +110,13 @@ const HomeserverOptions: React.FC<IProps> = () => {
Remember my choice
</StyledCheckbox>
<Button
secondary
onClick={(): void => {
dispatcher({ action: ActionType.SetAny });
}}
>
Use any homeserver
</Button>
<Toggle
checked={usePrefered}
onChange={(): void => setUsePrefered(!usePrefered)}
>
Use my preferred homeserver only
</Toggle>
{hsInput}
</Tile>
);

View File

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
@import "../color-scheme";
@import "../error";
@import '../color-scheme';
@import '../error';
.input {
width: 100%;
@ -23,7 +23,8 @@ limitations under the License.
background: $background;
border: 1px solid $font;
border: 1px solid $foreground;
font: lighten($grey, 60%);
border-radius: 24px;
font-size: 14px;
@ -32,9 +33,18 @@ limitations under the License.
&.error {
@include error;
}
&:focus {
border: 1px solid $font;
font: $font;
}
}
.inputError {
@include error;
text-align: center;
}
.inputMuted {
border-color: lighten($grey, 60%);
}

View File

@ -20,21 +20,23 @@ import { useField } from 'formik';
import './Input.scss';
interface IProps extends React.InputHTMLAttributes<Element> {
interface IProps extends React.InputHTMLAttributes<HTMLElement> {
name: string;
type: string;
muted?: boolean;
}
const Input: React.FC<IProps> = ({ className, ...props }) => {
const Input: React.FC<IProps> = ({ className, muted, ...props }) => {
const [field, meta] = useField(props);
const error =
meta.touched && meta.error ? (
const errorBool = meta.touched && meta.value !== '' && meta.error;
const error = errorBool ? (
<div className="inputError">{meta.error}</div>
) : null;
const classNames = classnames('input', className, {
error: meta.error,
error: errorBool,
inputMuted: !!muted,
});
return (

View File

@ -14,15 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
@import '../color-scheme';
.inviteTile {
display: grid;
row-gap: 24px;
.inviteTileClientSelection {
margin: 0 5%;
margin: 0 auto;
display: grid;
justify-content: space-between;
row-gap: 20px;
h2 + p {
color: $foreground;
}
}
hr {
width: 100%;
margin: 0;
}
}

View File

@ -25,7 +25,6 @@ import ClientSelection from './ClientSelection';
import { Client, ClientKind } from '../clients/types';
import { SafeLink } from '../parser/types';
import TextButton from './TextButton';
import FakeProgress from './FakeProgress';
interface IProps {
children?: React.ReactNode;
@ -39,10 +38,8 @@ const InviteTile: React.FC<IProps> = ({ children, client, link }: IProps) => {
let advanced: React.ReactNode;
if (client === null) {
invite = showAdvanced ? (
<FakeProgress />
) : (
<Button onClick={() => setShowAdvanced(!showAdvanced)}>
invite = showAdvanced ? null : (
<Button onClick={(): void => setShowAdvanced(!showAdvanced)}>
Accept invite
</Button>
);
@ -89,7 +86,9 @@ const InviteTile: React.FC<IProps> = ({ children, client, link }: IProps) => {
if (client === null) {
advanced = (
<>
<h4>Pick an app to accept the invite with</h4>
<hr />
<h2>Almost done!</h2>
<p>Great, pick a client below to confirm and continue</p>
<ClientSelection link={link} />
</>
);
@ -104,12 +103,15 @@ const InviteTile: React.FC<IProps> = ({ children, client, link }: IProps) => {
}
}
advanced = advanced ? (
<div className="inviteTileClientSelection">{advanced}</div>
) : null;
return (
<>
<Tile className="inviteTile">
{children}
{invite}
<div className="inviteTileClientSelection">{advanced}</div>
{advanced}
</Tile>
</>
);

View File

@ -15,15 +15,16 @@ limitations under the License.
*/
import React, { useState, useEffect, useContext } from 'react';
import { getEvent, client } from 'matrix-cypher';
import { getEvent, client } from '../matrix-cypher';
import { RoomPreviewWithTopic } from './RoomPreview';
import InviteTile from './InviteTile';
import { SafeLink, LinkKind } from '../parser/types';
import UserPreview from './UserPreview';
import UserPreview, { WrappedInviterPreview } from './UserPreview';
import EventPreview from './EventPreview';
import HomeserverOptions from './HomeserverOptions';
import DefaultPreview from './DefaultPreview';
import Toggle from './Toggle';
import { clientMap } from '../clients';
import {
getRoomFromId,
@ -32,12 +33,7 @@ import {
getUser,
} from '../utils/cypher-wrapper';
import { ClientContext } from '../contexts/ClientContext';
import HSContext, {
TempHSContext,
HSOptions,
State as HSState,
} from '../contexts/HSContext';
import Toggle from './Toggle';
import useHSs from '../utils/getHS';
interface IProps {
link: SafeLink;
@ -51,13 +47,12 @@ const invite = async ({
link: SafeLink;
}): Promise<JSX.Element> => {
// TODO: replace with client fetch
const defaultClient = await client(clientAddress);
switch (link.kind) {
case LinkKind.Alias:
return (
<RoomPreviewWithTopic
room={
await getRoomFromAlias(defaultClient, link.identifier)
await getRoomFromAlias(clientAddress, link.identifier)
}
/>
);
@ -65,14 +60,14 @@ const invite = async ({
case LinkKind.RoomId:
return (
<RoomPreviewWithTopic
room={await getRoomFromId(defaultClient, link.identifier)}
room={await getRoomFromId(clientAddress, link.identifier)}
/>
);
case LinkKind.UserId:
return (
<UserPreview
user={await getUser(defaultClient, link.identifier)}
user={await getUser(clientAddress, link.identifier)}
userId={link.identifier}
/>
);
@ -80,10 +75,10 @@ const invite = async ({
case LinkKind.Permalink:
return (
<EventPreview
room={await getRoomFromPermalink(defaultClient, link)}
room={await getRoomFromPermalink(clientAddress, link)}
event={
await getEvent(
defaultClient,
await client(clientAddress),
link.roomLink,
link.eventId
)
@ -118,32 +113,13 @@ const Preview: React.FC<PreviewProps> = ({ link, client }: PreviewProps) => {
return content;
};
function selectedClient(link: SafeLink, hsOptions: HSState): string[] {
switch (hsOptions.option) {
case HSOptions.Unset:
return [];
case HSOptions.None:
return [];
case HSOptions.TrustedHSOnly:
return [hsOptions.hs];
case HSOptions.Any:
return [
'https://' + link.identifier.split(':')[1],
...link.arguments.vias,
];
}
}
const LinkPreview: React.FC<IProps> = ({ link }: IProps) => {
let content: JSX.Element;
const [showHSOptions, setShowHSOPtions] = useState(false);
const [hsOptions] = useContext(HSContext);
const [tempHSState] = useContext(TempHSContext);
if (
hsOptions.option === HSOptions.Unset &&
tempHSState.option === HSOptions.Unset
) {
const hses = useHSs(link);
if (!hses.length) {
content = (
<>
<DefaultPreview link={link} />
@ -151,7 +127,7 @@ const LinkPreview: React.FC<IProps> = ({ link }: IProps) => {
checked={showHSOptions}
onChange={(): void => setShowHSOPtions(!showHSOptions)}
>
Show more information
About {link.identifier}
</Toggle>
</>
);
@ -159,16 +135,12 @@ const LinkPreview: React.FC<IProps> = ({ link }: IProps) => {
content = (
<>
{content}
<HomeserverOptions />
<HomeserverOptions link={link} />
</>
);
}
} else {
const clients =
tempHSState.option !== HSOptions.Unset
? selectedClient(link, tempHSState)
: selectedClient(link, hsOptions);
content = <Preview link={link} client={clients[0]} />;
content = <Preview link={link} client={hses[0]} />;
}
const [{ clientId }] = useContext(ClientContext);
@ -182,8 +154,22 @@ const LinkPreview: React.FC<IProps> = ({ link }: IProps) => {
const client = displayClientId ? clientMap[displayClientId] : null;
const sharer = link.arguments.sharer ? (
<WrappedInviterPreview
link={{
kind: LinkKind.UserId,
identifier: link.arguments.sharer,
arguments: { vias: [] },
originalLink: '',
}}
/>
) : (
<p style={{ margin: '0 0 10px 0' }}>You're invited to join</p>
);
return (
<InviteTile client={client} link={link}>
{sharer}
{content}
</InviteTile>
);

View File

@ -21,15 +21,30 @@ import logo from '../imgs/matrix-logo.svg';
import './MatrixTile.scss';
const MatrixTile: React.FC = () => {
interface IProps {
isLink?: boolean;
}
const MatrixTile: React.FC<IProps> = ({ isLink }: IProps) => {
const copy = isLink ? (
<div>
This invite uses <a href="https://matrix.org">Matrix</a>, an open
network for secure, decentralized communication.
</div>
) : (
<div>
Matrix.to is a stateless URL redirecting service for the{' '}
<a href="https://matrix.org">Matrix</a> ecosystem.
</div>
);
return (
<div>
<Tile className="matrixTile">
<img src={logo} alt="matrix-logo" />
<div>
This invite uses <a href="https://matrix.org">Matrix</a>, an
open network for secure, decentralized communication.
</div>
{copy}
</Tile>
</div>
);
};

View File

@ -16,16 +16,15 @@ limitations under the License.
.roomPreview {
> .avatar {
margin-top: 20px;
margin-bottom: 16px;
margin-bottom: 8px;
}
> h1 {
font-size: 20px;
font-size: 24px;
margin-bottom: 4px;
}
}
.roomTopic {
padding-top: 32px;
padding-top: 8px;
}

View File

@ -15,7 +15,7 @@ limitations under the License.
*/
import React from 'react';
import { Room } from 'matrix-cypher';
import { Room } from '../matrix-cypher';
import { RoomAvatar } from './Avatar';
@ -31,11 +31,15 @@ const RoomPreview: React.FC<IProps> = ({ room }: IProps) => {
: room.aliases
? room.aliases[0]
: room.room_id;
const members =
room.num_joined_members > 0 ? (
<p>{room.num_joined_members.toLocaleString()} members</p>
) : null;
return (
<div className="roomPreview">
<RoomAvatar room={room} />
<h1>{room.name ? room.name : roomAlias}</h1>
<p>{room.num_joined_members.toLocaleString()} members</p>
{members}
<p>{roomAlias}</p>
</div>
);

View File

@ -32,7 +32,7 @@ const StyledCheckbox: React.FC<IProps> = ({
<input {...otherProps} type="checkbox" />
{/* Using the div to center the image */}
<div className="styledCheckboxWrapper">
<img src={tick} />
<img src={tick} alt="" />
</div>
{children}
</label>

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
@import "../color-scheme";
@import '../color-scheme';
.textButton {
background: none;
@ -24,4 +24,8 @@ limitations under the License.
font-weight: normal;
font-size: 14px;
line-height: 24px;
&:hover {
cursor: pointer;
}
}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
@import "../color-scheme";
@import '../color-scheme';
.tile {
background-color: $background;
@ -30,4 +30,5 @@ limitations under the License.
p {
color: $grey;
}
transition: width 2s, height 2s, transform 2s;
}

View File

@ -37,4 +37,8 @@ limitations under the License.
}
}
}
&:hover {
cursor: pointer;
}
}

View File

@ -21,14 +21,14 @@ import chevron from '../imgs/chevron-down.svg';
import './Toggle.scss';
interface IProps extends React.InputHTMLAttributes<Element> {
children?: React.ReactChild;
children?: React.ReactNode;
}
const Toggle: React.FC<IProps> = ({ children, ...props }: IProps) => (
<label className="toggle">
{children}
<input type="checkbox" {...props} />
<img src={chevron} />
<img src={chevron} alt="" />
</label>
);

View File

@ -14,18 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
@import "../color-scheme";
@import '../color-scheme';
.userPreview {
width: 100%;
> .avatar {
margin-top: 20px;
margin-bottom: 16px;
margin-bottom: 8px;
}
h1 {
font-size: 20px;
font-size: 24px;
margin-bottom: 4px;
}
@ -70,5 +69,17 @@ limitations under the License.
.avatar {
flex-grow: 0;
flex-shrink: 0;
height: 32px;
width: 32px;
}
&.centeredMiniUserPreview {
h1 {
width: unset;
text-align: center;
}
img {
display: none;
}
}
}

View File

@ -14,10 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { User } from 'matrix-cypher';
import React, { useState, useEffect } from 'react';
import { client, User, getUserDetails } from '../matrix-cypher';
import classNames from 'classnames';
import icon from '../imgs/chat-icon.svg';
import { UserAvatar } from './Avatar';
import Avatar, { UserAvatar } from './Avatar';
import useHSs from '../utils/getHS';
import { UserId } from '../parser/types';
import './UserPreview.scss';
@ -37,14 +41,57 @@ const UserPreview: React.FC<IProps> = ({ user, userId }: IProps) => (
export default UserPreview;
export const InviterPreview: React.FC<IProps> = ({ user, userId }: IProps) => (
<div className="miniUserPreview">
interface InviterPreviewProps {
user?: User;
userId: string;
}
export const InviterPreview: React.FC<InviterPreviewProps> = ({
user,
userId,
}: InviterPreviewProps) => {
const avatar = user ? (
<UserAvatar user={user} userId={userId} />
) : (
<Avatar
className="avatarNoCrop"
label={`Placeholder icon for ${userId}`}
avatarUrl={icon}
/>
);
const className = classNames('miniUserPreview', {
centeredMiniUserPreview: !user,
});
return (
<div className={className}>
<div>
<h1>
Invited by <b>{user.displayname}</b>
Invited by <b>{user ? user.displayname : userId}</b>
</h1>
<p>{userId}</p>
{user ? <p>{userId}</p> : null}
</div>
<UserAvatar user={user} userId={userId} />
{avatar}
</div>
);
);
};
interface WrappedInviterProps {
link: UserId;
}
export const WrappedInviterPreview: React.FC<WrappedInviterProps> = ({
link,
}: WrappedInviterProps) => {
const [user, setUser] = useState<User | undefined>(undefined);
const hss = useHSs(link);
useEffect(() => {
if (hss.length) {
client(hss[0])
.then((c) => getUserDetails(c, link.identifier))
.then(setUser)
.catch((x) => console.log("couldn't fetch user preview", x));
}
}, [hss, link]);
return <InviterPreview user={user} userId={link.identifier} />;
};

View File

@ -101,6 +101,8 @@ export const ClientContext = React.createContext<
[State, React.Dispatch<Action>]
>([initialState, (): void => {}]);
export default ClientContext;
// Quick rename to make importing easier
export const ClientProvider = ClientContext.Provider;
export const ClientConsumer = ClientContext.Consumer;

View File

@ -19,8 +19,6 @@ import { string, object, union, literal, TypeOf } from 'zod';
import { persistReducer } from '../utils/localStorage';
//import { prefixFetch, Client, discoverServer } from 'matrix-cypher';
export enum HSOptions {
// The homeserver contact policy hasn't
// been set yet.
@ -29,17 +27,12 @@ export enum HSOptions {
TrustedHSOnly = '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),
}),
@ -55,7 +48,7 @@ export type State = TypeOf<typeof STATE_SCHEMA>;
export enum ActionType {
SetHS = 'SET_HS',
SetAny = 'SET_ANY',
SetNone = 'SET_NONE',
Clear = 'CLEAR',
}
export interface SetHS {
@ -67,24 +60,18 @@ export interface SetAny {
action: ActionType.SetAny;
}
export interface SetNone {
action: ActionType.SetNone;
export interface Clear {
action: ActionType.Clear;
}
export type Action = SetHS | SetAny | SetNone;
export type Action = SetHS | SetAny | Clear;
export const INITIAL_STATE: State = {
option: HSOptions.Unset,
};
export const unpersistedReducer = (state: State, action: Action): State => {
console.log('reducing');
console.log(action);
export const unpersistedReducer = (_state: State, action: Action): State => {
switch (action.action) {
case ActionType.SetNone:
return {
option: HSOptions.None,
};
case ActionType.SetAny:
return {
option: HSOptions.Any,
@ -94,8 +81,10 @@ export const unpersistedReducer = (state: State, action: Action): State => {
option: HSOptions.TrustedHSOnly,
hs: action.HSURL,
};
default:
return state;
case ActionType.Clear:
return {
option: HSOptions.Unset,
};
}
};

4
src/imgs/copy.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.5 15H6C4.89543 15 4 14.1046 4 13V6C4 4.89543 4.89543 4 6 4H13C14.1046 4 15 4.89543 15 6V9.5" stroke="white" stroke-width="1.5"/>
<rect x="9" y="9" width="11" height="11" rx="2" stroke="white" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 328 B

BIN
src/imgs/fractal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

3
src/imgs/link.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5285 6.54089L13.0273 6.04207C14.4052 4.66426 16.6259 4.65104 17.9874 6.01253C19.349 7.37402 19.3357 9.59466 17.9579 10.9725L15.5878 13.3425C14.21 14.7203 11.9893 14.7335 10.6277 13.372M11.4717 17.4589L10.9727 17.9579C9.59481 19.3357 7.37409 19.349 6.01256 17.9875C4.65102 16.626 4.66426 14.4053 6.04211 13.0275L8.41203 10.6577C9.78988 9.27988 12.0106 9.26665 13.3721 10.6281" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 549 B

155
src/imgs/nheko.svg Normal file
View File

@ -0,0 +1,155 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="1024"
height="1024"
viewBox="0 0 270.93333 270.93333"
version="1.1"
id="svg8"
inkscape:version="0.92.4 5da689c313, 2019-01-14"
sodipodi:docname="nheko.svg"
inkscape:export-filename="/home/nicolas/Dokumente/devel/open-source/nheko/resources/nheko-rebuild-round-corners.svg.png"
inkscape:export-xdpi="130.048"
inkscape:export-ydpi="130.048">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35355339"
inkscape:cx="852.07808"
inkscape:cy="-60.410565"
inkscape:document-units="mm"
inkscape:current-layer="layer2"
showgrid="true"
inkscape:window-width="1920"
inkscape:window-height="1019"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
showguides="true"
inkscape:snap-grids="true"
gridtolerance="10"
inkscape:snap-bbox="false"
inkscape:bbox-paths="true"
inkscape:snap-global="true"
inkscape:bbox-nodes="true"
inkscape:lockguides="false"
units="px">
<sodipodi:guide
position="0,0"
orientation="0,793.70079"
id="guide4797"
inkscape:locked="false" />
<sodipodi:guide
position="0,297"
orientation="1122.5197,0"
id="guide4803"
inkscape:locked="false" />
<inkscape:grid
type="axonomgrid"
id="grid4805"
units="px"
empspacing="2"
snapvisiblegridlinesonly="true"
spacingy="1.0583333" />
<sodipodi:guide
position="0,0"
orientation="0,755.90551"
id="guide4807"
inkscape:locked="false" />
<sodipodi:guide
position="200,0"
orientation="-755.90551,0"
id="guide4809"
inkscape:locked="false" />
<sodipodi:guide
position="200,200"
orientation="0,-755.90551"
id="guide4811"
inkscape:locked="false" />
<inkscape:grid
type="xygrid"
id="grid871"
empspacing="2"
color="#d43fff"
opacity="0.1254902"
empcolor="#cf3fff"
empopacity="0.25098039"
units="px"
spacingx="1.0583333"
spacingy="1.0583333"
enabled="false" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Logo"
style="display:inline"
transform="translate(0,-26.066668)">
<circle
id="path3792"
cx="135.46666"
cy="161.53333"
style="display:inline;fill:#333333;fill-opacity:1;stroke:none;stroke-width:0.3584221"
inkscape:transform-center-x="-57.929751"
inkscape:transform-center-y="532.03976"
inkscape:export-xdpi="96.000008"
inkscape:export-ydpi="96.000008"
r="135.46666" />
<path
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.32663074px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 48.965212,110.73276 H 239.52342 c 4.88824,0 4.88824,0 0,8.46688 L 180.59519,221.2662 c -4.6188,8.00001 -4.6188,8.00001 -9.50702,8.00001 h -19.55294 c -4.88824,0 -4.88824,0 -0.26944,-8.00001 l 44.2635,-76.66608 h -29.41224 l -43.91123,76.19952 c -4.88823,8.46657 -4.88823,8.46657 -9.77646,8.46657 H 29.329398 l 49.299816,-84.66609 h -49.29982 z"
id="path4834"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccccccccc"
inkscape:export-xdpi="96.000008"
inkscape:export-ydpi="96.000008" />
<path
style="fill:#c0def5;fill-opacity:1;stroke:none;stroke-width:0.3584221px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 97.764652,110.73276 H 127.09406 L 58.658797,229.26621 H 29.329398 Z"
id="path4836"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc"
inkscape:export-xdpi="96.000008"
inkscape:export-ydpi="96.000008" />
<path
style="fill:#87aade;fill-opacity:1;stroke:none;stroke-width:0.3584221px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 58.658797,229.26621 127.09406,110.73276 h 29.3294 L 87.988193,229.26621 Z"
id="path4838"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc"
inkscape:export-xdpi="96.000008"
inkscape:export-ydpi="96.000008" />
</g>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
style="display:inline"
transform="translate(0,-26.066668)" />
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

3
src/imgs/refresh.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.6498 6.35001C16.0198 4.72001 13.7098 3.78001 11.1698 4.04001C7.49978 4.41001 4.47978 7.39001 4.06978 11.06C3.51978 15.91 7.26978 20 11.9998 20C15.1898 20 17.9298 18.13 19.2098 15.44C19.5298 14.77 19.0498 14 18.3098 14C17.9398 14 17.5898 14.2 17.4298 14.53C16.2998 16.96 13.5898 18.5 10.6298 17.84C8.40978 17.35 6.61978 15.54 6.14978 13.32C5.30978 9.44001 8.25978 6.00001 11.9998 6.00001C13.6598 6.00001 15.1398 6.69001 16.2198 7.78001L14.7098 9.29001C14.0798 9.92001 14.5198 11 15.4098 11H18.9998C19.5498 11 19.9998 10.55 19.9998 10V6.41001C19.9998 5.52001 18.9198 5.07001 18.2898 5.70001L17.6498 6.35001Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 738 B

View File

@ -1,3 +1,3 @@
<svg width="10" height="8" viewBox="0 0 10 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.979065 4L3.63177 7L8.93718 1" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 12.5L8.84497 15.845C9.71398 16.714 11.1538 16.601 11.8767 15.6071L18.5 6.5" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 207 B

After

Width:  |  Height:  |  Size: 250 B

View File

@ -47,6 +47,7 @@ h1 {
font-size: 24px;
line-height: 32px;
text-align: center;
color: $foreground;
}
h4 {

View File

@ -0,0 +1,19 @@
/*
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.
*/
export * from './matrix-cypher';
export * from './utils';
export * from './schemas';

View File

@ -0,0 +1,193 @@
/*
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.
*/
/* eslint-disable import/first */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import any from 'promise.any';
any.shim()
import VersionSchema from './schemas/VersionSchema';
import WellKnownSchema from './schemas/WellKnownSchema';
import UserSchema, { User } from './schemas/UserSchema';
import RoomAliasSchema, {
RoomAlias,
} from './schemas/RoomAliasSchema';
import PublicRoomsSchema, {
PublicRooms,
Room,
} from './schemas/PublicRoomsSchema';
import EventSchema, {
Event,
} from './schemas/EventSchema';
import { ensure } from './utils/promises';
import { prefixFetch, parseJSON } from './utils/fetch';
/*
* A client is a resolved homeserver name wrapped in a lambda'd fetch
*/
export type Client = (path: string) => Promise<Response>;
/*
* Confirms that the target homeserver is properly configured and operational
*/
export const validateHS = (host: string) =>
prefixFetch(host)('/_matrix/client/versions')
.then(parseJSON)
.then(VersionSchema.parse)
.then(() => host);
/*
* Discovers the correct domain name for the host according to the spec's
* discovery rules
*/
export const discoverServer = (host: string) =>
prefixFetch(host)('/.well-known/matrix/client')
.then(resp => resp.ok
? resp.json()
.then(WellKnownSchema.parse)
.then(content => {
if (content === undefined) return host;
else if (
'm.homeserver' in content && content['m.homeserver']
) {
return content['m.homeserver'].base_url
} else {
return host
}
})
: ensure(
resp.status === 404,
() => host,
),
)
.then(validateHS)
/*
* Takes a hs domain and resolves it to it's current domain and returns a
* client
*/
export async function client(host: string): Promise<Client> {
return prefixFetch(await discoverServer(host))
}
/*
* Gets the details for a user
*/
export function getUserDetails(
client: Client,
userId: string,
): Promise<User> {
return client(`/_matrix/client/r0/profile/${userId}`)
.then(parseJSON)
.then(UserSchema.parse)
}
/*
* Gets the roomId of a room by resolving it's alias
*/
export function getRoomIdFromAlias(
client: Client,
roomAlias: string,
): Promise<RoomAlias> {
const encodedRoomAlias = encodeURIComponent(roomAlias);
return client(`/_matrix/client/r0/directory/room/${encodedRoomAlias}`)
.then(parseJSON)
.then(RoomAliasSchema.parse);
}
/*
* Gets the details of a room if that room is public
*/
export function getRoomDetails(clients: Client[], roomId: string): Promise<Room> {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return Promise.any(clients.map(client => searchPublicRooms(client, roomId)));
}
/*
* Gets a list of all public rooms on a hs
*/
export function getPublicRooms(client: Client): Promise<PublicRooms> {
return getPublicRoomsUnsafe(client)
.then(PublicRoomsSchema.parse)
}
/*
* Similar to getPubliRooms however id doesn't confirm the data returned from
* the hs is correct
*
* This is used because the room list can be huge and validating it all takes
* a long time
*/
export function getPublicRoomsUnsafe(client: Client): Promise<PublicRooms> {
// TODO: Do not assume server will return all results in one go
return client('/_matrix/client/r0/publicRooms')
.then(parseJSON)
}
/*
* Searches the public rooms of a homeserver for the metadata of a particular
*/
export function searchPublicRooms(
client: Client,
roomId: string,
): Promise<Room> {
// we use the unsage version here because the safe one is sloooow
return getPublicRoomsUnsafe(client)
.then(rooms => {
const [match] = rooms.chunk.filter(
chunk => chunk.room_id === roomId,
);
return match !== undefined
? Promise.resolve(match)
: Promise.reject(new Error(
`This server knowns no public room with id ${roomId}`,
));
});
}
/*
* Gets the details of an event from the homeserver
*/
export async function getEvent(
client: Client,
roomIdOrAlias: string,
eventId: string,
): Promise<Event> {
return client(`/_matrix/client/r0/rooms/${roomIdOrAlias}/event/${eventId}`)
.then(parseJSON)
.then(EventSchema.parse);
}
/*
* Gets an mxc resource
*/
export function convertMXCtoMediaQuery(
clientURL: string,
mxc: string,
): string {
// mxc://matrix.org/EqMZYbAYhREvHXvYFyfxOlkf
const matches = mxc.match(/mxc:\/\/(.+)\/(.+)/)
if (!matches) {
throw new Error(`mxc invalid: ${JSON.stringify({mxc})}`);
}
return `${clientURL}/_matrix/media/r0/download/${matches[1]}/${matches[2]}`;
}

View File

@ -0,0 +1,30 @@
/*
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 { object, string, TypeOf } from 'zod';
const EventSchema = object({
content: object({}).nonstrict(),
type: string(),
event_id: string(),
sender: string(),
origin_server_ts: string(),
unsigned: object({}).nonstrict().optional(),
room_id: string(),
});
export type Event = TypeOf<typeof EventSchema>;
export default EventSchema;

View File

@ -0,0 +1,43 @@
/*
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 { object, array, string, boolean, number, TypeOf } from 'zod';
export const RoomSchema = object({
aliases: array(string()).optional(),
canonical_alias: string().optional(),
name: string().optional(),
num_joined_members: number(),
room_id: string(),
topic: string().optional(),
world_readable: boolean(),
guest_can_join: boolean(),
avatar_url: string().optional(),
});
const PublicRoomsSchema = object({
chunk: array(RoomSchema),
next_batch: string().optional(),
prev_batch: string().optional(),
total_room_count_estimate: number().optional(),
});
export type Room = TypeOf<typeof RoomSchema>;
export type PublicRooms = TypeOf<typeof PublicRoomsSchema>;
export default PublicRoomsSchema;

View File

@ -0,0 +1,26 @@
/*
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 { object, array, string, TypeOf } from 'zod';
const RoomAliasSchema = object({
room_id: string(),
servers: array(string()),
});
export type RoomAlias = TypeOf<typeof RoomAliasSchema>;
export default RoomAliasSchema;

View File

@ -0,0 +1,26 @@
/*
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 { object, string, TypeOf } from 'zod';
const UserSchema = object({
avatar_url: string().optional(),
displayname: string().optional(),
})
export type User = TypeOf<typeof UserSchema>;
export default UserSchema;

View File

@ -0,0 +1,21 @@
/*
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 { object, string, array } from 'zod';
export default object({
versions: array(string()),
}).nonstrict()

View File

@ -0,0 +1,29 @@
/*
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 { object, string, TypeOf } from 'zod';
const WellKnownSchema = object({
'm.homeserver': object({
'base_url': string().url(),
}),
'm.identity_server': object({
'base_url': string().url(),
}),
});
export type WellKnown = TypeOf<typeof WellKnownSchema>;
export default WellKnownSchema;

View File

@ -0,0 +1,24 @@
/*
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.
*/
export * from './EventSchema';
export * from './PublicRoomsSchema';
export * from './RoomAliasSchema';
export * from './UserSchema';
export * from './VersionSchema';
export * from './WellKnownSchema';
export * from './index';

View File

@ -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 fetch from 'cross-fetch';
import { ensure } from './promises';
/*
* Wraps a fetch with a domain for easy reuse.
*/
export function prefixFetch(host: string) {
return (path: string) => fetch(
new URL(path, host).toString(),
);
}
export function parseJSON(resp: Response) {
return ensure(
resp.ok,
() => resp.json(),
`Error from Homeserver. Error code: ${resp.status}`,
);
}

View File

@ -0,0 +1,18 @@
/*
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.
*/
export * from './fetch';
export * from './promises';

View File

@ -0,0 +1,60 @@
/*
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.
*/
/*
* Conditional promises
*/
/*
* If the condition is false reject with rejectReason
* If it's true resolve with the result = resultThunk()
*/
export function ensure<T>(condition: boolean, resultThunk: () => T | PromiseLike<T>, rejectReason?: string) {
return condition
? Promise.resolve(resultThunk())
: Promise.reject(new Error(rejectReason));
}
/*
* Loggin utilities
*/
/*
* Logs a then using "success: {label: successArg}"
*/
export function logThen<T>(label: string): (v: T) => T | PromiseLike<T> {
return (v: T) => {
console.log('success:', {[`${label}`]: v}); return v
}
}
/*
* Logs a catch using "fail: {label: failArg}"
*/
export function logCatch<T>(label: string): (v: T) => T | PromiseLike<T> {
return (v: T) => {
console.log('fail:', {[`${label}`]: v});
return Promise.reject(v)
}
}
/*
* inserts loggers for both callbacks of a then
*/
export function logThens<T1, T2 = T1>(label: string) {
return [logThen<T1>(label), logCatch<T2>(label)]
}

View File

@ -22,6 +22,8 @@ import InvitingClientTile from '../components/InvitingClientTile';
import { parseHash } from '../parser/parser';
import { LinkKind } from '../parser/types';
/* eslint-disable no-restricted-globals */
interface IProps {
link: string;
}
@ -36,8 +38,14 @@ const LinkRouter: React.FC<IProps> = ({ link }: IProps) => {
case LinkKind.ParseFailed:
feedback = (
<Tile>
<h1>Invalid matrix.to link</h1>
<p>{link}</p>
<p>
That URL doesn't seem right. Links should be in the
format:
</p>
<br />
<p>
{location.host}/#/{'<'}matrix-resourceidentifier{'>'}
</p>
</Tile>
);
break;
@ -53,7 +61,6 @@ const LinkRouter: React.FC<IProps> = ({ link }: IProps) => {
feedback = (
<>
<LinkPreview link={parsedLink} />
<hr />
{client}
</>
);

View File

@ -20,6 +20,7 @@ limitations under the License.
import {
Client,
client,
Room,
RoomAlias,
User,
@ -27,7 +28,7 @@ import {
searchPublicRooms,
getUserDetails,
convertMXCtoMediaQuery,
} from 'matrix-cypher';
} from '../matrix-cypher';
import { LinkKind, Permalink } from '../parser/types';
/* This is a collection of methods for providing fallback metadata
@ -59,7 +60,8 @@ export const fallbackRoom = ({
const roomAlias_ = roomAlias ? roomAlias : identifier;
return {
aliases: [roomAlias_],
topic: 'Unable to find room details.',
topic:
'No details available. This might be a private room. You can still join below.',
canonical_alias: roomAlias_,
name: roomAlias_,
num_joined_members: 0,
@ -75,18 +77,24 @@ export const fallbackRoom = ({
* a `fallbackRoom`
*/
export async function getRoomFromAlias(
client: Client,
clientURL: string,
roomAlias: string
): Promise<Room> {
let resolvedRoomAlias: RoomAlias;
let resolvedClient: Client;
try {
resolvedRoomAlias = await getRoomIdFromAlias(client, roomAlias);
resolvedClient = await client(clientURL);
resolvedRoomAlias = await getRoomIdFromAlias(resolvedClient, roomAlias);
} catch {
return fallbackRoom({ identifier: roomAlias });
}
try {
return await searchPublicRooms(client, resolvedRoomAlias.room_id);
return await searchPublicRooms(
resolvedClient,
resolvedRoomAlias.room_id
);
} catch {
return fallbackRoom({
identifier: roomAlias,
@ -101,11 +109,12 @@ export async function getRoomFromAlias(
* a `fallbackRoom`
*/
export async function getRoomFromId(
client: Client,
clientURL: string,
roomId: string
): Promise<Room> {
try {
return await searchPublicRooms(client, roomId);
const resolvedClient = await client(clientURL);
return await searchPublicRooms(resolvedClient, roomId);
} catch {
return fallbackRoom({ identifier: roomId });
}
@ -114,9 +123,13 @@ export async function getRoomFromId(
/*
* Tries to fetch user details. If it fails it uses a `fallbackUser`
*/
export async function getUser(client: Client, userId: string): Promise<User> {
export async function getUser(
clientURL: string,
userId: string
): Promise<User> {
try {
return await getUserDetails(client, userId);
const resolvedClient = await client(clientURL);
return await getUserDetails(resolvedClient, userId);
} catch {
return fallbackUser(userId);
}
@ -127,7 +140,7 @@ export async function getUser(client: Client, userId: string): Promise<User> {
* a `fallbackRoom`
*/
export async function getRoomFromPermalink(
client: Client,
client: string,
link: Permalink
): Promise<Room> {
switch (link.roomKind) {

52
src/utils/getHS.ts Normal file
View File

@ -0,0 +1,52 @@
/*
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 { useContext } from 'react';
import HSContext, {
TempHSContext,
State,
HSOptions,
} from '../contexts/HSContext';
import { SafeLink } from '../parser/types';
function selectedClient(link: SafeLink, hsOptions: State): string[] {
switch (hsOptions.option) {
case HSOptions.Unset:
return [];
case HSOptions.TrustedHSOnly:
return [hsOptions.hs];
case HSOptions.Any:
return [
...link.identifier
.split('/')
.map((i) => 'https://' + i.split(':')[1]),
...link.arguments.vias,
];
}
}
export default function useHSs(link: SafeLink): string[] {
const [HSState] = useContext(HSContext);
const [TempHSState] = useContext(TempHSContext);
if (HSState.option !== HSOptions.Unset) {
return selectedClient(link, HSState);
} else if (TempHSState.option !== HSOptions.Unset) {
return selectedClient(link, TempHSState);
} else {
return [];
}
}