metadata: surface icon and show interstitial on invite

This commit is contained in:
Liam Fitzgerald 2021-01-22 14:12:09 +10:00
parent 752c279c83
commit 4cb8339bf1
No known key found for this signature in database
GPG Key ID: D390E12C61D1CFFB
9 changed files with 375 additions and 157 deletions

View File

@ -2,7 +2,7 @@
::
:: allow syncing group data from foreign paths to local paths
::
/- *group, *invite-store, *metadata-store
/- *group, invite-store, *metadata-store
/+ default-agent, verb, dbug, store=group-store, grpl=group, pull-hook
/+ resource, mdl=metadata
~% %group-hook-top ..part ~
@ -29,43 +29,86 @@
^- (pull-hook:pull-hook config)
=| state-zero
=* state -
=> |_ =bowl:gall
++ def ~(. (default-agent state %|) bowl)
++ watch-preview
|= rid=resource
^- card
=/ =path
preview+(en-path:resource rid)
=/ =dock
[entity.rid %metadata-push-hook]
[%pass path %agent dock %watch path]
::
++ watch-invites
^- card
[%pass /invites %agent [our.bowl %invite-store] %watch /updates]
::
++ take-invites
|= =sign:agent:gall
^- (quip card _state)
?+ -.sign (on-agent:def /invites sign)
%fact
?> ?=(%invite-update p.cage.sign)
=+ !<(=update:invite-store q.cage.sign)
:_ state
?. ?=(%invite -.update) ~
?. =(%contacts term.update) ~
(watch-preview resource.invite.update)^~
::
%kick [watch-invites^~ state]
==
::
++ take-preview
|= [=wire =sign:agent:gall]
^- (quip card _state)
?> ?=([%preview @ *] wire)
=/ rid=resource
(de-path:resource t.wire)
?+ -.sign (on-agent:def wire sign)
%fact
?> =(%metadata-update p.cage.sign)
=+ !<(upd=metadata-update q.cage.sign)
?> ?=(%preview -.upd)
:_ state(previews (~(put by previews) rid +.upd))
:~ [%give %fact ~[wire] cage.sign]
[%give %kick ~[wire] ~]
==
::
%watch-ack
:_ state
?~ p.sign ~
:~ [%give %fact ~[wire] tang+!>(u.p.sign)]
[%give %kick ~[wire] ~]
==
==
--
|_ =bowl:gall
+* this .
def ~(. (default-agent this %|) bowl)
dep ~(. (default:pull-hook this config) bowl)
met ~(. mdl bowl)
hc ~(. +> bowl)
::
++ on-init on-init:def
++ on-save !>(state)
++ on-load
|= =vase
?: =(1 1) `this
=+ !<(old=state-zero vase)
`this(state old)
:_ this(state old)
?: (~(has by wex.bowl) [/invites our.bowl %invite-store]) ~
watch-invites^~
::
++ on-poke on-poke:def
++ on-agent
|= [=wire =sign:agent:gall]
?. ?=([%preview @ @ @ ~] wire)
(on-agent:def wire sign)
=/ rid=resource
(de-path:resource t.wire)
?+ -.sign `this
%fact
?> =(%metadata-update p.cage.sign)
=+ !<(upd=metadata-update q.cage.sign)
?> ?=(%preview -.upd)
:_ this(previews (~(put by previews) rid +.upd))
:~ [%give %fact ~[wire] cage.sign]
[%give %kick ~[wire] ~]
=^ cards state
?+ wire (on-agent:def:hc wire sign)
[%invites ~] (take-invites:hc sign)
[%preview @ @ @ ~] (take-preview:hc wire sign)
==
::
%watch-ack
:_ this
?~ p.sign ~
:~ [%give %fact ~[wire] tang+!>(u.p.sign)]
[%give %kick ~[wire] ~]
==
==
[cards this]
::
++ on-watch
|= =path

View File

@ -0,0 +1,103 @@
import React, {
useState,
ReactNode,
useCallback,
SyntheticEvent,
useMemo,
useEffect,
} from "react";
import { Box } from "@tlon/indigo-react";
type ModalFunc = (dismiss: () => void) => JSX.Element;
interface UseModalProps {
modal: JSX.Element | ModalFunc;
}
interface UseModalResult {
modal: ReactNode;
showModal: () => void;
}
const stopPropagation = (e: SyntheticEvent) => {
e.stopPropagation();
};
export function useModal(props: UseModalProps): UseModalResult {
const [modalShown, setModalShown] = useState(false);
const dismiss = useCallback(() => {
setModalShown(false);
}, [setModalShown]);
const showModal = useCallback(() => {
setModalShown(true);
}, [setModalShown]);
const inner = useMemo(
() =>
!modalShown
? null
: typeof props.modal === "function"
? props.modal(dismiss)
: props.modal,
[modalShown, props.modal, dismiss]
);
const handleKeyDown = useCallback(
(event) => {
if (event.key === "Escape") {
dismiss();
}
},
[dismiss]
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [modalShown]);
const modal = useMemo(
() =>
!inner ? null : (
<Box
backgroundColor="scales.black30"
left="0px"
top="0px"
width="100%"
height="100%"
zIndex={10}
position="fixed"
display="flex"
justifyContent="center"
alignItems="center"
onClick={dismiss}
>
<Box
maxWidth="500px"
width="100%"
bg="white"
borderRadius={2}
border={[0, 1]}
borderColor={["washedGray", "washedGray"]}
onClick={stopPropagation}
display="flex"
alignItems="stretch"
flexDirection="column"
>
{inner}
</Box>
</Box>
),
[inner, dismiss]
);
return {
showModal,
modal,
};
}

View File

@ -1,69 +1,24 @@
import React, { useState, useEffect } from "react"
import React from "react"
import { Box, Button, Icon, Text } from "@tlon/indigo-react"
import { NewGroup } from "~/views/landscape/components/NewGroup";
import { JoinGroup } from "~/views/landscape/components/JoinGroup";
import {useModal} from "~/logic/lib/useModal";
const ModalButton = (props) => {
const {
childen,
children,
icon,
text,
bg,
color,
...rest
} = props;
const [modalShown, setModalShown] = useState(false);
const { modal, showModal } = useModal({ modal: props.children });
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setModalShown(false);
}
}
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [modalShown]);
return (
<>
{modalShown && (
<Box
backgroundColor='scales.black30'
left="0px"
top="0px"
width="100%"
height="100%"
zIndex={4}
position="fixed"
display="flex"
justifyContent="center"
alignItems="center"
onClick={() => setModalShown(false)}
>
<Box
maxWidth="500px"
width="100%"
bg="white"
borderRadius={2}
border={[0, 1]}
borderColor={["washedGray", "washedGray"]}
onClick={e => e.stopPropagation()}
display="flex"
alignItems="stretch"
flexDirection="column"
>
{typeof props.children === 'function'
? props.children(() => setModalShown(false))
: props.children}
</Box>
</Box>
)}
{modal}
<Box
onClick={() => setModalShown(true)}
onClick={showModal}
display="flex"
alignItems="center"
cursor="pointer"

View File

@ -1,4 +1,4 @@
import React, { useEffect, useCallback } from "react";
import React, { useEffect, useCallback, useState, useMemo } from "react";
import f from "lodash/fp";
import _ from "lodash";
import { Icon, Col, Row, Box, Text, Anchor, Rule } from "@tlon/indigo-react";
@ -13,6 +13,8 @@ import { cite } from '~/logic/lib/util';
import { InviteItem } from '~/views/components/Invite';
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import { useHistory } from "react-router-dom";
import {useModal} from "~/logic/lib/useModal";
import {JoinGroup} from "~/views/landscape/components/JoinGroup";
type DatedTimebox = [BigInteger, Timebox];
@ -101,6 +103,27 @@ export default function Inbox(props: {
}
}, [props.showArchive]);
const [joining, setJoining] = useState<[string, string] | null>(null);
const { modal, showModal } = useModal(
{ modal: useCallback(
(dismiss) => (
<JoinGroup
groups={props.groups}
contacts={props.contacts}
api={props.api}
autojoin={joining?.[0]?.slice(6)}
inviteUid={joining?.[1]}
/>
),
[props.contacts, props.groups, props.api, joining]
)})
const joinGroup = useCallback((group: string, uid: string) => {
setJoining([group, uid]);
showModal();
}, [setJoining, showModal]);
const acceptInvite = (app: string, uid: string) => async (invite) => {
const resource = {
ship: `~${invite.resource.ship}`,
@ -109,10 +132,7 @@ export default function Inbox(props: {
const resourcePath = resourceAsPath(invite.resource);
if(app === 'contacts') {
await api.contacts.join(resource);
await waiter(p => resourcePath in p.associations?.contacts);
await api.invite.accept(app, uid);
history.push(`/~landscape${resourcePath}`);
joinGroup(resourcePath, uid);
} else if ( app === 'chat') {
await api.invite.accept(app, uid);
history.push(`/~landscape/home/resource/chat${resourcePath.slice(5)}`);
@ -122,6 +142,9 @@ export default function Inbox(props: {
}
};
const inviteItems = (invites, api) => {
const returned = [];
Object.keys(invites).map((appKey) => {
@ -143,6 +166,7 @@ export default function Inbox(props: {
return (
<Col position="relative" height="100%" overflowY="auto" onScroll={onScroll} >
{modal}
<Col zIndex={4} gapY={2} bg="white" top="0px" position="sticky">
{inviteItems(invites, api)}
</Col>

View File

@ -0,0 +1,42 @@
import React, { ReactNode } from "react";
import { Metadata } from "~/types";
import { Col, Row, Text } from "@tlon/indigo-react";
import { MetadataIcon } from "./MetadataIcon";
interface GroupSummaryProps {
metadata: Metadata;
memberCount: number;
channelCount: number;
children?: ReactNode;
}
export function GroupSummary(props: GroupSummaryProps) {
const { channelCount, memberCount, metadata, children } = props;
return (
<Col maxWidth="300px" gapY="4">
<Row gapX="2">
<MetadataIcon
borderRadius="1"
border="1"
borderColor="lightGray"
width="40px"
height="40px"
metadata={metadata}
/>
<Col justifyContent="space-between">
<Text fontSize="1">{metadata.title}</Text>
<Row gapX="2" justifyContent="space-between">
<Text fontSize="1" gray>
{memberCount} participants
</Text>
<Text fontSize="1" gray>
{channelCount} channels
</Text>
</Row>
</Col>
</Row>
{metadata.description && <Text fontSize="1">{metadata.description}</Text>}
{children}
</Col>
);
}

View File

@ -13,6 +13,7 @@ import { Associations } from '~/types/metadata-update';
import { Dropdown } from '~/views/components/Dropdown';
import { Workspace } from '~/types';
import { getTitleFromWorkspace } from '~/logic/lib/workspace';
import {MetadataIcon} from './MetadataIcon';
const GroupSwitcherItem = ({ to, children, bottom = false, ...rest }) => (
<Link to={to}>
@ -77,15 +78,18 @@ export function GroupSwitcher(props: {
}) {
const { associations, workspace, isAdmin } = props;
const title = getTitleFromWorkspace(associations, workspace);
const metadata = workspace.type === 'home' ? undefined : associations.contacts[workspace.group].metadata;
const navTo = (to: string) => `${props.baseUrl}${to}`;
return (
<Box height='48px' backgroundColor="white" zIndex="2" position="sticky" top="0px" py={3} pl='3' borderBottom='1px solid' borderColor='washedGray'>
<Row width="100%" alignItems="center" height='48px' backgroundColor="white" zIndex="2" position="sticky" top="0px" pl='3' borderBottom='1px solid' borderColor='washedGray'>
<Col
bg="white"
width="100%"
height="100%"
>
<Row justifyContent="space-between">
<Row flexGrow={1} alignItems="center" justifyContent="space-between">
<Dropdown
width="100%"
width="auto"
dropWidth="231px"
alignY="top"
options={
@ -160,8 +164,9 @@ export function GroupSwitcher(props: {
</Col>
}
>
<Row width='100%' minWidth='0' flexShrink={0}>
<Row justifyContent="space-between" mr={1} flexShrink={0} width='100%' minWidth='0'>
<Row flexGrow={1} alignItems="center" width='100%' minWidth='0' flexShrink={0}>
{ metadata && <MetadataIcon mr="2" border="1" borderColor="lightGray" borderRadius="1" metadata={metadata} height="24px" width="24px" /> }
<Row justifyContent="space-between" mr={1} flexShrink={0} flexGrow={1} minWidth='0'>
<Text lineHeight="1.1" fontSize='2' fontWeight="700" overflow='hidden' display='inline-block' flexShrink='1' style={{ textOverflow: 'ellipsis', whiteSpace: 'pre' }}>{title}</Text>
</Row>
</Row>
@ -185,6 +190,6 @@ export function GroupSwitcher(props: {
</Row>
</Row>
</Col>
</Box>
</Row>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, ReactNode } from "react";
import {
Switch,
Route,
@ -27,6 +27,7 @@ import "~/views/apps/links/css/custom.css";
import "~/views/apps/publish/css/custom.css";
import { Workspace } from "~/types";
import { getGroupFromWorkspace } from "~/logic/lib/workspace";
import {GroupSummary} from "./GroupSummary";
type GroupsPaneProps = StoreState & {
baseUrl: string;
@ -192,8 +193,21 @@ export function GroupsPane(props: GroupsPaneProps) {
path={relativePath("")}
render={(routeProps) => {
const hasDescription = groupAssociation?.metadata?.description;
const description = (hasDescription && hasDescription !== "")
? hasDescription : "Create or select a channel to get started"
let summary: ReactNode;
if(groupAssociation?.group) {
const memberCount = props.groups[groupAssociation.group].members.size;
summary = <GroupSummary
memberCount={memberCount}
channelCount={0}
metadata={groupAssociation.metadata}
/>
} else {
summary = (<Box p="4"><Text fontSize="0" color='gray'>
Create or select a channel to get started
</Text></Box>);
}
const title = groupAssociation?.metadata?.title ?? 'Landscape';
return (
<>
@ -207,9 +221,7 @@ export function GroupsPane(props: GroupsPaneProps) {
display={["none", "flex"]}
p='4'
>
<Box p="4"><Text fontSize="0" color='gray'>
{description}
</Text></Box>
{summary}
</Col>
{popovers(routeProps, baseUrl)}
</Skeleton>

View File

@ -6,7 +6,7 @@ import {
Icon,
Box,
Text,
ManagedTextInputField as Input
ManagedTextInputField as Input,
} from "@tlon/indigo-react";
import { Formik, Form, FormikHelpers, useFormikContext } from "formik";
import { AsyncButton } from "~/views/components/AsyncButton";
@ -14,12 +14,14 @@ import * as Yup from "yup";
import { Groups, Rolodex } from "~/types";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import GlobalApi from "~/logic/api/global";
import { RouteComponentProps } from "react-router-dom";
import { RouteComponentProps, useHistory } from "react-router-dom";
import urbitOb from "urbit-ob";
import {resourceFromPath} from "~/logic/lib/group";
import { resourceFromPath } from "~/logic/lib/group";
import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton";
import { uxToHex, getModuleIcon } from "~/logic/lib/util";
import {FormError} from "~/views/components/FormError";
import { FormError } from "~/views/components/FormError";
import { MetadataIcon } from "./MetadataIcon";
import { GroupSummary } from "./GroupSummary";
const formSchema = Yup.object({
group: Yup.string()
@ -41,23 +43,25 @@ interface JoinGroupProps {
groups: Groups;
contacts: Rolodex;
api: GlobalApi;
autojoin: string | null;
autojoin?: string;
inviteUid?: string;
}
function Autojoin(props: { autojoin: string | null; }) {
function Autojoin(props: { autojoin: string | null }) {
const { submitForm } = useFormikContext();
useEffect(() => {
if(props.autojoin) {
if (props.autojoin) {
submitForm();
}
},[]);
}, []);
return null;
}
export function JoinGroup(props: JoinGroupProps & RouteComponentProps) {
const { api, history, autojoin } = props;
export function JoinGroup(props: JoinGroupProps) {
const { api, autojoin } = props;
const history = useHistory();
const initialValues: FormSchema = {
group: autojoin || "",
};
@ -67,12 +71,14 @@ export function JoinGroup(props: JoinGroupProps & RouteComponentProps) {
const onConfirm = useCallback(async () => {
const { group } = preview;
await api.contacts.join(resourceFromPath(group))
await api.contacts.join(resourceFromPath(group));
if (props.inviteUid) {
api.invite.accept("contacts", props.inviteUid);
}
await waiter(({ contacts, groups }) => {
return group in contacts && group in groups;
});
history.push(`/~landscape${group}`);
}, [api, preview, waiter]);
const onSubmit = useCallback(
@ -86,12 +92,17 @@ export function JoinGroup(props: JoinGroupProps & RouteComponentProps) {
setPreview(prev);
} catch (e) {
console.log(e);
if(!(e instanceof Error)) {
if (!(e instanceof Error)) {
actions.setStatus({ error: "Unknown error" });
} else if (e.message === "no-permissions" ) {
actions.setStatus({ error: "Unable to join group, you do not have the correct permissions" })
} else if (e.message === "offline" ) {
actions.setStatus({ error: "Group host is offline, please try again later" });
} else if (e.message === "no-permissions") {
actions.setStatus({
error:
"Unable to join group, you do not have the correct permissions",
});
} else if (e.message === "offline") {
actions.setStatus({
error: "Group host is offline, please try again later",
});
}
}
},
@ -100,64 +111,65 @@ export function JoinGroup(props: JoinGroupProps & RouteComponentProps) {
return (
<>
<Col overflowY="auto" p="4">
<Col width="100%" alignItems="center" overflowY="auto" p="4">
<Box mb={3}>
<Text fontSize="2" fontWeight="bold">Join a Group</Text>
<Text fontSize="2" fontWeight="bold">
Join a Group
</Text>
</Box>
{ preview ? (
<Col gapY="4">
<Row gapX="2">
<Box
borderRadius="1"
width="36px" height="36px"
bg={`#${uxToHex(preview.metadata.color)}`}
/>
<Col justifyContent="space-between">
<Text fontSize="1">{preview.metadata.title}</Text>
<Row gapX="2" justifyContent="space-between">
<Text fontSize="1" gray>{preview.members} participants</Text>
<Text fontSize="1" gray>{preview["channel-count"]} channels</Text>
{preview ? (
<GroupSummary
metadata={preview.metadata}
memberCount={preview?.members}
channelCount={preview?.["channel-count"]}
>
<Col
gapY="2"
p="2"
borderRadius="2"
border="1"
borderColor="washedGray"
bg="washedBlue"
>
<Text gray fontSize="1">
Channels
</Text>
{Object.values(preview.channels).map(({ metadata }: any) => (
<Row>
<Icon
mr="2"
color="blue"
icon={getModuleIcon(metadata.module) as any}
/>
<Text color="blue">{metadata.title} </Text>
</Row>
</Col>
</Row>
{preview.metadata.description && (
<Text gray fontSize="1">{preview.metadata.description}</Text>
)}
<Col gapY="2" p="2" borderRadius="2" border="1" borderColor="washedGray" bg="washedBlue">
<Text gray fontSize="1">Channels</Text>
{Object.values(preview.channels).map(({ metadata }: any) => (
<Row>
<Icon mr="2" color="blue" icon={getModuleIcon(metadata.module) as any} />
<Text color="blue">{metadata.title} </Text>
</Row>
))}
))}
</Col>
<StatelessAsyncButton primary name="join" onClick={onConfirm}>
Join {preview.metadata.title}
</StatelessAsyncButton>
</Col>
</GroupSummary>
) : (
<Formik
validationSchema={formSchema}
initialValues={initialValues}
onSubmit={onSubmit}
>
<Form>
<Autojoin autojoin={autojoin} />
<Col gapY="4">
<Input
id="group"
label="Group"
caption="What group are you joining?"
placeholder="~sampel-palnet/test-group"
/>
<AsyncButton>Join Group</AsyncButton>
<FormError />
</Col>
</Form>
</Formik>
)}
<Col width="100%" maxWidth="300px" gapY="4">
<Formik
validationSchema={formSchema}
initialValues={initialValues}
onSubmit={onSubmit}
>
<Form style={{ display: "contents" }}>
<Autojoin autojoin={autojoin} />
<Input
id="group"
label="Group"
caption="What group are you joining?"
placeholder="~sampel-palnet/test-group"
/>
<AsyncButton mt="4">Join Group</AsyncButton>
<FormError mt="4" />
</Form>
</Formik>
</Col>
)}
</Col>
</>
);

View File

@ -0,0 +1,22 @@
import React from "react";
import { Box, Image } from "@tlon/indigo-react";
import { uxToHex } from "~/logic/lib/util";
import { Metadata } from "~/types";
import { PropFunc } from "~/types/util";
type MetadataIconProps = PropFunc<typeof Box> & {
metadata: Metadata;
};
export function MetadataIcon(props: MetadataIconProps) {
const { metadata, ...rest } = props;
const bgColor = metadata.picture ? {} : { bg: `#${uxToHex(metadata.color)}` };
return (
<Box {...bgColor} {...rest}>
{metadata.picture && <Image height="100%" src={metadata.picture} />}
</Box>
);
}