diff --git a/pkg/interface/src/views/components/Error.tsx b/pkg/interface/src/views/components/Error.tsx index 18a4017b3e..0ff060e05b 100644 --- a/pkg/interface/src/views/components/Error.tsx +++ b/pkg/interface/src/views/components/Error.tsx @@ -1,5 +1,5 @@ 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 styled from 'styled-components'; @@ -13,6 +13,8 @@ const Summary = styled.summary` color: ${ p => p.theme.colors.black }; `; +const Details = styled.details``; + class ErrorComponent extends Component { render () { const { code, error, history, description } = this.props; @@ -25,20 +27,20 @@ class ErrorComponent extends Component { { description && ({description}) } {error && ( - + - “{error.message}” + “{error.message}” -
+
Stack trace -
{error.stack}
-
+ {error.stack} +
)} - If this is unexpected, email support@tlon.io or submit an issue. + If this is unexpected, email support@tlon.io or submit an issue. {history.length > 1 - ? - : + ? + : } ); diff --git a/pkg/interface/src/views/components/Group.tsx b/pkg/interface/src/views/components/Group.tsx deleted file mode 100644 index b5e9d6fd55..0000000000 --- a/pkg/interface/src/views/components/Group.tsx +++ /dev/null @@ -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 ( -
-
- {`${cite(ship)}`} - {children} -
- {options.length > 0 && ( - - Options - - {options.map(({ onSelect, text }) => ( - {text} - ))} - - - )} -
- ); - } -} - -class Tag extends Component<{ description: string; onRemove?: () => any }, {}> { - render() { - const { description, onRemove } = this.props; - return ( -
- {description} - {Boolean(onRemove) && ( - - ✗ - - )} -
- ); - } -} - -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; -} - -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(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 ( - - {((permissions && role) || present.length > 0) && ( -
- {role && ( - - )} - {present.map((tag, idx) => ( - - this.removeTag(ship, tag) - )} - description={tag.desc} - /> - ))} -
- )} -
- ); - }) - } - - 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 ( -
-
Pending
- {ships.map((ship) => ( - - ))} - {ships.length === 0 && No ships are pending} - {props.inviteShips && this.isAdmin() && ( - <> -
Invite
-
- -
- this.inviteShips(state.invites)} - className='db ba tc w-auto mr-auto mt2 ph2 black white-d f8 pointer' - > - Invite - - - )} -
- ); - } - - renderBanned(policy: OpenPolicy) { - const ships = Array.from(policy.open.banned || []); - - const options = (ship: Patp) => [ - { text: 'Unban', onSelect: () => this.allowUser(ship) }, - ]; - - return ( -
-
Banned
- {ships.map((ship) => ( - - ))} - {ships.length === 0 && No ships are banned} -
- ); - } - - render() { - const { group, resourcePath, className } = this.props; - const resource = resourceFromPath(resourcePath); - const memberElements = this.memberElements(); - - return ( -
-
- Host -
- {cite(resource.ship)} -
-
- {'invite' in group.policy && this.renderInvites(group.policy)} - {'open' in group.policy && this.renderBanned(group.policy)} -
-
Members
-
{memberElements[index]}
} - /> -
- - -
- ); - } -} diff --git a/pkg/interface/src/views/components/ImageInput.tsx b/pkg/interface/src/views/components/ImageInput.tsx index 1ec0c6c7f1..e4f7870b53 100644 --- a/pkg/interface/src/views/components/ImageInput.tsx +++ b/pkg/interface/src/views/components/ImageInput.tsx @@ -7,6 +7,7 @@ import { Button, Label, ErrorLabel, + BaseInput } from "@tlon/indigo-react"; import { useField } from "formik"; import { S3State } from "~/types/s3-update"; @@ -75,7 +76,7 @@ export function ImageInput(props: ImageInputProps) { > {uploading ? "Uploading" : "Upload"} - void; - disabled?: boolean; - associations?: Associations; -} - -interface InviteSearchState { - groups: string[][]; - peers: PatpNoSig[]; - contacts: Map; - searchValue: string; - searchResults: Invites; - selected: PatpNoSig | Path | null; - inviteError: boolean; -} - -export class InviteSearch extends Component< - InviteSearchProps, - InviteSearchState -> { - textarea: React.RefObject = 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(); - 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 =
; - let searchResults =
; - - 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 = ; - if (state.inviteError) { - invErrElem = ( - - Invited ships must be validly formatted ship names. - - ); - } - - if ( - state.searchResults.groups.length > 0 || - state.searchResults.ships.length > 0 - ) { - const groupHeader = - state.searchResults.groups.length > 0 ? ( -

Groups

- ) : ( - '' - ); - - const shipHeader = - state.searchResults.ships.length > 0 ? ( -

Ships

- ) : ( - '' - ); - - const groupResults = state.searchResults.groups.map((group) => { - return ( -
  • this.addGroup(group[0])} - > - - {group[1] ? group[1] : group[0]} - -
  • - ); - }); - - const shipResults = Array.from(new Set(state.searchResults.ships)).map((ship) => { - const nicknames = (this.state.contacts.get(ship) || []) - .filter((e) => { - return !(e === ''); - }) - .join(', '); - - return ( -
  • this.addShip(ship)} - > - - - {'~' + ship} - - - {nicknames} - -
  • - ); - }); - - searchResults = ( -
    - {groupHeader} - {groupResults} - {shipHeader} - {shipResults} -
    - ); - } - - const groupInvites = props.invites.groups || []; - const shipInvites = props.invites.ships || []; - - if (groupInvites.length > 0 || shipInvites.length > 0) { - const groups = groupInvites.map((group) => { - return ( - - {group} - this.deleteGroup()} - > - x - - - ); - }); - - const ships = shipInvites.map((ship) => { - return ( - - {'~' + ship} - this.deleteShip(ship)} - > - x - - - ); - }); - - participants = ( -
    - Participants - {groups} {ships} -
    - ); - } - - return ( -
    - -