Compare commits
4 commits
main
...
jryans/ux-
Author | SHA1 | Date | |
---|---|---|---|
|
5656059342 | ||
|
75d7cfa2a9 | ||
|
7a79e68d5e | ||
|
0a3b8c2123 |
9 changed files with 197 additions and 130 deletions
|
@ -16,6 +16,7 @@ limitations under the License.
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import 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 = () => {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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> = {};
|
||||||
try {
|
if (values.HSUrl === "other") {
|
||||||
string().url().parse(values.HSUrl);
|
try {
|
||||||
} catch {
|
string().url().parse(hsToURL(values.HSOtherUrl));
|
||||||
errors.HSUrl =
|
} catch {
|
||||||
'This must be a valid homeserver URL, starting with https://';
|
errors.HSOtherUrl =
|
||||||
|
'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 {
|
||||||
{({ values, errors }): JSX.Element => (
|
const useAnotherServer = <TextButton onClick={(e): void => setShowHSPicker(true)}>use another server</TextButton>;
|
||||||
<Form>
|
instructions = <p>View this link using {chosenHS} to preview content, or you can {useAnotherServer} or {continueWithout}.</p>
|
||||||
<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
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,42 +33,39 @@ 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) {
|
<Tile>
|
||||||
case LinkKind.ParseFailed:
|
<p>
|
||||||
feedback = (
|
That URL doesn't seem right. Links should be in the format:
|
||||||
<Tile>
|
</p>
|
||||||
<p>
|
<br />
|
||||||
That URL doesn't seem right. Links should be in the
|
<p>
|
||||||
format:
|
{location.host}/#/{'<'}matrix-resourceidentifier{'>'}
|
||||||
</p>
|
</p>
|
||||||
<br />
|
</Tile>
|
||||||
<p>
|
);
|
||||||
{location.host}/#/{'<'}matrix-resourceidentifier{'>'}
|
|
||||||
</p>
|
|
||||||
</Tile>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
if (parsedLink.arguments.client) {
|
|
||||||
client = (
|
|
||||||
<InvitingClientTile
|
|
||||||
clientName={parsedLink.arguments.client}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
feedback = (
|
|
||||||
<>
|
|
||||||
<LinkPreview link={parsedLink} />
|
|
||||||
{client}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return feedback;
|
if (hsState.option === HSOptions.Unset) {
|
||||||
|
return <HomeserverOptions link={parsedLink} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let client: JSX.Element = <></>;
|
||||||
|
if (parsedLink.arguments.client) {
|
||||||
|
client = (
|
||||||
|
<InvitingClientTile clientName={parsedLink.arguments.client} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LinkPreview link={parsedLink} />
|
||||||
|
{client}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LinkRouter;
|
export default LinkRouter;
|
||||||
|
|
|
@ -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 server = getHSFromIdentifier(identifier);
|
||||||
const match = identifier.match(/^.*:(?<server>.*)$/);
|
if (server) {
|
||||||
if (match && match.groups) {
|
identifierHS.push(server);
|
||||||
const server = match.groups.server;
|
|
||||||
if (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];
|
||||||
|
|
Loading…
Reference in a new issue