profile: add identity form

This commit is contained in:
Liam Fitzgerald 2020-08-27 09:16:26 +10:00
parent e9c6322ff4
commit 6085752f21
11 changed files with 387 additions and 167 deletions

View File

@ -1,5 +1,7 @@
import _ from 'lodash';
export const MOBILE_BROWSER_REGEX = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i;
export function resourceAsPath(resource) {
const { name, ship } = resource;
return `/ship/~${ship}/${name}`;
@ -75,6 +77,14 @@ export function uxToHex(ux) {
return value;
}
export function hexToUx(hex) {
const ux = _.chain(hex.split(""))
.chunk(4)
.map((x) => _.dropWhile(x, (y) => y === 0).join(""))
.join(".");
return `0x${ux}`;
}
function hexToDec(hex) {
const alphabet = '0123456789ABCDEF'.split('');
return hex.reverse().reduce((acc, digit, idx) => {

View File

@ -1,5 +1,6 @@
import React, { Component } from 'react';
import { UnControlled as CodeEditor } from 'react-codemirror2';
import { MOBILE_BROWSER_REGEX } from "~/logic/lib/util";
import CodeMirror from 'codemirror';
import 'codemirror/mode/markdown/markdown';
@ -132,7 +133,7 @@ export default class ChatEditor extends Component {
onChange={(e, d, v) => this.messageChange(e, d, v)}
editorDidMount={(editor) => {
this.editor = editor;
if (!BROWSER_REGEX.test(navigator.userAgent)) {
if (!MOBILE_BROWSER_REGEX.test(navigator.userAgent)) {
editor.focus();
}
}}

View File

@ -333,33 +333,6 @@ export default class GroupsApp extends Component<GroupsAppProps, {}> {
);
}}
/>
<Route exact path="/~groups/me"
render={(props) => {
const me = defaultContacts[window.ship] || {};
return (
<Skeleton
history={props.history}
api={api}
contacts={contacts}
groups={groups}
invites={invites}
activeDrawer="rightPanel"
selected="me"
associations={associations}
>
<ContactCard
api={api}
history={props.history}
path="/~/default"
contact={me}
s3={s3}
ship={window.ship}
/>
</Skeleton>
);
}}
/>
</Switch>
</>
);

View File

@ -0,0 +1,124 @@
import React, { Component } from "react";
import { Sigil } from "~/logic/lib/sigil";
import * as Yup from "yup";
import { Link } from "react-router-dom";
import { EditElement } from "./edit-element";
import { Spinner } from "~/views/components/Spinner";
import { uxToHex } from "~/logic/lib/util";
import { Col, Input, Box, Text, Row } from "@tlon/indigo-react";
import { Formik, Form, FormikHelpers } from "formik";
import { Contact } from "~/types/contact-update";
import { AsyncButton } from "~/views/components/AsyncButton";
import { ColorInput } from "~/views/components/ColorInput";
import GlobalApi from "~/logic/api/global";
import { ImageInput } from "~/views/components/ImageInput";
import { S3State } from "~/types";
interface ContactCardProps {
contact: Contact;
path: string;
api: GlobalApi;
s3: S3State;
}
const formSchema = Yup.object({
color: Yup.string(),
nickname: Yup.string(),
email: Yup.string().matches(
new RegExp(
String(
/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*/.source
) +
/@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/
.source
),
"Not a valid email"
),
phone: Yup.string().matches(
new RegExp(
String(/^\s*(?:\+?(\d{1,3}))?/.source) +
/([-. (]*(\d{3})[-. )]*)?((\d{3})[-. ]*(\d{2,4})(?:[-.x ]*(\d+))?)\s*$/
.source
),
"Not a valid phone"
),
website: Yup.string().matches(
new RegExp(
String(/[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}/.source) +
/\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/.source
),
"Not a valid website"
),
});
export function ContactCard(props: ContactCardProps) {
const us = `~${window.ship}`;
const { contact } = props;
const onSubmit = async (values: Contact, actions: FormikHelpers<Contact>) => {
try {
await Object.keys(values).reduce((acc, key) => {
const newValue = key !== "color" ? values[key] : uxToHex(values[key]);
if (newValue !== contact[key]) {
if (key === "avatar") {
return acc.then(() =>
props.api.contacts.edit(props.path, us, {
avatar: { url: newValue },
} as any)
);
}
return acc.then(() =>
props.api.contacts.edit(props.path, us, {
[key]: newValue,
} as any)
);
}
return acc;
}, Promise.resolve());
actions.setStatus({ success: null });
} catch (e) {
console.error(e);
actions.setStatus({ error: e.message });
}
};
const hexColor = contact.color ? `#${uxToHex(contact.color)}` : "#000000";
return (
<Box p={4} height="100%" overflowY="auto">
<Formik
validationSchema={formSchema}
initialValues={contact}
onSubmit={onSubmit}
>
<Form>
<Col>
<Row
borderBottom={1}
borderBottomColor="washedGray"
pb={3}
alignItems="center"
>
<Sigil size={32} classes="" color={hexColor} ship={us} />
<Box ml={2}>
<Text fontFamily="mono">{us}</Text>
</Box>
</Row>
<ImageInput mt={3} id="avatar" label="Avatar" s3={props.s3} />
<ColorInput id="color" label="Sigil Color" />
<Input id="nickname" label="Nickname" />
<Input id="email" label="Email" />
<Input id="phone" label="Phone" />
<Input id="website" label="Website" />
<Input id="notes" label="Notes" />
<AsyncButton primary loadingText="Updating..." border>
Save
</AsyncButton>
</Col>
</Form>
</Formik>
</Box>
);
}

View File

@ -18,30 +18,6 @@ export class GroupSidebar extends Component {
const selectedClass = (props.selected === 'me') ? 'bg-gray4 bg-gray1-d' : 'bg-white bg-gray0-d';
const rootIdentity = <Link
key={1}
to={'/~groups/me'}
>
<div
className={
'w-100 pl4 pt1 pb1 f9 flex justify-start content-center ' +
selectedClass}
>
<Sigil
ship={window.ship}
color="#000000"
classes="mix-blend-diff"
size={32}
/>
<p
className="f9 w-70 dib v-mid ml2 nowrap mono"
style={{ paddingTop: 6 }}
>
{cite(window.ship)}
</p>
</div>
</Link>;
const inviteItems =
Object.keys(props.invites)
.map((uid) => {
@ -127,8 +103,6 @@ export class GroupSidebar extends Component {
<p className="f9 pt4 pl4 green2 bn">Join Group</p>
</Link>
<Welcome contacts={props.contacts} />
<h2 className="f9 pt4 pr4 pb2 pl4 gray2 c-default">Your Identity</h2>
{rootIdentity}
{inviteItems}
<h2 className="f9 pt4 pr4 pb2 pl4 gray2 c-default">Groups</h2>
{groupItems}

View File

@ -1,9 +1,9 @@
import React from 'react';
import { Box, InputLabel, Radio, Input } from '@tlon/indigo-react';
import GlobalApi from '../../../../api/global';
import { S3State } from '../../../../types';
import { ImageInput } from './ImageInput';
import GlobalApi from '~/logic/api/global';
import { S3State } from '~/types';
import { ImageInput } from '~/views/components/ImageInput';
export type BgType = "none" | "url" | "color";

View File

@ -1,10 +1,26 @@
import React, { useMemo } from "react";
import { useDrag } from "react-dnd";
import { usePreview } from "react-dnd-multi-backend";
import { capitalize } from 'lodash';
import { capitalize } from "lodash";
import { TileTypeBasic, Tile } from "../../../../types/launch-update";
import { Box, Img, Text } from "@tlon/indigo-react";
import { Box, Img as _Img, Text } from "@tlon/indigo-react";
import styled from "styled-components";
// Need to change dojo image
const Img = styled(_Img)<{ invert?: boolean }>`
${(p) =>
p.theme.colors.white !== "rgba(255,255,255,1)" ? `filter: invert(1);` : ``}
${(p) =>
!p.invert
? ``
: p.theme.colors.white !== "rgba(255,255,255,1)"
? `
filter: invert(0);
`
: `filter: invert(1);`}
`;
interface DragTileProps {
index: number;
@ -27,6 +43,7 @@ function DragTileBox({ title, index, tile, ...props }: any) {
justifyContent="space-around"
flexDirection="column"
border={1}
borderColor="black"
height="100%"
width="100%"
style={{ cursor: "move" }}
@ -38,7 +55,13 @@ function DragTileBox({ title, index, tile, ...props }: any) {
function DragTileCustom({ index, title, style }: any) {
const tile = { type: { custom: null } };
return (
<DragTileBox bg="white" style={style} title={title} tile={tile} index={index}>
<DragTileBox
bg="white"
style={style}
title={title}
tile={tile}
index={index}
>
<Text fontSize={1}>{capitalize(title)}</Text>
</DragTileBox>
);
@ -55,11 +78,19 @@ function DragTileBasic(props: {
<DragTileBox
tile={{ type: props.tile }}
index={props.index}
bg={isDojo ? "black" : "white"}
bg={
"white" // isDojo ? "black" : "white"
}
style={props.style}
>
<Img width="48px" height="48px" src={tile.iconUrl} />
<Text color={isDojo ? "white" : "black"}>{tile.title}</Text>
<Img width="48px" height="48px" src={tile.iconUrl} invert={isDojo} />
<Text
color={
"black" // isDojo ? "white" : "black"
}
>
{tile.title}
</Text>
</DragTileBox>
);
}

View File

@ -1,68 +1,124 @@
import React from "react";
import { Box, Col, Center, Icon } from "@tlon/indigo-react";
import { Box, Text, Row, Col, Center, Icon } from "@tlon/indigo-react";
import { Sigil } from "~/logic/lib/sigil";
import { uxToHex, MOBILE_BROWSER_REGEX } from "~/logic/lib/util";
import Settings from "./components/settings";
import { Route, Link } from "react-router-dom";
import { ContactCard } from "../groups/components/lib/ContactCard";
const SidebarItem = ({ children, view, current }) => {
const selected = current === view;
const color = selected ? "blue" : "black";
return (
<Link to={`/~profile/${view}`}>
<Row
display="flex"
alignItems="center"
verticalAlign="middle"
py={1}
px={3}
backgroundColor={selected ? "washedBlue" : "white"}
>
<Icon mr={2} display="inline-block" icon="Circle" fill={color} />
<Text color={color} fontSize={0}>
{children}
</Text>
</Row>
</Link>
);
};
export default function ProfileScreen(props: any) {
const { ship, dark } = props;
return (
<Box height="100%" px={[0,3]} pb={[0,3]} borderRadius={1}>
<Box
height="100%"
width="100%"
display="flex"
borderRadius={1}
bg="white"
border={1}
borderColor="washedGray"
>
<Col
display={["none", "block"]}
collapse
borderRight={1}
borderColor="washedGray"
>
<Box borderBottom={1} borderBottomColor="washedGray">
<Route
path={["/~profile/:view", "/~profile"]}
render={({ match, history }) => {
const { view } = match.params;
const contact = props.contacts?.["/~/default"]?.[window.ship];
const sigilColor = contact?.color
? `#${uxToHex(contact.color)}`
: dark
? "#FFFFFF"
: "#000000";
if(!contact) {
return null;
}
if (!view && !MOBILE_BROWSER_REGEX.test(window.navigator.userAgent)) {
history.replace("/~profile/settings");
}
return (
<Box height="100%" px={[0, 3]} pb={[0, 3]} borderRadius={1}>
<Box
bg="black"
borderRadius={8}
margin={4}
height={128}
width={128}
display="flex"
justifyContent="center"
alignItems="center"
height="100%"
width="100%"
display="grid"
gridTemplateColumns={["100%", "200px 1fr"]}
gridTemplateRows={["48px 1fr", "1fr"]}
borderRadius={1}
bg="white"
border={1}
borderColor="washedGray"
>
<Sigil
ship={`~${ship}`}
size={80}
color={dark ? "#FFFFFF" : "#000000"}
/>
<Col
display={!view ? "flex" : ["none", "flex"]}
alignItems="center"
borderRight={1}
borderColor="washedGray"
>
<Box width="100%" borderBottom={1} borderBottomColor="washedGray">
<Box
mx="auto"
bg={sigilColor}
borderRadius={8}
my={4}
height={128}
width={128}
display="flex"
justifyContent="center"
alignItems="center"
>
<Sigil ship={`~${ship}`} size={80} color={sigilColor} />
</Box>
</Box>
<Box width="100%" py={3}>
<SidebarItem current={view} view="settings">
Ship Settings
</SidebarItem>
<SidebarItem current={view} view="identity">
Your Identity
</SidebarItem>
</Box>
</Col>
<Box
display={!view ? "none" : ["flex", "none"]}
alignItems="center"
px={3}
borderBottom={1}
borderBottomColor="washedGray"
>
<Link to="/~profile">{"<- Back"}</Link>
</Box>
<Box overflowY="auto" flexGrow={1}>
{view === "settings" && <Settings {...props} />}
{view === "identity" && (
<ContactCard
contact={contact}
path="/~/default"
api={props.api}
s3={props.s3}
/>
)}
</Box>
</Box>
</Box>
<Box py={4}>
<Box
display="flex"
alignItems="center"
verticalAlign="middle"
fontSize={0}
py={1}
px={3}
color="blue"
backgroundColor="washedBlue"
>
<Icon mr={2} display="inline-block" icon="Circle" fill="blue" />
Ship Settings
</Box>
</Box>
</Col>
<Box overflowY="auto" flexGrow={1}>
<Settings {...props} />
</Box>
</Box>
</Box>
);
}}
></Route>
);
}

View File

@ -0,0 +1,64 @@
import React from "react";
import { useField } from "formik";
import styled from "styled-components";
import { Col, InputLabel, Row, Box, ErrorMessage } from "@tlon/indigo-react";
import { uxToHex, hexToUx } from "~/logic/lib/util";
const Input = styled.input`
background-color: ${ p => p.theme.colors.white };
color: ${ p => p.theme.colors.black };
box-sizing: border-box;
border: 1px solid;
border-right: none;
border-color: ${(p) => p.theme.colors.lightGray};
border-top-left-radius: ${(p) => p.theme.radii[2]}px;
border-bottom-left-radius: ${(p) => p.theme.radii[2]}px;
padding: ${(p) => p.theme.space[2]}px;
font-size: 12px;
line-height: 1.2;
`;
type ColorInputProps = Parameters<typeof Col>[0] & {
id: string;
label: string;
}
export function ColorInput(props: ColorInputProps) {
const { id, label, ...rest } = props;
const [{ value }, { error }, { setValue }] = useField(id);
const hex = value.substr(2).replace('.', '');
const padded = hex.padStart(6, '0');
const onChange = (e: any) => {
const { value: newValue } = e.target as HTMLInputElement;
const valid = newValue.match(/^(\d|[a-f]|[A-F]){0,6}$/);
if(!valid) {
return;
}
const result = hexToUx(newValue);
setValue(result);
};
return (
<Col {...rest}>
<InputLabel htmlFor={id}>{label}</InputLabel>
<Row mt={2}>
<Input onChange={onChange} value={hex} />
<Box
borderBottomRightRadius={1}
borderTopRightRadius={1}
border={1}
borderLeft={0}
borderColor="lightGray"
width="32px"
alignSelf="stretch"
bg={`#${padded}`}
/>
</Row>
<ErrorMessage mt="2">{error}</ErrorMessage>
</Col>
);
}

View File

@ -6,19 +6,16 @@ import { useField } from "formik";
import { S3State } from "~/types/s3-update";
import { useS3 } from "~/logic/lib/useS3";
interface ImageInputProps {
type ImageInputProps = Parameters<typeof Box>[0] & {
id: string;
name: string;
label: string;
url: string;
api: GlobalApi;
s3: S3State;
}
};
export function ImageInput(props: ImageInputProps) {
const { name, id, label, url, api } = props;
const { id, label, s3, ...rest } = props;
const { uploadDefault, canUpload } = useS3(props.s3);
const { uploadDefault, canUpload } = useS3(s3);
const [uploading, setUploading] = useState(false);
@ -47,8 +44,8 @@ export function ImageInput(props: ImageInputProps) {
}, [ref]);
return (
<Box display="flex">
<Input type="text" label={label} id={id} />
<Box {...rest} display="flex">
<Input disabled={uploading} type="text" label={label} id={id} />
{canUpload && (
<>
<Button

View File

@ -1,50 +1,40 @@
import React from 'react';
import { Box, Text } from '@tlon/indigo-react';
import React from "react";
import { Box, Text } from "@tlon/indigo-react";
const ReconnectBox = ({ color, children, onClick }) => (
<Box
ml={2}
px={2}
py={1}
display="flex"
color={color}
bg="white"
alignItems="center"
border={1}
verticalAlign="middle"
lineHeight="0"
borderRadius={2}
style={{ cursor: "pointer" }}
onClick={onClick}
>
<Text color={color}>{children}</Text>
</Box>
);
const ReconnectButton = ({ connection, subscription }) => {
const connectedStatus = connection || 'connected';
const connectedStatus = connection || "connected";
const reconnect = subscription.restart.bind(subscription);
if (connectedStatus === 'disconnected') {
if (connectedStatus === "disconnected") {
return (
<>
<Box
ml={2}
px={2}
py={1}
display='inline-block'
color='red'
bg="white"
border={1}
verticalAlign="middle"
lineHeight='0'
borderRadius={2}
style={{ cursor: 'pointer' }}
onClick={reconnect}>
<Text color='red'>Reconnect </Text>
</Box>
</>
<ReconnectBox onClick={reconnect} color="red">
Reconnect
</ReconnectBox>
);
} else if (connectedStatus === 'reconnecting') {
return (
<>
<Box
ml={2}
px={2}
py={1}
bg='white'
lineHeight="0"
verticalAlign="middle"
display='inline-block'
color='yellow'
border={1}
borderRadius={2}>
<Text color='yellow'>Reconnecting</Text>
</Box>
</>
);
} else {
return null;
}
};
} else if (connectedStatus === "reconnecting") {
return <ReconnectBox color="yellow">Reconnecting</ReconnectBox>;
} else {
return null;
}
};
export default ReconnectButton;