landscape: add "messages" workspace and logic

This commit is contained in:
Matilde Park 2021-02-02 18:45:40 -05:00
parent a48c372a0e
commit f0033ab02f
16 changed files with 212 additions and 94 deletions

View File

@ -110,8 +110,12 @@ export default function index(contacts, associations, apps, currentGroup, groups
landscape.push(obj);
} else {
const app = each.metadata.module || each['app-name'];
const group = (groups[each.group]?.hidden)
? '/home' : each.group;
let group = each.group;
if (groups[each.group]?.hidden && app === 'chat') {
group = '/messages';
} else if (groups[each.group]?.hidden) {
group = '/home';
}
const obj = result(
title,
`/~landscape${group}/join/${app}${each.resource}`,

View File

@ -392,3 +392,16 @@ export const useHovering = (): useHoveringInterface => {
};
return { hovering, bind };
};
const DM_REGEX = /ship\/~([a-z]|-)*\/dm--/;
export function getItemTitle(association: Association) {
if(DM_REGEX.test(association.resource)) {
const [,,ship,name] = association.resource.split('/');
if(ship.slice(1) === window.ship) {
return cite(`~${name.slice(4)}`);
}
return cite(ship);
}
return association.metadata.title || association.resource
};

View File

@ -7,6 +7,8 @@ export function getTitleFromWorkspace(
switch (workspace.type) {
case "home":
return "My Channels";
case "messages":
return "Messages";
case "group":
const association = associations.groups[workspace.group];
return association?.metadata?.title || "";

View File

@ -9,4 +9,8 @@ interface HomeWorkspace {
type: 'home'
}
export type Workspace = HomeWorkspace | GroupWorkspace;
interface Messages {
type: 'messages'
}
export type Workspace = HomeWorkspace | GroupWorkspace | Messages;

View File

@ -27,7 +27,7 @@ const StatusBar = (props) => {
const invites = [].concat(...Object.values(props.invites).map(obj => Object.values(obj)));
const metaKey = (window.navigator.platform.includes('Mac')) ? '⌘' : 'Ctrl+';
const { toggleOmnibox, hideAvatars } =
useLocalState(({ toggleOmnibox, hideAvatars }) =>
useLocalState(({ toggleOmnibox, hideAvatars }) =>
({ toggleOmnibox, hideAvatars })
);
@ -91,6 +91,9 @@ const StatusBar = (props) => {
>
<Text color='#000000'>Submit <Text color='#000000' display={['none', 'inline']}>an</Text> issue</Text>
</StatusBarItem>
<StatusBarItem mr={2} onClick={() => props.history.push('/~landscape/messages')}>
<Icon icon="Users"/>
</StatusBarItem>
<Dropdown
dropWidth="150px"
width="auto"

View File

@ -78,7 +78,9 @@ export function GroupSwitcher(props: {
}) {
const { associations, workspace, isAdmin } = props;
const title = getTitleFromWorkspace(associations, workspace);
const metadata = workspace.type === 'home' ? undefined : associations.groups[workspace.group].metadata;
const metadata = (workspace.type === 'home' || workspace.type === 'messages')
? undefined
: associations.groups[workspace.group].metadata;
const navTo = (to: string) => `${props.baseUrl}${to}`;
return (
<Row width="100%" alignItems="center" height='48px' backgroundColor="white" zIndex="2" position="sticky" top="0px" pl='3' borderBottom='1px solid' borderColor='washedGray'>

View File

@ -47,10 +47,6 @@ export function InvitePopover(props: InvitePopoverProps) {
useOutsideClick(innerRef, onOutsideClick);
const onSubmit = async ({ ships, emails }: { ships: string[] }, actions) => {
if(props.workspace.type === 'home') {
history.push(`/~landscape/dm/${deSig(ships[0])}`);
return;
}
// TODO: how to invite via email?
try {
const resource = resourceFromPath(association.group);
@ -105,14 +101,13 @@ export function InvitePopover(props: InvitePopoverProps) {
<Col gapY="3" pt={3} px={3}>
<Box>
<Text>Invite to </Text>
<Text fontWeight="800">{title || "DM"}</Text>
<Text fontWeight="800">{title}</Text>
</Box>
<ShipSearch
groups={props.groups}
contacts={props.contacts}
id="ships"
label=""
maxLength={props.workspace.type === 'home' ? 1 : undefined}
autoFocus
/>
<FormError message="Failed to invite" />

View File

@ -3,10 +3,7 @@ import {
Box,
ManagedTextInputField as Input,
Col,
ManagedRadioButtonField as Radio,
Text,
Icon,
Row
Text
} from '@tlon/indigo-react';
import { Formik, Form } from 'formik';
import * as Yup from 'yup';
@ -21,8 +18,8 @@ import { useWaitForProps } from '~/logic/lib/useWaitForProps';
import { Groups } from '~/types/group-update';
import { ShipSearch, shipSearchSchemaInGroup, shipSearchSchema } from '~/views/components/ShipSearch';
import { Rolodex, Workspace } from '~/types';
import {IconRadio} from '~/views/components/IconRadio';
import {ChannelWriteFieldSchema, ChannelWritePerms} from './ChannelWritePerms';
import { IconRadio } from '~/views/components/IconRadio';
import { ChannelWriteFieldSchema, ChannelWritePerms } from './ChannelWritePerms';
type FormSchema = {
name: string;
@ -32,7 +29,7 @@ type FormSchema = {
} & ChannelWriteFieldSchema;
const formSchema = (members?: string[]) => Yup.object({
name: Yup.string().required('Channel must have a name'),
name: Yup.string(),
description: Yup.string(),
ships: Yup.array(Yup.string()),
moduleType: Yup.string().required('Must choose channel type'),
@ -55,11 +52,16 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
const waiter = useWaitForProps(props, 5000);
const onSubmit = async (values: FormSchema, actions) => {
const name = (values.name) ? values.name : values.moduleType;
const resId: string = stringToSymbol(values.name)
+ ((workspace?.type !== 'home') ? `-${Math.floor(Math.random() * 10000)}`
+ ((workspace?.type !== 'messages') ? `-${Math.floor(Math.random() * 10000)}`
: '');
try {
let { name, description, moduleType, ships, writers } = values;
let { description, moduleType, ships, writers } = values;
ships = ships.filter(e => e !== "");
if (workspace?.type === 'messages' && ships.length === 1) {
return history.push(`/~landscape/dm/${deSig(ships[0])}`);
}
if (group) {
await api.graph.createManagedGraph(
resId,
@ -83,7 +85,6 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
writers.push(us);
await api.groups.addTag(resource, tag, writers);
}
} else {
await api.graph.createUnmanagedGraph(
resId,
@ -115,13 +116,13 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
<Box pb='3' display={['block', 'none']} onClick={() => history.push(props.baseUrl)}>
<Text fontSize='0' bold>{'<- Back'}</Text>
</Box>
<Box fontSize="1" fontWeight="bold" mb={4} color="black">
New Channel
<Box color="black">
<Text fontSize={2} bold>{workspace?.type === 'messages' ? 'Direct Message' : 'New Channel'}</Text>
</Box>
<Formik
validationSchema={formSchema(members)}
initialValues={{
moduleType: 'chat',
moduleType: (workspace?.type === 'home') ? 'publish' : 'chat',
name: '',
description: '',
group: '',
@ -136,37 +137,54 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
maxWidth="348px"
gapY="4"
>
<Col gapY="2">
<Col pt={4} gapY="2" display={(workspace?.type === "messages") ? 'none' : 'flex'}>
<Box fontSize="1" color="black" mb={2}>Channel Type</Box>
<IconRadio icon="Chat" label="Chat" id="chat" name="moduleType" />
<IconRadio icon="Publish" label="Notebook" id="publish" name="moduleType" />
<IconRadio icon="Links" label="Collection" id="link" name="moduleType" />
<IconRadio
display={!(workspace?.type === 'home') ? 'flex' : 'none'}
icon="Chat"
label="Chat"
id="chat"
name="moduleType"
/>
<IconRadio
icon="Publish"
label="Notebook"
id="publish"
name="moduleType"
/>
<IconRadio
icon="Links"
label="Collection"
id="link"
name="moduleType"
/>
</Col>
<Input
display={workspace?.type === 'messages' ? 'none' : 'flex'}
id="name"
label="Name"
caption="Provide a name for your channel"
placeholder="eg. My Channel"
/>
<Input
display={workspace?.type === 'messages' ? 'none' : 'flex'}
id="description"
label="Description"
caption="What's your channel about?"
placeholder="Channel description"
/>
{(workspace?.type === 'home') ? (
{(workspace?.type === 'home' || workspace?.type === 'messages') ? (
<ShipSearch
groups={props.groups}
contacts={props.contacts}
id="ships"
label="Invitees"
/>) : (
/>) : (
<ChannelWritePerms
groups={props.groups}
contacts={props.contacts}
/>
)}
<Box justifySelf="start">
<AsyncButton
primary
@ -174,7 +192,7 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
type="submit"
border
>
Create Channel
Create
</AsyncButton>
</Box>
<FormError message="Channel creation failed" />

View File

@ -28,14 +28,14 @@ type ResourceProps = StoreState & {
} & RouteComponentProps;
export function Resource(props: ResourceProps) {
const { association, api, notificationsGraphConfig, groups } = props;
const { association, api, notificationsGraphConfig, groups, contacts } = props;
const app = association.metadata.module || association["app-name"];
const rid = association.resource;
const selectedGroup = association.group;
const relativePath = (p: string) =>
`${props.baseUrl}/resource/${app}${rid}${p}`;
const skelProps = { api, association, groups };
const skelProps = { api, association, groups, contacts };
let title = props.association.metadata.title;
if ('workspace' in props) {
if ('group' in props.workspace && props.workspace.group in props.associations.groups) {
@ -65,12 +65,12 @@ export function Resource(props: ResourceProps) {
render={(routeProps) => {
return (
<ChannelPopoverRoutes
association={association}
association={association}
group={props.groups?.[selectedGroup]}
groups={props.groups}
contacts={props.contacts}
api={props.api}
baseUrl={relativePath("")}
baseUrl={relativePath("")}
notificationsGraphConfig={notificationsGraphConfig}
/>
);

View File

@ -15,6 +15,8 @@ import { ChannelSettings } from "./ChannelSettings";
import { ChannelMenu } from "./ChannelMenu";
import { NotificationGraphConfig, Groups } from "~/types";
import {isWriter} from "~/logic/lib/group";
import urbitOb from 'urbit-ob';
import { getItemTitle } from '~/logic/lib/util';
const TruncatedBox = styled(Box)`
white-space: pre;
@ -24,6 +26,7 @@ const TruncatedBox = styled(Box)`
type ResourceSkeletonProps = {
groups: Groups;
contacts: any;
association: Association;
api: GlobalApi;
baseUrl: string;
@ -35,16 +38,30 @@ type ResourceSkeletonProps = {
export function ResourceSkeleton(props: ResourceSkeletonProps) {
const { association, api, baseUrl, children, atRoot, groups } = props;
const app = association?.metadata?.module || association["app-name"];
const rid = association.resource;
const rid = association.resource;
const group = groups[association.group];
const workspace =
group?.hidden ? "/home" : association.group;
let workspace = association.group;
const title = props.title || association?.metadata?.title;
if (group?.hidden && app === "chat") {
workspace = "/messages";
} else if (group?.hidden) {
workspace = "/home";
}
let title = (workspace === "/messages")
? getItemTitle(association)
: association?.metadata?.title;
let recipient = false;
if (urbitOb.isValidPatp(title)) {
recipient = title;
title = (props.contacts?.[title]?.nickname) ? props.contacts[title].nickname : title;
}
const [, , ship, resource] = rid.split("/");
const resourcePath = (p: string) => baseUrl + `/resource/${app}/ship/${ship}/${resource}` + p;
const resourcePath = (p: string) => baseUrl + p;
const isOwn = `~${window.ship}` === ship;
let canWrite = (app === 'publish') ? true : false;
@ -78,7 +95,16 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) {
<Link to={`/~landscape${workspace}`}> {"<- Back"}</Link>
</Box>
<Box px={1} mr={2} minWidth={0} display="flex">
<Text fontSize='2' fontWeight='700' display="inline-block" verticalAlign="middle" textOverflow="ellipsis" overflow="hidden" whiteSpace="pre" minWidth={0}>
<Text
mono={urbitOb.isValidPatp(title)}
fontSize='2'
fontWeight='700'
display="inline-block"
verticalAlign="middle"
textOverflow="ellipsis"
overflow="hidden"
whiteSpace="pre"
minWidth={0}>
{title}
</Text>
</Box>
@ -91,12 +117,13 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) {
color="gray"
>
<RichText
display={(workspace === '/messages' && (urbitOb.isValidPatp(title))) ? "none" : "inline-block"}
mono={(workspace === '/messages' && !(urbitOb.isValidPatp(title)))}
color="gray"
mb="0"
display="inline-block"
disableRemoteContent
>
{association?.metadata?.description}
{(workspace === "/messages") ? recipient : association?.metadata?.description}
</RichText>
</TruncatedBox>
<Box flexGrow={1} />

View File

@ -93,6 +93,8 @@ export function Sidebar(props: SidebarProps) {
handleSubmit={setConfig}
selected={selected || ''}
workspace={workspace}
api={props.api}
history={props.history}
/>
<SidebarList
config={config}
@ -102,6 +104,8 @@ export function Sidebar(props: SidebarProps) {
groups={props.groups}
apps={props.apps}
baseUrl={props.baseUrl}
workspace={workspace}
contacts={props.contacts}
/>
</ScrollbarLessCol>
);

View File

@ -1,13 +1,14 @@
import React from "react";
import _ from 'lodash';
import { Icon, Row, Box, Text } from "@tlon/indigo-react";
import { Icon, Row, Box, Text, BaseImage } from "@tlon/indigo-react";
import { SidebarAppConfigs, SidebarItemStatus } from "./Sidebar";
import { HoverBoxLink } from "~/views/components/HoverBox";
import { Groups, Association } from "~/types";
import { cite, getModuleIcon } from "~/logic/lib/util";
import { Sigil } from '~/logic/lib/sigil';
import urbitOb from 'urbit-ob';
import { getModuleIcon, getItemTitle, uxToHex } from "~/logic/lib/util";
function SidebarItemIndicator(props: { status?: SidebarItemStatus }) {
switch (props.status) {
@ -24,31 +25,18 @@ function SidebarItemIndicator(props: { status?: SidebarItemStatus }) {
}
}
;
const DM_REGEX = /ship\/~([a-z]|-)*\/dm--/;
function getItemTitle(association: Association) {
if(DM_REGEX.test(association.resource)) {
const [,,ship,name] = association.resource.split('/');
if(ship.slice(1) === window.ship) {
return cite(`~${name.slice(4)}`);
}
return cite(ship);
}
return association.metadata.title || association.resource
}
export function SidebarItem(props: {
hideUnjoined: boolean;
association: Association;
contacts: any;
groups: Groups;
path: string;
selected: boolean;
apps: SidebarAppConfigs;
workspace: Workspace;
}) {
const { association, path, selected, apps, groups } = props;
const title = getItemTitle(association);
let title = getItemTitle(association);
const appName = association?.["app-name"];
const mod = association?.metadata?.module || appName;
const rid = association?.resource
@ -58,12 +46,19 @@ export function SidebarItem(props: {
if (!app) {
return null;
}
const DM = (isUnmanaged && props.workspace?.type === "messages");
const itemStatus = app.getStatus(path);
const hasUnread = itemStatus === "unread" || itemStatus === "mention";
const isSynced = itemStatus !== "unsubscribed";
const baseUrl = isUnmanaged ? `/~landscape/home` : `/~landscape${groupPath}`;
let baseUrl = `/~landscape${groupPath}`;
if (DM) {
baseUrl = '/~landscape/messages';
} else if (isUnmanaged) {
baseUrl = '/~landscape/home';
}
const to = isSynced
? `${baseUrl}/resource/${mod}${rid}`
@ -75,6 +70,21 @@ export function SidebarItem(props: {
return null;
}
let img = null;
if (urbitOb.isValidPatp(title)) {
if (props.contacts?.[title] && props.contacts[title].avatar) {
img = <BaseImage src={props.contacts[title].avatar} width='16px' height='16px' borderRadius={2}/>;
} else {
img = <Sigil ship={title} color={`#${uxToHex(props.contacts?.[title]?.color || '0x0')}`} icon padded size={16}/>
}
if (props.contacts?.[title] && props.contacts[title].nickname) {
title = props.contacts[title].nickname;
}
} else {
img = <Box flexShrink={0} height={16} width={16} borderRadius={2} backgroundColor={`#${uxToHex(props?.association?.metadata?.color)}` || "#000000"}/>
}
return (
<HoverBoxLink
to={to}
@ -90,11 +100,14 @@ export function SidebarItem(props: {
selected={selected}
>
<Row width='100%' alignItems="center" flex='1 auto' minWidth='0'>
<Icon
display="block"
color={color}
icon={getModuleIcon(mod) as any}
/>
{DM ? img : (
<Icon
display="block"
color={color}
icon={getModuleIcon(mod) as any}
/>
)
}
<Box width='100%' flexShrink={2} ml={2} display='flex' overflow='hidden'>
<Text
lineHeight="tall"
@ -102,6 +115,7 @@ export function SidebarItem(props: {
flex='1'
overflow='hidden'
width='100%'
mono={urbitOb.isValidPatp(title)}
fontWeight={hasUnread ? "bold" : "regular"}
color={selected || isSynced ? "black" : "lightGray"}
style={{ textOverflow: 'ellipsis', whiteSpace: 'pre'}}

View File

@ -37,22 +37,28 @@ function sidebarSort(
export function SidebarList(props: {
apps: SidebarAppConfigs;
contacts: any;
config: SidebarListConfig;
associations: Associations;
groups: Groups;
baseUrl: string;
group?: string;
selected?: string;
workspace: Workspace;
}) {
const { selected, group, config } = props;
const { selected, group, config, workspace } = props;
const associations = { ...props.associations.graph };
const ordered = Object.keys(associations)
.filter((a) => {
const assoc = associations[a];
return group
? assoc.group === group
: !(assoc.group in props.associations.groups);
if (workspace?.type === 'messages') {
return (!(assoc.group in props.associations.groups) && assoc.metadata.module === "chat");
} else {
return group
? assoc.group === group
: (!(assoc.group in props.associations.groups) && assoc.metadata.module !== "chat");
}
})
.sort(sidebarSort(associations, props.apps)[config.sortBy]);
@ -69,6 +75,8 @@ export function SidebarList(props: {
apps={props.apps}
hideUnjoined={config.hideUnjoined}
groups={props.groups}
contacts={props.contacts}
workspace={workspace}
/>
);
})}

View File

@ -17,8 +17,11 @@ import { Link, useHistory } from 'react-router-dom';
import { getGroupFromWorkspace } from "~/logic/lib/workspace";
import { roleForShip } from "~/logic/lib/group";
import {Groups, Rolodex, Associations} from "~/types";
import { NewChannel } from "~/views/landscape/components/NewChannel";
import GlobalApi from "~/logic/api/global";
export function SidebarListHeader(props: {
api: GlobalApi;
initialValues: SidebarListConfig;
associations: Associations;
groups: Groups;
@ -40,10 +43,12 @@ export function SidebarListHeader(props: {
const groupPath = getGroupFromWorkspace(props.workspace);
const role = props.groups?.[groupPath] ? roleForShip(props.groups[groupPath], window.ship) : undefined;
const memberMetadata =
const memberMetadata =
groupPath ? props.associations.contacts?.[groupPath].metadata.vip === 'member-metadata' : false;
const isAdmin = memberMetadata || (role === "admin") || (props.workspace?.type === 'home');
const isAdmin = memberMetadata || (role === "admin") || (props.workspace?.type === 'home') || (props.workspace?.type === "messages");
const noun = (props.workspace?.type === "messages") ? "Messages" : "Channels";
return (
<Row
@ -56,7 +61,7 @@ export function SidebarListHeader(props: {
>
<Box flexShrink='0'>
<Text>
{props.initialValues.hideUnjoined ? "Joined Channels" : "All Channels"}
{props.initialValues.hideUnjoined ? `Joined ${noun}` : `All ${noun}`}
</Text>
</Box>
<Box
@ -64,26 +69,44 @@ export function SidebarListHeader(props: {
display='flex'
alignItems='center'
>
<Link
style={{
{props.workspace?.type === "messages"
? (
<Dropdown
flexShrink={0}
dropWidth="300px"
width="auto"
alignY="top"
alignX={["right", "left"]}
options={
<Col
background="white"
border={1}
borderColor="washedGray"
>
<NewChannel
api={props.api}
history={props.history}
associations={props.associations}
contacts={props.contacts}
groups={props.groups}
workspace={props.workspace}
/>
</Col>
}
>
<Icon icon="Plus" color="gray" pr='12px'/>
</Dropdown>
)
: (
<Link style={{
display: isAdmin ? "inline-block" : "none" }}
to={
!!groupPath ? `/~landscape${groupPath}/new` : `/~landscape/home/new`}>
to={!!groupPath
? `/~landscape${groupPath}/new`
: `/~landscape/${props.workspace?.type}/new`}>
<Icon icon="Plus" color="gray" pr='12px'/>
</Link>
<Link to={`${props.baseUrl}/invites`}
style={{ display: (props.workspace?.type === 'home') ? 'inline-block' : 'none'}}>
<Text
display='inline-block'
py='1px'
px='3px'
mr='12px'
backgroundColor='washedBlue'
color='blue'
borderRadius='1'>
+ DM
</Text>
</Link>
)
}
<Dropdown
flexShrink='0'
width="auto"

View File

@ -63,6 +63,7 @@ export function Skeleton(props: SkeletonProps) {
groups={props.groups}
mobileHide={props.mobileHide}
workspace={props.workspace}
history={props.history}
/>
{props.children}
</Body>

View File

@ -27,7 +27,7 @@ type LandscapeProps = StoreState & {
export function DMRedirect(props: LandscapeProps & RouteComponentProps & { ship: string; }) {
const { ship, api, history, graphKeys } = props;
const goToGraph = useCallback((graph: string) => {
history.push(`/~landscape/home/resource/chat/ship/~${graph}`);
history.push(`/~landscape/messages/resource/chat/ship/~${graph}`);
}, [history]);
useEffect(() => {