landscape: migrate global components to indigo-react

Fixes urbit/landscape#50.
This commit is contained in:
Matilde Park 2020-11-03 16:48:44 -05:00
parent 2834d6b2e6
commit d9d7edf720
12 changed files with 36 additions and 1230 deletions

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Text, Box, Col } from '@tlon/indigo-react'; import { Text, Box, Col, Button, BaseAnchor } from '@tlon/indigo-react';
import { RouteComponentProps, withRouter } from 'react-router-dom'; import { RouteComponentProps, withRouter } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
@ -13,6 +13,8 @@ const Summary = styled.summary`
color: ${ p => p.theme.colors.black }; color: ${ p => p.theme.colors.black };
`; `;
const Details = styled.details``;
class ErrorComponent extends Component<ErrorProps> { class ErrorComponent extends Component<ErrorProps> {
render () { render () {
const { code, error, history, description } = this.props; const { code, error, history, description } = this.props;
@ -25,20 +27,20 @@ class ErrorComponent extends Component<ErrorProps> {
</Box> </Box>
{ description && (<Box mb={4}><Text>{description}</Text></Box>) } { description && (<Box mb={4}><Text>{description}</Text></Box>) }
{error && ( {error && (
<Box mb={4} style={{maxWidth: '100%'}}> <Box mb={4} style={{ maxWidth: '100%' }}>
<Box mb={2}> <Box mb={2}>
<Text fontFamily="mono"><code>&ldquo;{error.message}&rdquo;</code></Text> <Text mono>&ldquo;{error.message}&rdquo;</Text>
</Box> </Box>
<details> <Details>
<Summary>Stack trace</Summary> <Summary>Stack trace</Summary>
<Text><pre style={{ wordWrap: 'break-word', overflowX: 'scroll' }} className="tl">{error.stack}</pre></Text> <Text mono p='1' borderRadius='1' display='block' overflow='auto' backgroundColor='washedGray' style={{ whiteSpace: 'pre', wordWrap: 'break-word' }}>{error.stack}</Text>
</details> </Details>
</Box> </Box>
)} )}
<Text mb={4} textAlign="center">If this is unexpected, email <code>support@tlon.io</code> or <a className="bb" href="https://github.com/urbit/urbit/issues/new/choose">submit an issue</a>.</Text> <Text mb={4} textAlign="center">If this is unexpected, email <code>support@tlon.io</code> or <BaseAnchor color='black' href="https://github.com/urbit/urbit/issues/new/choose">submit an issue</BaseAnchor>.</Text>
{history.length > 1 {history.length > 1
? <button className="bg-light-green green2 pa2 pointer" onClick={() => history.go(-1) }>Go back</button> ? <Button primary onClick={() => history.go(-1) }>Go back</Button>
: <button className="bg-light-green green2 pa2 pointer" onClick={() => history.push('/') }>Go home</button> : <Button primary onClick={() => history.push('/') }>Go home</Button>
} }
</Col> </Col>
); );

View File

@ -1,350 +0,0 @@
import React, { Component } from 'react';
import _, { capitalize } from 'lodash';
import { Virtuoso as VirtualList } from 'react-virtuoso';
import { cite, deSig } from '~/logic/lib/util';
import { roleForShip, resourceFromPath } from '~/logic/lib/group';
import {
Group,
InvitePolicy,
OpenPolicy,
roleTags,
Groups,
} from '~/types/group-update';
import { Path, PatpNoSig, Patp } from '~/types/noun';
import GlobalApi from '~/logic/api/global';
import { Menu, MenuButton, MenuList, MenuItem, Text } from '@tlon/indigo-react';
import InviteSearch, { Invites } from './InviteSearch';
import { Spinner } from './Spinner';
import { Rolodex } from '~/types/contact-update';
import { Associations } from '~/types/metadata-update';
class GroupMember extends Component<{ ship: Patp; options: any[] }, {}> {
render() {
const { ship, options, children } = this.props;
return (
<div className='flex justify-between f9 items-center'>
<div className='flex flex-column flex-shrink-0'>
<Text mono mr='2'>{`${cite(ship)}`}</Text>
{children}
</div>
{options.length > 0 && (
<Menu>
<MenuButton width='min-content'>Options</MenuButton>
<MenuList>
{options.map(({ onSelect, text }) => (
<MenuItem onSelect={onSelect}><Text fontsize='0'>{text}</Text></MenuItem>
))}
</MenuList>
</Menu>
)}
</div>
);
}
}
class Tag extends Component<{ description: string; onRemove?: () => any }, {}> {
render() {
const { description, onRemove } = this.props;
return (
<div className='br-pill ba b-black b--white-d r-full items-center ph2 f9 mr2 flex'>
<Text>{description}</Text>
{Boolean(onRemove) && (
<Text onClick={onRemove} ml='1' style={{ cursor: 'pointer' }}>
</Text>
)}
</div>
);
}
}
interface GroupViewAppTag {
tag: string;
app: string;
desc: string;
addDesc: string;
}
interface GroupViewProps {
group: Group;
groups: Groups;
contacts: Rolodex;
associations: Associations;
resourcePath: Path;
appTags?: GroupViewAppTag[];
api: GlobalApi;
className: string;
permissions?: boolean;
inviteShips: (ships: PatpNoSig[]) => Promise<any>;
}
export class GroupView extends Component<
GroupViewProps,
{ invites: Invites; awaiting: boolean }
> {
constructor(props) {
super(props);
this.setInvites = this.setInvites.bind(this);
this.inviteShips = this.inviteShips.bind(this);
this.state = {
invites: {
ships: [],
groups: [],
},
awaiting: false
};
}
removeUser(who: PatpNoSig) {
return () => {
const resource = resourceFromPath(this.props.resourcePath);
this.props.api.groups.remove(resource, [`~${who}`]);
};
}
banUser(who: PatpNoSig) {
const resource = resourceFromPath(this.props.resourcePath);
this.props.api.groups.changePolicy(resource, {
open: {
banShips: [`~${who}`],
},
});
}
allowUser(who: PatpNoSig) {
const resource = resourceFromPath(this.props.resourcePath);
this.props.api.groups.changePolicy(resource, {
open: {
allowShips: [`~${who}`],
},
});
}
removeInvite(who: PatpNoSig) {
const resource = resourceFromPath(this.props.resourcePath);
this.props.api.groups.changePolicy(resource, {
invite: {
removeInvites: [`~${who}`],
},
});
}
removeTag(who: PatpNoSig, tag: any) {
const resource = resourceFromPath(this.props.resourcePath);
return this.props.api.groups.removeTag(resource, tag, [`~${who}`]);
}
addTag(who: PatpNoSig, tag: any) {
const resource = resourceFromPath(this.props.resourcePath);
return this.props.api.groups.addTag(resource, tag, [`~${who}`]);
}
isAdmin(): boolean {
const role = roleForShip(this.props.group, window.ship);
return role === 'admin';
}
optionsForShip(ship: Patp, missing: GroupViewAppTag[]) {
const { permissions, resourcePath, group } = this.props;
const resource = resourceFromPath(resourcePath);
let options: any[] = [];
if (!permissions) {
return options;
}
const role = roleForShip(group, ship);
const myRole = roleForShip(group, window.ship);
if (role === 'admin' || resource.ship === ship) {
return [];
}
if (
'open' in group.policy // If blacklist, not whitelist
&& (this.isAdmin()) // And we can ban people (TODO: add || role === 'moderator')
&& ship !== window.ship // We can't ban ourselves
) {
options.unshift({ text: 'Ban', onSelect: () => this.banUser(ship) });
}
if (this.isAdmin() && !role) {
options = options.concat(
missing.map(({ addDesc, tag, app }) => ({
text: addDesc,
onSelect: () => this.addTag(ship, { tag, app }),
}))
);
options = options.concat(
roleTags.reduce(
(acc, role) => [
...acc,
{
text: `Make ${capitalize(role)}`,
onSelect: () => this.addTag(ship, { tag: role }),
},
],
[] as any[]
)
);
}
return options;
}
doIfAdmin<Ret>(f: () => Ret) {
return this.isAdmin() ? f : undefined;
}
getAppTags(ship: Patp): [GroupViewAppTag[], GroupViewAppTag[]] {
const { tags } = this.props.group;
const { appTags } = this.props;
return _.partition(appTags, ({ app, tag }) => {
return tags?.[app]?.[tag]?.has(ship);
});
}
memberElements() {
const { group, permissions } = this.props;
const { members } = group;
const isAdmin = this.isAdmin();
return Array.from(members).map((ship) => {
const role = roleForShip(group, deSig(ship));
const onRoleRemove =
role && isAdmin
? () => {
this.removeTag(ship, { tag: role });
}
: undefined;
const [present, missing] = this.getAppTags(ship);
const options = this.optionsForShip(ship, missing);
return (
<GroupMember ship={ship} options={options}>
{((permissions && role) || present.length > 0) && (
<div className='flex mt1'>
{role && (
<Tag
onRemove={onRoleRemove}
description={capitalize(role)}
/>
)}
{present.map((tag, idx) => (
<Tag
key={idx}
onRemove={this.doIfAdmin(() =>
this.removeTag(ship, tag)
)}
description={tag.desc}
/>
))}
</div>
)}
</GroupMember>
);
})
}
setInvites(invites: Invites) {
this.setState({ invites });
}
inviteShips(invites: Invites) {
const { props, state } = this;
this.setState({ awaiting: true });
props.inviteShips(invites.ships).then(() => {
this.setState({ invites: { ships: [], groups: [] }, awaiting: false });
});
}
renderInvites(policy: InvitePolicy) {
const { props, state } = this;
const ships = Array.from(policy.invite.pending || []);
const options = (ship: Patp) => [
{ text: 'Uninvite', onSelect: () => this.removeInvite(ship) },
];
return (
<div className='flex flex-column'>
<div className='f9 gray2 mt6 mb3'>Pending</div>
{ships.map((ship) => (
<GroupMember key={ship} ship={ship} options={options(ship)} />
))}
{ships.length === 0 && <Text>No ships are pending</Text>}
{props.inviteShips && this.isAdmin() && (
<>
<div className='f9 gray2 mt6 mb3'>Invite</div>
<div style={{ width: 'calc(min(400px, 100%)' }}>
<InviteSearch
groups={props.groups}
contacts={props.contacts}
shipResults
groupResults={false}
invites={state.invites}
setInvite={this.setInvites}
associations={props.associations}
/>
</div>
<a
onClick={() => this.inviteShips(state.invites)}
className='db ba tc w-auto mr-auto mt2 ph2 black white-d f8 pointer'
>
Invite
</a>
</>
)}
</div>
);
}
renderBanned(policy: OpenPolicy) {
const ships = Array.from(policy.open.banned || []);
const options = (ship: Patp) => [
{ text: 'Unban', onSelect: () => this.allowUser(ship) },
];
return (
<div className='flex flex-column'>
<div className='f9 gray2 mt6 mb3'>Banned</div>
{ships.map((ship) => (
<GroupMember key={ship} ship={ship} options={options(ship)} />
))}
{ships.length === 0 && <Text>No ships are banned</Text>}
</div>
);
}
render() {
const { group, resourcePath, className } = this.props;
const resource = resourceFromPath(resourcePath);
const memberElements = this.memberElements();
return (
<div className={className}>
<div className='flex flex-column'>
<Text gray display='block'>Host</Text>
<div className='flex justify-between mt3'>
<Text mono mr='2'>{cite(resource.ship)}</Text>
</div>
</div>
{'invite' in group.policy && this.renderInvites(group.policy)}
{'open' in group.policy && this.renderBanned(group.policy)}
<div className='flex flex-column'>
<div className='f9 gray2 mt6 mb3'>Members</div>
<VirtualList
style={{ height: '500px', width: '100%' }}
totalCount={memberElements.length}
item={(index) => <div key={index} className='flex flex-column pv3'>{memberElements[index]}</div>}
/>
</div>
<Spinner
awaiting={this.state.awaiting}
classes='mt4'
text='Inviting to chat...'
/>
</div>
);
}
}

View File

@ -7,6 +7,7 @@ import {
Button, Button,
Label, Label,
ErrorLabel, ErrorLabel,
BaseInput
} from "@tlon/indigo-react"; } from "@tlon/indigo-react";
import { useField } from "formik"; import { useField } from "formik";
import { S3State } from "~/types/s3-update"; import { S3State } from "~/types/s3-update";
@ -75,7 +76,7 @@ export function ImageInput(props: ImageInputProps) {
> >
{uploading ? "Uploading" : "Upload"} {uploading ? "Uploading" : "Upload"}
</Button> </Button>
<input <BaseInput
style={{ display: "none" }} style={{ display: "none" }}
type="file" type="file"
id="fileElement" id="fileElement"

View File

@ -1,540 +0,0 @@
import React, { Component, createRef } from 'react';
import _ from 'lodash';
import Mousetrap from 'mousetrap';
import urbitOb from 'urbit-ob';
import { Sigil } from '~/logic/lib/sigil';
import { PatpNoSig, Path } from '~/types/noun';
import { Groups} from '~/types/group-update';
import { Rolodex, Contact } from '~/types/contact-update';
import { Associations } from '~/types/metadata-update';
export interface Invites {
ships: PatpNoSig[];
groups: string[][];
}
interface InviteSearchProps {
groups: Groups;
contacts: Rolodex;
groupResults: boolean;
shipResults: boolean;
invites: Invites;
setInvite: (inv: Invites) => void;
disabled?: boolean;
associations?: Associations;
}
interface InviteSearchState {
groups: string[][];
peers: PatpNoSig[];
contacts: Map<PatpNoSig, string[]>;
searchValue: string;
searchResults: Invites;
selected: PatpNoSig | Path | null;
inviteError: boolean;
}
export class InviteSearch extends Component<
InviteSearchProps,
InviteSearchState
> {
textarea: React.RefObject<HTMLTextAreaElement> = createRef();
constructor(props) {
super(props);
this.state = {
groups: [],
peers: [],
contacts: new Map(),
searchValue: '',
searchResults: {
groups: [],
ships: [],
},
selected: null,
inviteError: false,
};
this.search = this.search.bind(this);
}
componentDidMount() {
this.peerUpdate();
this.bindShortcuts();
}
componentDidUpdate(prevProps) {
if (prevProps !== this.props) {
this.peerUpdate();
}
}
peerUpdate() {
const groups = Array.from(Object.keys(this.props.contacts))
.filter((e) => !e.startsWith('/~/'))
.map((e) => {
const eachGroup: Path[] = [];
eachGroup.push(e);
if (this.props.associations) {
let name = e;
if (e in this.props.associations) {
name =
this.props.associations[e].metadata.title !== ''
? this.props.associations[e].metadata.title
: e;
}
eachGroup.push(name);
}
return Array.from(eachGroup);
});
let peers: PatpNoSig[] = [];
const peerSet = new Set<PatpNoSig>();
const contacts = new Map();
_.map(this.props.groups, (group, path) => {
if (group.members.size > 0) {
const groupEntries = group.members.values();
for (const member of groupEntries) {
peerSet.add(member);
}
}
const groupContacts = this.props.contacts[path];
if (groupContacts) {
const groupEntries = group.members.values();
for (const member of groupEntries) {
if (groupContacts[member]) {
if (contacts.has(member)) {
contacts
.get(member)
.push(groupContacts[member].nickname);
} else {
contacts.set(member, [
groupContacts[member].nickname,
]);
}
}
}
}
});
peers = Array.from(peerSet);
this.setState({ groups: groups, peers: peers, contacts: contacts });
}
search(event) {
const searchTerm = event.target.value.toLowerCase().replace('~', '');
const { state, props } = this;
this.setState({ searchValue: event.target.value });
if (searchTerm.length < 1) {
this.setState({ searchResults: { groups: [], ships: [] } });
}
if (searchTerm.length > 0) {
if (state.inviteError === true) {
this.setState({ inviteError: false });
}
let groupMatches = !props.groupResults ? [] :
state.groups.filter((e) => {
return (
e[0].includes(searchTerm) || e[1].toLowerCase().includes(searchTerm)
);
});
let shipMatches = !props.shipResults ? [] :
state.peers.filter((e) => {
return (
e.includes(searchTerm) && !props.invites.ships.includes(e)
);
});
for (const contact of state.contacts.keys()) {
const thisContact = state.contacts.get(contact) || [];
const match = thisContact.filter((e) => {
return e.toLowerCase().includes(searchTerm);
});
if (match.length > 0) {
if (!(contact in shipMatches) && props.shipResults) {
shipMatches.push(contact);
}
}
}
let isValid = true;
if (!urbitOb.isValidPatp('~' + searchTerm)) {
isValid = false;
}
if (props.shipResults && isValid && shipMatches.findIndex((s) => s === searchTerm) < 0) {
shipMatches.unshift(searchTerm);
}
const { selected } = state;
const groupIdx = groupMatches.findIndex(([path]) => path === selected);
const shipIdx = shipMatches.findIndex((ship) => ship === selected);
const staleSelection = groupIdx < 0 && shipIdx < 0;
if (!selected || staleSelection) {
const newSelection = _.get(groupMatches, '[0][0]') || shipMatches[0];
this.setState({ selected: newSelection });
}
if (searchTerm.length < 3) {
groupMatches = groupMatches
.filter(([, name]) =>
name
.toLowerCase()
.split(' ')
.some((s) => s.startsWith(searchTerm))
)
.sort((a, b) => a[1].length - b[1].length);
shipMatches = shipMatches.slice(0, 3);
}
this.setState({
searchResults: { groups: groupMatches, ships: shipMatches },
});
}
}
bindShortcuts() {
const mousetrap = Mousetrap(this.textarea.current);
mousetrap.bind(['down', 'tab'], (e) => {
e.preventDefault();
e.stopPropagation();
this.nextSelection();
});
mousetrap.bind(['up', 'shift+tab'], (e) => {
e.preventDefault();
e.stopPropagation();
this.nextSelection(true);
});
mousetrap.bind('enter', (e) => {
e.preventDefault();
e.stopPropagation();
const { selected } = this.state;
if (selected && selected.startsWith('/')) {
this.addGroup(selected);
} else if (selected) {
this.addShip(selected);
}
this.setState({ selected: null });
});
}
nextSelection(backward = false) {
const { selected, searchResults } = this.state;
const { ships, groups } = searchResults;
if (!selected) {
return;
}
let groupIdx = groups.findIndex(([path]) => path === selected);
let shipIdx = ships.findIndex((ship) => ship === selected);
if (groupIdx >= 0) {
backward ? groupIdx-- : groupIdx++;
let selected = _.get(groups, [groupIdx, 0]);
if (groupIdx === groups.length) {
selected = ships.length === 0 ? groups[0][0] : ships[0];
}
if (groupIdx < 0) {
selected =
ships.length === 0
? groups[groups.length - 1][0]
: ships[ships.length - 1];
}
this.setState({ selected });
return;
}
if (shipIdx >= 0) {
backward ? shipIdx-- : shipIdx++;
let selected = ships[shipIdx];
if (shipIdx === ships.length) {
selected = groups.length === 0 ? ships[0] : groups[0][0];
}
if (shipIdx < 0) {
selected =
groups.length === 0
? ships[ships.length - 1]
: groups[groups.length - 1][0];
}
this.setState({ selected });
}
}
deleteGroup() {
const { ships } = this.props.invites;
this.setState({
searchValue: '',
searchResults: { groups: [], ships: [] },
});
this.props.setInvite({ groups: [], ships: ships });
}
deleteShip(ship) {
let { groups, ships } = this.props.invites;
this.setState({
searchValue: '',
searchResults: { groups: [], ships: [] },
});
ships = ships.filter((e) => {
return e !== ship;
});
this.props.setInvite({ groups: groups, ships: ships });
}
addGroup(group) {
this.setState({
searchValue: '',
searchResults: { groups: [], ships: [] },
});
this.props.setInvite({ groups: [group], ships: [] });
}
addShip(ship) {
const { groups, ships } = this.props.invites;
this.setState({
searchValue: '',
searchResults: { groups: [], ships: [] },
});
if (!ships.includes(ship)) {
ships.push(ship);
}
if (groups.length > 0) {
return false;
}
this.props.setInvite({ groups: groups, ships: ships });
return true;
}
submitShipToAdd(ship) {
const searchTerm = ship.toLowerCase().replace('~', '').trim();
let isValid = true;
if (!urbitOb.isValidPatp('~' + searchTerm)) {
isValid = false;
}
if (!isValid) {
this.setState({ inviteError: true, searchValue: '' });
} else if (isValid) {
this.addShip(searchTerm);
this.setState({ searchValue: '' });
}
}
render() {
const { props, state } = this;
let searchDisabled = props.disabled;
if (props.invites.groups) {
if (props.invites.groups.length > 0) {
searchDisabled = true;
}
}
let participants = <div />;
let searchResults = <div />;
let placeholder = '';
if (props.shipResults) {
placeholder = 'ships';
}
if (props.groupResults) {
if (placeholder.length > 0) {
placeholder = placeholder + ' or ';
}
placeholder = placeholder + 'existing groups';
}
placeholder = 'Search for ' + placeholder;
let invErrElem = <span />;
if (state.inviteError) {
invErrElem = (
<span className='f9 inter red2 db pt2'>
Invited ships must be validly formatted ship names.
</span>
);
}
if (
state.searchResults.groups.length > 0 ||
state.searchResults.ships.length > 0
) {
const groupHeader =
state.searchResults.groups.length > 0 ? (
<p className='f9 gray2 ph3 pb2'>Groups</p>
) : (
''
);
const shipHeader =
state.searchResults.ships.length > 0 ? (
<p className='f9 gray2 pv2 ph3'>Ships</p>
) : (
''
);
const groupResults = state.searchResults.groups.map((group) => {
return (
<li
key={group[0]}
className={
'list white-d f8 pv2 ph3 pointer' +
' hover-bg-gray4 hover-bg-gray1-d ' +
(group[1] ? 'inter' : 'mono') +
(group[0] === state.selected ? ' bg-gray1-d bg-gray4' : '')
}
onClick={() => this.addGroup(group[0])}
>
<span className='mix-blend-diff white'>
{group[1] ? group[1] : group[0]}
</span>
</li>
);
});
const shipResults = Array.from(new Set(state.searchResults.ships)).map((ship) => {
const nicknames = (this.state.contacts.get(ship) || [])
.filter((e) => {
return !(e === '');
})
.join(', ');
return (
<li
key={ship}
className={
'list mono white-d f8 pv1 ph3 pointer' +
' hover-bg-gray4 hover-bg-gray1-d relative' +
(ship === state.selected ? ' bg-gray1-d bg-gray4' : '')
}
onClick={(e) => this.addShip(ship)}
>
<Sigil
ship={'~' + ship}
size={24}
color='#000000'
classes='mix-blend-diff v-mid'
/>
<span className='v-mid ml2 mw5 truncate dib mix-blend-diff white'>
{'~' + ship}
</span>
<span className='absolute right-1 di truncate mw4 inter f9 pt1 mix-blend-diff white'>
{nicknames}
</span>
</li>
);
});
searchResults = (
<div
className={
'absolute bg-white bg-gray0-d white-d' +
' pv3 z-1 w-100 mt1 ba b--white-d overflow-y-scroll mh-16'
}
>
{groupHeader}
{groupResults}
{shipHeader}
{shipResults}
</div>
);
}
const groupInvites = props.invites.groups || [];
const shipInvites = props.invites.ships || [];
if (groupInvites.length > 0 || shipInvites.length > 0) {
const groups = groupInvites.map((group) => {
return (
<span
key={group[0]}
className={
'f9 mono black pa2 bg-gray5 bg-gray1-d' +
' ba b--gray4 b--gray2-d white-d dib mr2 mt2 c-default'
}
>
{group}
<span
className='white-d ml3 mono pointer'
onClick={(e) => this.deleteGroup()}
>
x
</span>
</span>
);
});
const ships = shipInvites.map((ship) => {
return (
<span
key={ship}
className={
'f9 mono black pa2 bg-gray5 bg-gray1-d' +
' ba b--gray4 b--gray2-d white-d dib mr2 mt2 c-default'
}
>
{'~' + ship}
<span
className='white-d ml3 mono pointer'
onClick={(e) => this.deleteShip(ship)}
>
x
</span>
</span>
);
});
participants = (
<div
className={
'f9 gray2 bb bl br b--gray3 b--gray2-d bg-gray0-d ' +
'white-d pa3 db w-100 inter'
}
>
<span className='db gray2'>Participants</span>
{groups} {ships}
</div>
);
}
return (
<div className='relative'>
<img
src='/~landscape/img/search.png'
className='absolute invert-d'
style={{
height: 16,
width: 16,
top: 14,
left: 12,
}}
/>
<textarea
ref={this.textarea}
className={
'f7 ba b--gray3 b--gray2-d bg-gray0-d white-d pa3 w-100' +
' db focus-b--black focus-b--white-d'
}
placeholder={placeholder}
disabled={searchDisabled}
rows={1}
spellCheck={false}
style={{
resize: 'none',
paddingLeft: 36,
}}
onChange={this.search}
value={state.searchValue}
/>
{searchResults}
{participants}
{invErrElem}
</div>
);
}
}
export default InviteSearch;

View File

@ -1,6 +1,6 @@
import React, { PureComponent, Fragment } from 'react'; import React, { PureComponent, Fragment } from 'react';
import { LocalUpdateRemoteContentPolicy } from "~/types/local-update"; import { LocalUpdateRemoteContentPolicy } from "~/types/local-update";
import { Button } from '@tlon/indigo-react'; import { BaseAnchor, BaseImage, Box, Button } from '@tlon/indigo-react';
import { hasProvider } from 'oembed-parser'; import { hasProvider } from 'oembed-parser';
import EmbedContainer from 'react-oembed-container'; import EmbedContainer from 'react-oembed-container';
import { memoize } from 'lodash'; import { memoize } from 'lodash';
@ -70,15 +70,15 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
} }
wrapInLink(contents) { wrapInLink(contents) {
return (<a return (<BaseAnchor
href={this.props.url} href={this.props.url}
style={{ color: 'inherit' }} style={{ color: 'inherit', textDecoration: 'none' }}
className={`word-break-all ${(typeof contents === 'string') ? 'bb' : ''}`} className={`word-break-all ${(typeof contents === 'string') ? 'bb' : ''}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
{contents} {contents}
</a>); </BaseAnchor>);
} }
render() { render() {
@ -102,7 +102,7 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
if (isImage && remoteContentPolicy.imageShown) { if (isImage && remoteContentPolicy.imageShown) {
return this.wrapInLink( return this.wrapInLink(
<img <BaseImage
src={url} src={url}
style={style} style={style}
onLoad={onLoad} onLoad={onLoad}
@ -157,8 +157,11 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
> >
{this.state.unfold ? 'collapse' : 'expand'} {this.state.unfold ? 'collapse' : 'expand'}
</Button> : null} </Button> : null}
<div <Box
className={'embed-container mb2 w-100 w-75-l w-50-xl ' + (this.state.unfold ? 'db' : 'dn')} mb='2'
width='100%'
display={this.state.unfold ? 'block' : 'none'}
className='embed-container'
style={style} style={style}
onLoad={onLoad} onLoad={onLoad}
{...oembedProps} {...oembedProps}
@ -169,7 +172,7 @@ export default class RemoteContent extends PureComponent<RemoteContentProps, Rem
<div dangerouslySetInnerHTML={{__html: this.state.embed.html}}></div> <div dangerouslySetInnerHTML={{__html: this.state.embed.html}}></div>
</EmbedContainer> </EmbedContainer>
: null} : null}
</div> </Box>
</Fragment> </Fragment>
); );
} else { } else {

View File

@ -1,39 +0,0 @@
import React, { Component } from 'react';
export class SidebarSwitcher extends Component {
render() {
const classes = this.props.classes ? this.props.classes : '';
const style = this.props.style || {};
const paddingTop = this.props.classes ? '0px' : '8px';
return (
<div className={classes} style={{ paddingTop: paddingTop, ...style }}>
<a
className='pointer flex-shrink-0'
onClick={() => {
this.props.api.local.sidebarToggle();
}}
>
<img
className='pr3 dn dib-m dib-l dib-xl'
src={
this.props.sidebarShown
? '/~landscape/img/ChatSwitcherLink.png'
: '/~landscape/img/ChatSwitcherClosed.png'
}
height='16'
width='16'
style={{
maxWidth: 16
}}
/>
</a>
</div>
);
}
}
export default SidebarSwitcher;

View File

@ -1,6 +1,7 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import normalizeWheel from 'normalize-wheel'; import normalizeWheel from 'normalize-wheel';
import { Box } from '@tlon/indigo-react';
interface VirtualScrollerProps { interface VirtualScrollerProps {
origin: 'top' | 'bottom'; origin: 'top' | 'bottom';
@ -332,13 +333,13 @@ export default class VirtualScroller extends PureComponent<VirtualScrollerProps,
}; };
return ( return (
<div ref={this.setWindow.bind(this)} onScroll={this.onScroll.bind(this)} style={{...style, ...{overflowY: 'scroll', transform }}}> <Box overflowY='scroll' ref={this.setWindow.bind(this)} onScroll={this.onScroll.bind(this)} style={{ ...style, ...{ transform } }}>
<div ref={this.scrollContainer} style={{ transform }}> <Box ref={this.scrollContainer} style={{ transform }}>
<div style={{ height: `${origin === 'top' ? startgap : endgap}px` }}></div> <Box style={{ height: `${origin === 'top' ? startgap : endgap}px` }}></Box>
{indexesToRender.map(render)} {indexesToRender.map(render)}
<div style={{ height: `${origin === 'top' ? endgap : startgap}px` }}></div> <Box style={{ height: `${origin === 'top' ? endgap : startgap}px` }}></Box>
</div> </Box>
</div> </Box>
); );
} }
} }

View File

@ -1,85 +0,0 @@
import React, { Component } from 'react';
import { Box, Text, Row, BaseInput } from '@tlon/indigo-react';
export class MetadataColor extends Component {
constructor(props) {
super(props);
this.state = {
color: props.initialValue
};
this.changeColor = this.changeColor.bind(this);
this.submitColor = this.submitColor.bind(this);
}
componentDidUpdate(prevProps) {
const { props } = this;
if (prevProps.initialValue !== props.initialValue) {
this.setState({ color: props.initialValue });
}
}
changeColor(event) {
this.setState({ color: event.target.value });
}
submitColor() {
const { props, state } = this;
let color = state.color;
if (color.startsWith('#')) {
color = state.color.substr(1);
}
const hexExp = /([0-9A-Fa-f]{6})/;
const hexTest = hexExp.exec(color);
if (!props.isDisabled && hexTest && (state.color !== props.initialValue)) {
props.setValue(color);
}
}
render() {
const { props, state } = this;
return (
<Box
width='100%'
mb='3'
opacity={(props.isDisabled) ? '0.3' : '1'}
>
<Text my='1' display='block' fontSize='1'>Change color</Text>
<Text fontSize='0' gray display='block' mb='3'>Give this {props.resource} a color when viewing group channels</Text>
<Row
position='relative'
maxWidth='10rem'
width='100%'
>
<Box
position='absolute'
height='16px'
width='16px'
backgroundColor={state.color}
style={{ top: 18, left: 11 }}
/>
<BaseInput
pl='5'
fontSize='1'
border='1px solid'
borderColor='gray'
backgroundColor='white'
pt='3'
pb='3'
pr='3'
display='block'
width='100%'
flex='auto'
color='black'
mr='3'
value={state.color}
disabled={props.isDisabled}
onChange={this.changeColor}
onBlur={this.submitColor}
/>
</Row>
</Box>
);
}
}

View File

@ -1,69 +0,0 @@
import React, { Component } from 'react';
import { Box, Text, Row, BaseInput } from '@tlon/indigo-react';
export class MetadataInput extends Component {
constructor(props) {
super(props);
this.state = {
value: props.initialValue
};
}
componentDidUpdate(prevProps) {
const { props } = this;
if (prevProps.initialValue !== props.initialValue) {
this.setState({ value: props.initialValue });
}
}
render() {
const {
title,
description,
isDisabled,
setValue
} = this.props;
return (
<Box
width='100%'
mb='3'
opacity={(isDisabled) ? '0.3' : '1'}
>
<Text display='block' fontSize='1' mb='1'>{title}</Text>
<Text display='block' mb='4' fontSize='0' gray>{description}</Text>
<Row
width='100%'
position='relative'
maxWidth='29rem'
>
<BaseInput
fontSize='1'
border='1px solid'
borderColor='gray'
backgroundColor='white'
p='3'
display='block'
color='black'
width='100'
flex='auto'
mr='3'
type="text"
value={this.state.value}
disabled={isDisabled}
onChange={(e) => {
this.setState({ value: e.target.value });
}}
onBlur={() => {
if (!isDisabled) {
setValue(this.state.value || '');
}
}}
/>
</Row>
</Box>
);
}
}

View File

@ -1,97 +0,0 @@
import React from 'react';
import { MetadataColor } from './color';
import { MetadataInput } from './input';
import { Box } from '@tlon/indigo-react';
import { uxToHex } from '~/logic/lib/util';
export const MetadataSettings = (props) => {
const {
isOwner,
association,
changeLoading,
api,
resource,
app,
module
} = props;
const title =
(props.association && 'metadata' in props.association) ?
association.metadata.title : '';
const description =
(props.association && 'metadata' in props.association) ?
association.metadata.description : '';
const color =
(props.association && 'metadata' in props.association) ?
`#${uxToHex(props.association.metadata.color)}` : '';
return (
<Box mt='6'>
<MetadataInput
title='Rename'
description={`Change the name of this ${resource}`}
isDisabled={!isOwner}
initialValue={title}
setValue={(val) => {
changeLoading(false, true, `Editing ${resource}...`, () => {
api.metadata.metadataAdd(
app,
association['app-path'],
association['group-path'],
val,
association.metadata.description,
association.metadata['date-created'],
uxToHex(association.metadata.color),
module
).then(() => {
changeLoading(false, false, '', () => {});
});
});
}} />
<MetadataInput
title='Change description'
description={`Change the description of this ${resource}`}
isDisabled={!isOwner}
initialValue={description}
setValue={(val) => {
changeLoading(false, true, `Editing ${resource}...`, () => {
api.metadata.metadataAdd(
app,
association['app-path'],
association['group-path'],
association.metadata.title,
val,
association.metadata['date-created'],
uxToHex(association.metadata.color),
module
).then(() => {
changeLoading(false, false, '', () => {});
});
});
}} />
<MetadataColor
initialValue={color}
isDisabled={!isOwner}
resource={resource}
setValue={(val) => {
changeLoading(false, true, `Editing ${resource}...`, () => {
props.api.metadata.metadataAdd(
app,
association['app-path'],
association['group-path'],
association.metadata.title,
association.metadata.description,
association.metadata['date-created'],
val,
module
).then(() => {
changeLoading(false, false, '', () => {});
});
});
}} />
</Box>
);
};

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react' import React, { Component } from 'react'
import { Box, Text, Icon } from "@tlon/indigo-react"; import { BaseInput, Box, Text, Icon } from "@tlon/indigo-react";
import S3Client from '~/logic/lib/s3'; import S3Client from '~/logic/lib/s3';
import { Spinner } from './Spinner'; import { Spinner } from './Spinner';
@ -117,7 +117,6 @@ export class S3Upload extends Component<S3UploadProps, S3UploadState> {
this.setState({ isUploading: false }); this.setState({ isUploading: false });
}); });
}, 200); }, 200);
} }
onClick() { onClick() {
@ -141,8 +140,8 @@ export class S3Upload extends Component<S3UploadProps, S3UploadState> {
const display = children || <Icon icon='ArrowNorth' />; const display = children || <Icon icon='ArrowNorth' />;
return ( return (
<> <>
<input <BaseInput
className="dn" display='none'
type="file" type="file"
id="fileElement" id="fileElement"
ref={this.inputRef} ref={this.inputRef}
@ -150,7 +149,7 @@ export class S3Upload extends Component<S3UploadProps, S3UploadState> {
onChange={this.onChange.bind(this)} /> onChange={this.onChange.bind(this)} />
{this.state.isUploading {this.state.isUploading
? <Spinner awaiting={true} classes={className} /> ? <Spinner awaiting={true} classes={className} />
: <span className={`pointer ${className}`} onClick={this.onClick.bind(this)}>{display}</span> : <Text cursor='pointer' className={className} onClick={this.onClick.bind(this)}>{display}</Text>
} }
</> </>
); );

View File

@ -1,20 +0,0 @@
import React, { Component } from 'react';
export class Toggle extends Component {
render() {
const switchClasses = (this.props.boolean)
? 'relative checked bg-green2 br3 h1 toggle v-mid z-0'
: 'relative bg-gray4 bg-gray1-d br3 h1 toggle v-mid z-0';
return (
<input
type="checkbox"
style={{ WebkitAppearance: 'none', width: 28 }}
className={switchClasses}
onChange={this.props.change}
/>
);
}
}
export default Toggle;