Merge pull request #108 from matrix-org/matrixtwo/gdpr

Implement homeserver selection
This commit is contained in:
Jorik Schellekens 2020-09-15 17:06:15 +01:00 committed by GitHub
commit 8cac5fb9f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 644 additions and 72 deletions

11
src/_mixins.scss Normal file
View File

@ -0,0 +1,11 @@
@mixin unreal-focus {
outline-width: 2px;
outline-style: solid;
outline-color: Highlight;
/* WebKit gets its native focus styles. */
@media (-webkit-min-device-pixel-ratio: 0) {
outline-color: -webkit-focus-ring-color;
outline-style: auto;
}
}

View File

@ -21,6 +21,7 @@ import { ActionType, ClientContext } from '../contexts/ClientContext';
import ClientList from './ClientList';
import { SafeLink } from '../parser/types';
import Button from './Button';
import StyledCheckbox from './StyledCheckbox';
interface IProps {
link: SafeLink;
@ -31,40 +32,34 @@ const ClientSelection: React.FC<IProps> = ({ link }: IProps) => {
const [rememberSelection, setRememberSelection] = useState(false);
const options = (
<div className="advancedOptions">
<label>
<input
type="checkbox"
onChange={(): void => {
setRememberSelection(!rememberSelection);
}}
checked={rememberSelection}
/>
<StyledCheckbox
onChange={(): void => {
setRememberSelection(!rememberSelection);
}}
checked={rememberSelection}
>
Remember my selection for future invites in this browser
</label>
<label>
<input
type="checkbox"
onChange={(): void => {
clientStateDispatch({
action: ActionType.ToggleShowOnlyDeviceClients,
});
}}
checked={clientState.showOnlyDeviceClients}
/>
</StyledCheckbox>
<StyledCheckbox
onChange={(): void => {
clientStateDispatch({
action: ActionType.ToggleShowOnlyDeviceClients,
});
}}
checked={clientState.showOnlyDeviceClients}
>
Show only clients suggested for this device
</label>
<label>
<input
type="checkbox"
onChange={(): void => {
clientStateDispatch({
action: ActionType.ToggleShowExperimentalClients,
});
}}
checked={clientState.showExperimentalClients}
/>
</StyledCheckbox>
<StyledCheckbox
onChange={(): void => {
clientStateDispatch({
action: ActionType.ToggleShowExperimentalClients,
});
}}
checked={clientState.showExperimentalClients}
>
Show experimental clients
</label>
</StyledCheckbox>
</div>
);

View File

@ -0,0 +1,22 @@
/*
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.
*/
.defaultPreview {
.avatar {
border-radius: 0;
border: 0;
}
}

View File

@ -0,0 +1,42 @@
/*
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 { SafeLink } from '../parser/types';
import Avatar from './Avatar';
import './DefaultPreview.scss';
import genericRoomPreview from '../imgs/chat-icon.svg';
interface IProps {
link: SafeLink;
}
const DefaultPreview: React.FC<IProps> = ({ link }: IProps) => {
return (
<div className="defaultPreview">
<Avatar
avatarUrl={genericRoomPreview}
label={`Generic icon representing ${link.identifier}`}
/>
<h1>{link.identifier}</h1>
</div>
);
};
export default DefaultPreview;

View File

@ -0,0 +1,56 @@
/*
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';
.homeserverOptions {
display: grid;
row-gap: 20px;
background: $app-background;
text-align: left;
> * {
width: 100%;
}
.homeserverOptionsDescription {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
> p {
flex-grow: 1;
}
> img {
flex-shrink: 0;
flex-grow: 0;
background-color: $background;
height: 62px;
width: 62px;
padding: 11px;
border-radius: 100%;
}
}
form {
display: grid;
row-gap: 25px;
}
}

View File

@ -0,0 +1,32 @@
/*
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 HomeserverOptions from './HomeserverOptions';
export default {
title: 'HomeserverOptions',
parameters: {
design: {
type: 'figma',
url:
'https://figma.com/file/WSXjCGc1k6FVI093qhlzOP/04-Recieving-share-link?node-id=143%3A5853',
},
},
};
export const Default: React.FC = () => <HomeserverOptions />;

View File

@ -0,0 +1,114 @@
/*
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, useState } from 'react';
import { Formik, Form } 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 Input from './Input';
import Toggle from './Toggle';
import StyledCheckbox from './StyledCheckbox';
import './HomeserverOptions.scss';
interface IProps {}
interface FormValues {
HSUrl: string;
}
function validateURL(values: FormValues): Partial<FormValues> {
const errors: Partial<FormValues> = {};
try {
string().url().parse(values.HSUrl);
} catch {
errors.HSUrl = 'This must be a valid url';
}
return errors;
}
const HomeserverOptions: React.FC<IProps> = () => {
const HSStateDispatcher = useContext(HSContext)[1];
const TempHSStateDispatcher = useContext(TempHSContext)[1];
const [rememberSelection, setRemeberSelection] = useState(false);
const [usePrefered, setUsePrefered] = useState(false);
const dispatcher = rememberSelection
? HSStateDispatcher
: TempHSStateDispatcher;
const hsInput = usePrefered ? (
<Formik
initialValues={{
HSUrl: '',
}}
validate={validateURL}
onSubmit={({ HSUrl }): void =>
dispatcher({ action: ActionType.SetHS, HSURL: HSUrl })
}
>
<Form>
<Input
type="text"
name="HSUrl"
placeholder="https://example.com"
/>
<Button type="submit">Set HS</Button>
</Form>
</Formik>
) : null;
return (
<Tile className="homeserverOptions">
<div className="homeserverOptionsDescription">
<div>
<p>
Let's locate a homeserver to show you more information.
</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
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>
);
};
export default HomeserverOptions;

View File

@ -22,6 +22,8 @@ import InviteTile from './InviteTile';
import { SafeLink, LinkKind } from '../parser/types';
import UserPreview from './UserPreview';
import EventPreview from './EventPreview';
import HomeserverOptions from './HomeserverOptions';
import DefaultPreview from './DefaultPreview';
import { clientMap } from '../clients';
import {
getRoomFromId,
@ -30,16 +32,26 @@ 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';
interface IProps {
link: SafeLink;
}
const LOADING: JSX.Element = <>Generating invite</>;
const invite = async ({ link }: { link: SafeLink }): Promise<JSX.Element> => {
const invite = async ({
clientAddress,
link,
}: {
clientAddress: string;
link: SafeLink;
}): Promise<JSX.Element> => {
// TODO: replace with client fetch
const defaultClient = await client('https://matrix.org');
const defaultClient = await client(clientAddress);
switch (link.kind) {
case LinkKind.Alias:
return (
@ -85,12 +97,79 @@ const invite = async ({ link }: { link: SafeLink }): Promise<JSX.Element> => {
}
};
const LinkPreview: React.FC<IProps> = ({ link }: IProps) => {
const [content, setContent] = useState(LOADING);
interface PreviewProps extends IProps {
client: string;
}
const Preview: React.FC<PreviewProps> = ({ link, client }: PreviewProps) => {
const [content, setContent] = useState(<DefaultPreview link={link} />);
// TODO: support multiple clients with vias
useEffect(() => {
(async (): Promise<void> => setContent(await invite({ link })))();
}, [link]);
(async (): Promise<void> =>
setContent(
await invite({
clientAddress: client,
link,
})
))();
}, [link, client]);
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
) {
content = (
<>
<DefaultPreview link={link} />
<Toggle
checked={showHSOptions}
onChange={(): void => setShowHSOPtions(!showHSOptions)}
>
Show more information
</Toggle>
</>
);
if (showHSOptions) {
content = (
<>
{content}
<HomeserverOptions />
</>
);
}
} else {
const clients =
tempHSState.option !== HSOptions.Unset
? selectedClient(link, tempHSState)
: selectedClient(link, hsOptions);
content = <Preview link={link} client={clients[0]} />;
}
const [{ clientId }] = useContext(ClientContext);

View File

@ -0,0 +1,61 @@
/*
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';
@import '../mixins';
.styledCheckbox {
display: flex;
align-items: center;
input[type='checkbox'] {
appearance: none;
margin: 0;
padding: 0;
&:checked + div {
background: $foreground;
img {
display: block;
}
}
&.focus-visible {
& + div {
@include unreal-focus;
}
}
}
.styledCheckboxWrapper {
display: flex;
margin-right: 5px;
border: 2px solid $foreground;
box-sizing: border-box;
border-radius: 4px;
height: 16px;
width: 16px;
img {
height: 100%;
width: 100%;
display: none;
}
}
}

View File

@ -0,0 +1,41 @@
/*
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.
*/
/*
* Stolen from the matrix-react-sdk
*/
import React from 'react';
import tick from '../imgs/tick.svg';
import './StyledCheckbox.scss';
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const StyledCheckbox: React.FC<IProps> = ({
children,
className,
...otherProps
}: IProps) => (
<label className="styledCheckbox">
<input {...otherProps} type="checkbox" />
{/* Using the div to center the image */}
<div className="styledCheckboxWrapper">
<img src={tick} />
</div>
{children}
</label>
);
export default StyledCheckbox;

View File

@ -0,0 +1,40 @@
/*
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 '../mixins';
.toggle {
display: flex;
> input[type='checkbox'] {
// Remove the OS's representation
margin: 0;
padding: 0;
appearance: none;
&.focus-visible {
& + img {
@include unreal-focus;
}
}
&:checked {
& + img {
transform: rotate(180deg);
}
}
}
}

35
src/components/Toggle.tsx Normal file
View File

@ -0,0 +1,35 @@
/*
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 chevron from '../imgs/chevron-down.svg';
import './Toggle.scss';
interface IProps extends React.InputHTMLAttributes<Element> {
children?: React.ReactChild;
}
const Toggle: React.FC<IProps> = ({ children, ...props }: IProps) => (
<label className="toggle">
{children}
<input type="checkbox" {...props} />
<img src={chevron} />
</label>
);
export default Toggle;

View File

@ -22,6 +22,13 @@ import {
reducer as clientReducer,
initialState as clientInitialState,
} from './ClientContext';
import {
HSProvider,
reducer as HSReducer,
initialState as HSInitialState,
unpersistedReducer as HSTempReducer,
TempHSProvider,
} from './HSContext';
interface IProps {
children: React.ReactNode;
@ -30,7 +37,13 @@ interface IProps {
export default ({ children }: IProps): JSX.Element => (
<UserAgentProvider ua={window.navigator.userAgent}>
<ClientProvider value={useReducer(clientReducer, clientInitialState)}>
{children}
<HSProvider value={useReducer(HSReducer, HSInitialState)}>
<TempHSProvider
value={useReducer(HSTempReducer, HSInitialState)}
>
{children}
</TempHSProvider>
</HSProvider>
</ClientProvider>
</UserAgentProvider>
);

View File

@ -21,12 +21,12 @@ import { persistReducer } from '../utils/localStorage';
//import { prefixFetch, Client, discoverServer } from 'matrix-cypher';
enum HSOptions {
export enum HSOptions {
// The homeserver contact policy hasn't
// been set yet.
Unset = 'UNSET',
// Matrix.to should only contact a single provided homeserver
TrustedClientOnly = 'TRUSTED_CLIENT_ONLY',
TrustedHSOnly = 'TRUSTED_CLIENT_ONLY',
// Matrix.to may contact any homeserver it requires
Any = 'ANY',
// Matrix.to may not contact any homeservers
@ -44,31 +44,31 @@ const STATE_SCHEMA = union([
option: literal(HSOptions.Any),
}),
object({
option: literal(HSOptions.TrustedClientOnly),
option: literal(HSOptions.TrustedHSOnly),
hs: string(),
}),
]);
type State = TypeOf<typeof STATE_SCHEMA>;
export type State = TypeOf<typeof STATE_SCHEMA>;
// TODO: rename actions to something with more meaning out of context
export enum ActionTypes {
export enum ActionType {
SetHS = 'SET_HS',
SetAny = 'SET_ANY',
SetNone = 'SET_NONE',
}
export interface SetHS {
action: ActionTypes.SetHS;
action: ActionType.SetHS;
HSURL: string;
}
export interface SetAny {
action: ActionTypes.SetAny;
action: ActionType.SetAny;
}
export interface SetNone {
action: ActionTypes.SetNone;
action: ActionType.SetNone;
}
export type Action = SetHS | SetAny | SetNone;
@ -77,37 +77,55 @@ export const INITIAL_STATE: State = {
option: HSOptions.Unset,
};
export const unpersistedReducer = (state: State, action: Action): State => {
console.log('reducing');
console.log(action);
switch (action.action) {
case ActionType.SetNone:
return {
option: HSOptions.None,
};
case ActionType.SetAny:
return {
option: HSOptions.Any,
};
case ActionType.SetHS:
return {
option: HSOptions.TrustedHSOnly,
hs: action.HSURL,
};
default:
return state;
}
};
export const [initialState, reducer] = persistReducer(
'home-server-options',
INITIAL_STATE,
STATE_SCHEMA,
(state: State, action: Action): State => {
switch (action.action) {
case ActionTypes.SetNone:
return {
option: HSOptions.None,
};
case ActionTypes.SetAny:
return {
option: HSOptions.Any,
};
case ActionTypes.SetHS:
return {
option: HSOptions.TrustedClientOnly,
hs: action.HSURL,
};
default:
return state;
}
}
unpersistedReducer
);
// The defualt reducer needs to be overwritten with the one above
// after it's been put through react's useReducer
const { Provider, Consumer } = React.createContext<
[State, React.Dispatch<Action>]
>([initialState, (): void => {}]);
const HSContext = React.createContext<[State, React.Dispatch<Action>]>([
initialState,
(): void => {},
]);
export default HSContext;
// Quick rename to make importing easier
export const HSProvider = Provider;
export const HSConsumer = Consumer;
export const HSProvider = HSContext.Provider;
export const HSConsumer = HSContext.Consumer;
// The defualt reducer needs to be overwritten with the one above
// after it's been put through react's useReducer
// The temp reducer is for unpersisted choices with regards to GDPR
export const TempHSContext = React.createContext<
[State, React.Dispatch<Action>]
>([INITIAL_STATE, (): void => {}]);
// Quick rename to make importing easier
export const TempHSProvider = TempHSContext.Provider;
export const TempHSConsumer = TempHSContext.Consumer;

4
src/imgs/chat-icon.svg Normal file
View File

@ -0,0 +1,4 @@
<svg width="36" height="38" viewBox="0 0 36 38" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.4184 32.9995C20.2122 31.5062 20.6613 29.8061 20.6613 28.0024C20.6613 22.0566 15.7808 17.2365 9.76039 17.2365C6.93884 17.2365 4.36767 18.2952 2.43193 20.0323C2.21698 18.9773 2.10413 17.8851 2.10413 16.7666C2.10413 7.78278 9.38427 0.5 18.3648 0.5C27.3453 0.5 34.6254 7.78278 34.6254 16.7666C34.6254 19.283 34.0542 21.6659 33.0345 23.7928L35.874 33.0245C36.1106 33.7938 35.3879 34.5131 34.6198 34.273L25.4569 31.4085C23.6119 32.3044 21.5722 32.8617 19.4184 32.9995Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.8203 37.1184C13.4204 37.1184 17.1495 33.4167 17.1495 28.8504C17.1495 24.2842 13.4204 20.5825 8.8203 20.5825C4.22021 20.5825 0.491095 24.2842 0.491095 28.8504C0.491095 30.1291 0.783484 31.3399 1.30551 32.4206L0.126628 36.2238C-0.111642 36.9925 0.609435 37.7134 1.37807 37.475L5.18834 36.293C6.28601 36.8218 7.51826 37.1184 8.8203 37.1184Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 7.5L9 10.5L12 7.5" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

View File

@ -0,0 +1,3 @@
<svg width="27" height="42" viewBox="0 0 27 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.5 0.771484C6.05481 0.771484 0 6.8263 0 14.2715C0 19.1178 2.5543 23.3676 6.39844 25.7465C6.49799 25.8206 6.6118 25.8733 6.7327 25.9013C6.8536 25.9293 6.97899 25.932 7.10099 25.9092C7.22299 25.8865 7.33896 25.8387 7.44162 25.769C7.54428 25.6993 7.63141 25.609 7.69753 25.504C7.76366 25.399 7.80735 25.2814 7.82587 25.1587C7.84438 25.036 7.83732 24.9108 7.80512 24.7909C7.77292 24.6711 7.71629 24.5592 7.63877 24.4623C7.56126 24.3653 7.46454 24.2855 7.35469 24.2277C4.02199 22.1654 1.8 18.4852 1.8 14.2715C1.8 7.79909 7.02761 2.57148 13.5 2.57148C19.9724 2.57148 25.2 7.79909 25.2 14.2715C25.2 18.4852 22.978 22.1654 19.6453 24.2277C19.5355 24.2855 19.4387 24.3653 19.3612 24.4623C19.2837 24.5592 19.2271 24.6711 19.1949 24.7909C19.1627 24.9108 19.1556 25.036 19.1741 25.1587C19.1926 25.2814 19.2363 25.399 19.3025 25.504C19.3686 25.609 19.4557 25.6993 19.5584 25.769C19.661 25.8387 19.777 25.8865 19.899 25.9092C20.021 25.932 20.1464 25.9293 20.2673 25.9013C20.3882 25.8733 20.502 25.8206 20.6016 25.7465C24.4457 23.3676 27 19.1178 27 14.2715C27 6.8263 20.9452 0.771484 13.5 0.771484ZM13.5 6.17148C9.03715 6.17148 5.4 9.80863 5.4 14.2715C5.4 16.7729 6.53578 19.0162 8.325 20.5011C8.41424 20.5855 8.51978 20.6506 8.63514 20.6926C8.75049 20.7346 8.87323 20.7526 8.99578 20.7454C9.11834 20.7383 9.23814 20.7061 9.3478 20.6509C9.45746 20.5957 9.55467 20.5187 9.63346 20.4245C9.71224 20.3304 9.77093 20.2211 9.80591 20.1034C9.8409 19.9857 9.85144 19.8621 9.83689 19.7402C9.82234 19.6183 9.78301 19.5007 9.72131 19.3945C9.65961 19.2884 9.57685 19.196 9.47812 19.123C8.08472 17.9666 7.2 16.2292 7.2 14.2715C7.2 10.7814 10.0099 7.97148 13.5 7.97148C16.9901 7.97148 19.8 10.7814 19.8 14.2715C19.8 16.2292 18.9153 17.9666 17.5219 19.123C17.4231 19.196 17.3404 19.2884 17.2787 19.3945C17.217 19.5007 17.1777 19.6183 17.1631 19.7402C17.1486 19.8621 17.1591 19.9857 17.1941 20.1034C17.2291 20.2211 17.2878 20.3304 17.3665 20.4245C17.4453 20.5187 17.5425 20.5957 17.6522 20.6509C17.7619 20.7061 17.8817 20.7383 18.0042 20.7454C18.1268 20.7526 18.2495 20.7346 18.3649 20.6926C18.4802 20.6506 18.5858 20.5855 18.675 20.5011C20.4642 19.0162 21.6 16.7729 21.6 14.2715C21.6 9.80863 17.9628 6.17148 13.5 6.17148ZM13.5 11.1215C11.771 11.1215 10.35 12.5424 10.35 14.2715C10.35 15.5207 11.0844 16.6165 12.15 17.1262L3.65625 40.048C3.61467 40.1588 3.59532 40.2768 3.59932 40.395C3.60331 40.5133 3.63057 40.6297 3.67953 40.7374C3.72849 40.8452 3.7982 40.9422 3.88468 41.023C3.97115 41.1038 4.0727 41.1668 4.18352 41.2084C4.29433 41.2499 4.41225 41.2692 4.53054 41.2652C4.64882 41.2612 4.76516 41.2339 4.8729 41.185C4.98064 41.136 5.07768 41.0662 5.15847 40.9797C5.23926 40.8932 5.30222 40.7917 5.34375 40.6809L7.12969 35.8715H19.8703L21.6562 40.6809C21.6978 40.7917 21.7607 40.8932 21.8415 40.9797C21.9223 41.0662 22.0194 41.136 22.1271 41.185C22.2348 41.2339 22.3512 41.2612 22.4695 41.2652C22.5877 41.2692 22.7057 41.2499 22.8165 41.2084C22.9273 41.1668 23.0288 41.1038 23.1153 41.023C23.2018 40.9422 23.2715 40.8452 23.3205 40.7374C23.3694 40.6297 23.3967 40.5133 23.4007 40.395C23.4047 40.2768 23.3853 40.1588 23.3438 40.048L14.85 17.1262C15.9156 16.6165 16.65 15.5207 16.65 14.2715C16.65 12.5424 15.229 11.1215 13.5 11.1215ZM13.5 12.9215C14.2562 12.9215 14.85 13.5152 14.85 14.2715C14.85 15.0277 14.2562 15.6215 13.5 15.6215C12.7438 15.6215 12.15 15.0277 12.15 14.2715C12.15 13.5152 12.7438 12.9215 13.5 12.9215ZM13.5 18.659L15.5391 24.1715H11.4609L13.5 18.659ZM13.8516 25.9715H16.2L16.9453 27.9684L13.8516 25.9715ZM10.7437 26.098L15.3984 29.1074L9.61875 29.1213L10.7437 26.0979V26.098ZM18.0422 30.9074L18.9844 33.4527L12.9797 30.9215L18.0422 30.9075V30.9074ZM8.87344 31.1324L15.8062 34.0715H7.79062L8.87344 31.1324Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

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

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 207 B