Merge branch 'release/next-js' into mp/landscape/indigo-bump

This commit is contained in:
Matilde Park 2021-04-16 12:12:08 -04:00
commit cd6e44ac82
19 changed files with 856 additions and 664 deletions

View File

@ -5,6 +5,20 @@ let
set -eu
# set defaults
amesPort=${toString amesPort}
# check args
for i in "$@"
do
case $i in
-p=*|--port=*)
amesPort="''${i#*=}"
shift
;;
esac
done
# If the container is not started with the `-i` flag
# then STDIN will be closed and we need to start
# Urbit/vere with the `-t` flag.
@ -23,7 +37,7 @@ let
mv $keyname /tmp
# Boot urbit with the key, exit when done booting
urbit $ttyflag -w $(basename $keyname .key) -k /tmp/$keyname -c $(basename $keyname .key) -p ${toString amesPort} -x
urbit $ttyflag -w $(basename $keyname .key) -k /tmp/$keyname -c $(basename $keyname .key) -p $amesPort -x
# Remove the keyfile for security
rm /tmp/$keyname
@ -34,7 +48,7 @@ let
cometname=''${comets[0]}
rm *.comet
urbit $ttyflag -c $(basename $cometname .comet) -p ${toString amesPort} -x
urbit $ttyflag -c $(basename $cometname .comet) -p $amesPort -x
fi
# Find the first directory and start urbit with the ship therein
@ -42,7 +56,7 @@ let
dirs=( $dirnames )
dirname=''${dirnames[0]}
exec urbit $ttyflag -p ${toString amesPort} $dirname
exec urbit $ttyflag -p $amesPort $dirname
'';

View File

@ -32,7 +32,7 @@ same (if [developing on a local development ship][local]). Then, from
'pkg/interface':
```
npm install
npm ci
npm run start
```
@ -59,7 +59,7 @@ module.exports = {
```
The dev environment will attempt to match the subdomain against the keys of this
object, and if matched will proxy to the corresponding URL. For example, the
object, and if matched will proxy to the corresponding URL. For example, the
above config will proxy `zod.localhost:9000` to `http://localhost:8080`,
`bus.localhost:9000` to `http://localhost:8081` and so on and so forth. If no
match is found, then it will fallback to the `URL` property.
@ -71,7 +71,7 @@ linter and for usage through the command, do the following:
```bash
$ cd ./pkg/interface
$ npm install
$ npm ci
$ npm run lint
```

View File

@ -98,8 +98,9 @@
"lint-file": "eslint",
"tsc": "tsc",
"tsc:watch": "tsc --watch",
"preinstall": "./preinstall.sh",
"build:dev": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
"build:prod": "cd ../npm/api && npm i && cd ../../interface && cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
"build:prod": "cd ../npm/api && npm ci && cd ../../interface && cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
"start": "webpack-dev-server --config config/webpack.dev.js",
"test": "echo \"Error: no test specified\" && exit 1"
},

12
pkg/interface/preinstall.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/sh
cd ../npm
for i in $(find . -type d -maxdepth 1) ; do
packageJson="${i}/package.json"
if [ -f "${packageJson}" ]; then
echo "installing ${i}..."
cd ./${i}
npm ci
cd ..
fi
done

View File

@ -41,6 +41,12 @@ export function useLazyScroll(
}
}, [count]);
useEffect(() => {
if(!ready) {
setIsDone(false);
}
}, [ready]);
useEffect(() => {
if (!ref.current || isDone || !ready) {
return;
@ -58,7 +64,7 @@ export function useLazyScroll(
return () => {
ref.current?.removeEventListener('scroll', onScroll);
};
}, [ref?.current, count, ready]);
}, [ref?.current, ready, isDone]);
return { isDone, isLoading };
}

View File

@ -10,7 +10,7 @@ export function useRunIO<I, O>(
io: (i: I) => Promise<O>,
after: (o: O) => void,
key: string
) {
): () => Promise<void> {
const [resolve, setResolve] = useState<() => void>(() => () => {});
const [reject, setReject] = useState<(e: any) => void>(() => () => {});
const [output, setOutput] = useState<O | null>(null);

View File

@ -63,6 +63,16 @@ export function unixToDa(unix: number) {
return DA_UNIX_EPOCH.add(timeSinceEpoch);
}
export function dmCounterparty(resource: string) {
const [,,ship,name] = resource.split('/');
return ship === `~${window.ship}` ? `~${name.slice(4)}` : ship;
}
export function isDm(resource: string) {
const [,,,name] = resource.split('/');
return name.startsWith('dm--');
}
export function makePatDa(patda: string) {
return bigInt(udToDec(patda));
}

View File

@ -16,7 +16,7 @@ const useGroupState = createState<GroupState>('Group', {
}, ['groups']);
export function useGroup(group: string) {
return useGroupState(useCallback(s => s.groups[group], [group]));
return useGroupState(useCallback(s => s.groups[group] as Group | undefined, [group]));
}
export function useGroupForAssoc(association: Association) {

View File

@ -1,4 +1,5 @@
import { MetadataUpdatePreview, Associations } from "@urbit/api";
import { useCallback } from 'react';
import { MetadataUpdatePreview, Association, Associations } from "@urbit/api";
import { BaseState, createState } from "./base";
@ -9,6 +10,14 @@ export interface MetadataState extends BaseState<MetadataState> {
// preview: (group: string) => Promise<MetadataUpdatePreview>;
};
export function useAssocForGraph(graph: string) {
return useMetadataState(useCallback(s => s.associations.graph[graph] as Association | undefined, [graph]));
}
export function useAssocForGroup(group: string) {
return useMetadataState(useCallback(s => s.associations.groups[group] as Association | undefined, [group]));
}
const useMetadataState = createState<MetadataState>('Metadata', {
associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} },
// preview: async (group): Promise<MetadataUpdatePreview> => {
@ -54,4 +63,4 @@ const useMetadataState = createState<MetadataState>('Metadata', {
});
export default useMetadataState;
export default useMetadataState;

View File

@ -1,65 +1,80 @@
import React, { ReactNode, useCallback } from 'react';
import moment from 'moment';
import { Row, Box, Col, Text, Anchor, Icon, Action } from '@tlon/indigo-react';
import { Link, useHistory } from 'react-router-dom';
import _ from 'lodash';
import React, { ReactNode, useCallback } from "react";
import moment from "moment";
import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react";
import { Link, useHistory } from "react-router-dom";
import _ from "lodash";
import {
GraphNotifIndex,
GraphNotificationContents,
Associations,
Rolodex,
Groups
} from '~/types';
import { Header } from './header';
import { cite, deSig, pluralize, useShowNickname } from '~/logic/lib/util';
import Author from '~/views/components/Author';
import GlobalApi from '~/logic/api/global';
import { getSnippet } from '~/logic/lib/publish';
import styled from 'styled-components';
import { MentionText } from '~/views/components/MentionText';
import ChatMessage from '../chat/components/ChatMessage';
import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group';
import useMetadataState from '~/logic/state/metadata';
import {PermalinkEmbed} from '../permalinks/embed';
import {parsePermalink, referenceToPermalink} from '~/logic/lib/permalinks';
Groups,
} from "~/types";
import { Header } from "./header";
import {
cite,
deSig,
pluralize,
useShowNickname,
isDm,
} from "~/logic/lib/util";
import { GraphContentWide } from "~/views/landscape/components/Graph/GraphContentWide";
import Author from "~/views/components/Author";
import GlobalApi from "~/logic/api/global";
import styled from "styled-components";
import useContactState from "~/logic/state/contact";
import useGroupState from "~/logic/state/group";
import useMetadataState, {
useAssocForGraph,
useAssocForGroup,
} from "~/logic/state/metadata";
import { PermalinkEmbed } from "../permalinks/embed";
import { parsePermalink, referenceToPermalink } from "~/logic/lib/permalinks";
import { Post, Group, Association } from "@urbit/api";
import { BigInteger } from "big-integer";
const TruncBox = styled(Box)<{ truncate?: number }>`
-webkit-line-clamp: ${(p) => p.truncate ?? "unset"};
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
color: ${(p) => p.theme.colors.black};
`;
function getGraphModuleIcon(module: string) {
if (module === 'link') {
return 'Collection';
if (module === "link") {
return "Collection";
}
if(module === 'post') {
return 'Groups';
if (module === "post") {
return "Groups";
}
return _.capitalize(module);
}
const FilterBox = styled(Box)`
background: linear-gradient(
to bottom,
transparent,
${(p) => p.theme.colors.white}
);
`;
function describeNotification(description: string, plural: boolean): string {
function describeNotification(
description: string,
plural: boolean,
isDm: boolean,
singleAuthor: boolean
): string {
switch (description) {
case 'post':
return 'replied to you';
case 'link':
return `added ${pluralize('new link', plural)} to`;
case 'comment':
return `left ${pluralize('comment', plural)} on`;
case 'edit-comment':
return `updated ${pluralize('comment', plural)} on`;
case 'note':
return `posted ${pluralize('note', plural)} to`;
case 'edit-note':
return `updated ${pluralize('note', plural)} in`;
case 'mention':
return 'mentioned you on';
case 'message':
return `sent ${pluralize('message', plural)} to`;
case "post":
return singleAuthor ? "replied to you" : "Your post received replies";
case "link":
return `New link${plural ? "s" : ""} in`;
case "comment":
return `New comment${plural ? "s" : ""} on`;
case "note":
return `New Note${plural ? "s" : ""} in`;
case "edit-note":
return `updated ${pluralize("note", plural)} in`;
case "mention":
return singleAuthor ? "mentioned you in" : "You were mentioned in";
case "message":
if (isDm) {
return "messaged you";
}
return `New message${plural ? "s" : ""} in`;
default:
return description;
}
@ -67,105 +82,88 @@ function describeNotification(description: string, plural: boolean): string {
const GraphUrl = ({ contents, api }) => {
const [{ text }, link] = contents;
if('reference' in link) {
if ("reference" in link) {
return (
<PermalinkEmbed
<PermalinkEmbed
transcluded={1}
link={referenceToPermalink(link).link}
api={api}
showOurContact
/>);
/>
);
}
return (
<Box borderRadius='2' p='2' bg='scales.black05'>
<Anchor underline={false} target='_blank' color='black' href={link.url}>
<Icon verticalAlign='bottom' mr='2' icon='ArrowExternal' />
<Box borderRadius="2" p="2" bg="scales.black05">
<Anchor underline={false} target="_blank" color="black" href={link.url}>
<Icon verticalAlign="bottom" mr="2" icon="ArrowExternal" />
{text}
</Anchor>
</Box>
);
};
function ContentSummary({ icon, name, author, to }) {
return (
<Link to={to}>
<Col
gapY="1"
flexDirection={["column", "row"]}
alignItems={["flex-start", "center"]}
>
<Row
alignItems="center"
gapX="2"
p="1"
width="fit-content"
borderRadius="2"
border="1"
borderColor="lightGray"
>
<Icon display="block" icon={icon} />
<Text verticalAlign="baseline" fontWeight="medium">
{name}
</Text>
</Row>
<Row ml={[0, 1]} alignItems="center">
<Text lineHeight="1" fontWeight="medium" mr="1">
by
</Text>
<Author
sigilPadding={6}
size={24}
dontShowTime
ship={author}
showImage
/>
</Row>
</Col>
</Link>
);
}
export const GraphNodeContent = ({
group,
association,
post,
mod,
index,
}) => {
export const GraphNodeContent = ({ post, mod, index, hidden, association }) => {
const { contents } = post;
const idx = index.slice(1).split('/');
if (mod === 'link') {
if (idx.length === 1) {
return <GraphUrl contents={contents} />;
} else if (idx.length === 3) {
return <MentionText content={contents} group={group} />;
}
return null;
}
if (mod === 'publish') {
if (idx[1] === '2') {
return (
<MentionText
content={contents}
group={group}
fontSize='14px'
lineHeight='tall'
/>
);
} else if (idx[1] === '1') {
const [{ text: header }, { text: body }] = contents;
const snippet = getSnippet(body);
return (
<Col>
<Box mb='2' fontWeight='500'>
<Text>{header}</Text>
</Box>
<Box overflow='hidden' maxHeight='400px' position='relative'>
<Text lineHeight='tall'>{snippet}</Text>
<FilterBox
width='100%'
zIndex='1'
height='calc(100% - 2em)'
bottom='-4px'
position='absolute'
/>
</Box>
</Col>
);
}
}
if(mod === 'post') {
return <MentionText content={contents} group={group} />;
}
if (mod === 'chat') {
const idx = index.slice(1).split("/");
const { group, resource } = association;
const url = getNodeUrl(mod, hidden, group, resource, index);
if (mod === "link" && idx.length === 1) {
const [{ text: title }] = contents;
return (
<Row
width='100%'
flexShrink={0}
flexGrow={1}
flexWrap='wrap'
marginLeft='-32px'
>
<ChatMessage
renderSigil={false}
containerClass='items-top cf hide-child'
group={group}
groups={{}}
association={association}
associations={{ graph: {}, groups: {} }}
msg={post}
fontSize='0'
pt='2'
hideHover={true}
/>
</Row>
<ContentSummary to={url} icon="Links" name={title} author={post.author} />
);
}
return null;
if (mod === "publish" && idx[1] === "1") {
const [{ text: title }] = contents;
return (
<ContentSummary to={url} icon="Note" name={title} author={post.author} />
);
}
return (
<TruncBox truncate={8}>
<GraphContentWide api={{} as any} post={post} showOurContact />
</TruncBox>
);
};
function getNodeUrl(
@ -175,78 +173,103 @@ function getNodeUrl(
graph: string,
index: string
) {
if (hidden && mod === 'chat') {
groupPath = '/messages';
if (hidden && mod === "chat") {
groupPath = "/messages";
} else if (hidden) {
groupPath = '/home';
groupPath = "/home";
}
const graphUrl = `/~landscape${groupPath}/resource/${mod}${graph}`;
const idx = index.slice(1).split('/');
if (mod === 'publish') {
const [noteId] = idx;
return `${graphUrl}/note/${noteId}`;
} else if (mod === 'link') {
const [linkId] = idx;
return `${graphUrl}/index/${linkId}`;
} else if (mod === 'chat') {
if(idx.length > 0) {
const idx = index.slice(1).split("/");
if (mod === "publish") {
console.log(idx);
const [noteId, kind, commId] = idx;
const selected = kind === "2" ? `?selected=${commId}` : "";
return `${graphUrl}/note/${noteId}${selected}`;
} else if (mod === "link") {
const [linkId, commId] = idx;
return `${graphUrl}/index/${linkId}${commId ? `?selected=${commId}` : ""}`;
} else if (mod === "chat") {
if (idx.length > 0) {
return `${graphUrl}?msg=${idx[0]}`;
}
return graphUrl;
} else if( mod === 'post') {
} else if (mod === "post") {
return `/~landscape${groupPath}/feed${index}`;
}
return '';
return "";
}
const GraphNode = ({
post,
author,
mod,
description,
time,
index,
graph,
groupPath,
group,
read,
onRead,
showContact = false
}) => {
author = deSig(author);
const history = useHistory();
const contacts = useContactState((state) => state.contacts);
const nodeUrl = getNodeUrl(mod, group?.hidden, groupPath, graph, index);
const association = useMetadataState(
useCallback(s => s.associations.graph[graph], [graph])
interface PostsByAuthor {
author: string;
posts: Post[];
}
const GraphNodes = (props: {
posts: Post[];
graph: string;
hideAuthors?: boolean;
group?: Group;
groupPath: string;
description: string;
index: string;
mod: string;
association: Association;
hidden: boolean;
}) => {
const {
posts,
mod,
hidden,
index,
description,
hideAuthors = false,
association,
} = props;
const postsByConsecAuthor = _.reduce(
posts,
(acc: PostsByAuthor[], val: Post, key: number) => {
const lent = acc.length;
if (lent > 0 && acc?.[lent - 1]?.author === val.author) {
const last = acc[lent - 1];
const rest = acc.slice(0, -1);
return [...rest, { ...last, posts: [...last.posts, val] }];
}
return [...acc, { author: val.author, posts: [val] }];
},
[]
);
const onClick = useCallback(() => {
if (!read) {
onRead();
}
history.push(nodeUrl);
}, [read, onRead]);
return (
<Row onClick={onClick} gapX='2' pt={showContact ? 2 : 0}>
<Col flexGrow={1} alignItems='flex-start'>
{showContact && (
<Author showImage ship={author} date={time} group={group} />
)}
<Row width='100%' p='1' flexDirection='column'>
<GraphNodeContent
post={post}
mod={mod}
description={description}
association={association}
index={index}
group={group}
remoteContentPolicy={{}}
/>
</Row>
</Col>
</Row>
<>
{_.map(postsByConsecAuthor, ({ posts, author }, idx) => {
const time = posts[0]?.["time-sent"];
return (
<Col key={idx} flexGrow={1} alignItems="flex-start">
{!hideAuthors && (
<Author
size={24}
sigilPadding={6}
showImage
ship={author}
date={time}
/>
)}
<Col gapY="2" py={hideAuthors ? 0 : 2} width="100%">
{_.map(posts, (post) => (
<GraphNodeContent
key={post.index}
post={post}
mod={mod}
index={index}
association={association}
hidden={hidden}
/>
))}
</Col>
</Col>
);
})}
</>
);
};
@ -260,53 +283,80 @@ export function GraphNotification(props: {
api: GlobalApi;
}) {
const { contents, index, read, time, api, timebox } = props;
const history = useHistory();
const authors = _.map(contents, 'author');
const authors = _.uniq(_.map(contents, "author"));
const singleAuthor = authors.length === 1;
const { graph, group } = index;
const icon = getGraphModuleIcon(index.module);
const desc = describeNotification(index.description, contents.length !== 1);
const association = useAssocForGraph(graph)!;
const dm = isDm(graph);
const desc = describeNotification(
index.description,
contents.length !== 1,
dm,
singleAuthor
);
const groupAssociation = useAssocForGroup(association.group);
const groups = useGroupState((state) => state.groups);
const onClick = useCallback(() => {
if (props.archived || read) {
return;
if (
!(
(index.description === "note" || index.description === "link") &&
index.index === "/"
)
) {
const first = contents[0];
const { group, resource } = association;
history.push(
getNodeUrl(
index.module,
groups[association.group]?.hidden,
group,
resource,
first.index
)
);
}
return api.hark['read'](timebox, { graph: index });
}, [api, timebox, index, read]);
const groups = useGroupState((state) => state.groups);
const authorsInHeader =
dm ||
((index.description === "mention" || index.description === "post") &&
singleAuthor);
const hideAuthors =
authorsInHeader ||
index.description === "note" ||
index.description === "link";
const channelTitle = dm ? undefined : association.metadata.title ?? graph;
const groupTitle = groupAssociation?.metadata?.title;
return (
<>
<Header
onClick={onClick}
archived={props.archived}
time={time}
read={read}
authors={authors}
moduleIcon={icon}
channel={graph}
group={group}
authors={authorsInHeader ? authors : []}
channelTitle={channelTitle}
description={desc}
groupTitle={groupTitle}
content
/>
<Box flexGrow={1} width='100%' pl={5} gridArea='main'>
{_.map(contents, (content, idx) => (
<GraphNode
post={content}
author={content.author}
mod={index.module}
time={content?.['time-sent']}
description={index.description}
index={content.index}
graph={graph}
group={groups[group]}
groupPath={group}
read={read}
onRead={onClick}
showContact={idx === 0}
/>
))}
</Box>
<Col onClick={onClick} gapY="2" flexGrow={1} width="100%" gridArea="main">
<GraphNodes
hideAuthors={hideAuthors}
posts={contents.slice(0, 4)}
mod={index.module}
description={index.description}
index={contents?.[0].index}
association={association}
hidden={groups[association.group]?.hidden}
/>
{contents.length > 4 && (
<Text mb="2" gray>
+ {contents.length - 4} more
</Text>
)}
</Col>
</>
);
}

View File

@ -12,6 +12,7 @@ import {
import { Header } from './header';
import GlobalApi from '~/logic/api/global';
import {useAssocForGroup} from '~/logic/state/metadata';
function describeNotification(description: string, plural: boolean) {
switch (description) {
@ -52,23 +53,16 @@ export function GroupNotification(props: GroupNotificationProps): ReactElement {
const { group } = index;
const desc = describeNotification(index.description, contents.length !== 1);
const onClick = useCallback(() => {
if (props.archived) {
return;
}
const func = read ? 'unread' : 'read';
return api.hark[func](timebox, { group: index });
}, [api, timebox, index, read]);
const association = useAssocForGroup(group)
const groupTitle = association?.metadata?.title ?? group;
return (
<Col onClick={onClick} p="2">
<Col>
<Header
archived={props.archived}
time={time}
read={read}
group={group}
authors={authors}
description={desc}
groupTitle={groupTitle}
/>
</Col>
);

View File

@ -1,103 +1,90 @@
import React, { ReactElement } from 'react';
import f from 'lodash/fp';
import _ from 'lodash';
import moment from 'moment';
import React, { ReactElement } from "react";
import _ from "lodash";
import moment from "moment";
import { Text as NormalText, Row, Rule, Box, Col } from "@tlon/indigo-react";
import { Text as NormalText, Row, Icon, Rule } from '@tlon/indigo-react';
import { Associations, Contact, Contacts, Rolodex } from '@urbit/api';
import { PropFunc } from '~/types/util';
import { useShowNickname } from '~/logic/lib/util';
import Timestamp from '~/views/components/Timestamp';
import useContactState from '~/logic/state/contact';
import useMetadataState from '~/logic/state/metadata';
import { PropFunc } from "~/types/util";
import Timestamp from "~/views/components/Timestamp";
import Author from "~/views/components/Author";
import Dot from "~/views/components/Dot";
const Text = (props: PropFunc<typeof Text>) => (
<NormalText fontWeight="500" {...props} />
);
function Author(props: { patp: string; last?: boolean }): ReactElement {
const contacts = useContactState(state => state.contacts);
const contact: Contact | undefined = contacts?.[`~${props.patp}`];
const showNickname = useShowNickname(contact);
const name = showNickname ? contact.nickname : `~${props.patp}`;
export function Header(
props: {
channelTitle?: string;
groupTitle?: string;
description: string;
time?: number;
authors?: string[];
content?: boolean;
} & PropFunc<typeof Row>
): ReactElement {
const {
description,
channelTitle = "",
groupTitle,
authors = [],
content = false,
time,
} = props;
return (
<Text mono={!showNickname}>
{name}
{!props.last && ', '}
</Text>
);
}
export function Header(props: {
authors: string[];
archived?: boolean;
channel?: string;
group: string;
description: string;
moduleIcon?: string;
time: number;
read: boolean;
} & PropFunc<typeof Row> ): ReactElement {
const { description, channel, moduleIcon, read } = props;
const associations = useMetadataState(state => state.associations);
const authors = _.uniq(props.authors);
const authorDesc = f.flow(
f.take(3),
f.entries,
f.map(([idx, p]: [string, string]) => {
const lent = Math.min(3, authors.length);
const last = lent - 1 === parseInt(idx, 10);
return <Author key={idx} patp={p} last={last} />;
}),
auths => (
<React.Fragment>
{auths}
{authors.length > 3 &&
` and ${authors.length - 3} other${authors.length === 4 ? '' : 's'}`}
</React.Fragment>
)
)(authors);
const time = moment(props.time).format('HH:mm');
const groupTitle =
associations.groups?.[props.group]?.metadata?.title;
const app = 'graph';
const channelTitle =
(channel && associations?.[app]?.[channel]?.metadata?.title) ||
channel;
return (
<Row onClick={props.onClick} p="2" flexWrap="wrap" alignItems="center" gridArea="header">
{!props.archived && (
<Icon
display="block"
opacity={read ? 0 : 1}
mr={2}
icon="Bullet"
color="blue"
/>
)}
<Text mr="1" mono>
{authorDesc}
</Text>
<Text mr="1">{description}</Text>
{Boolean(moduleIcon) && <Icon icon={moduleIcon as any} mr={1} />}
{Boolean(channel) && <Text fontWeight="500" mr={1}>{channelTitle}</Text>}
<Rule vertical height="12px" mr={1} />
{groupTitle &&
<>
<Text fontWeight="500" mr={1}>{groupTitle}</Text>
<Rule vertical height="12px" mr={1} />
</>
}
<Timestamp stamp={moment(props.time)} color="lightGray" date={false} />
<Row
flexDirection={["column-reverse", "row"]}
minHeight="4"
mb={content ? 2 : 0}
onClick={props.onClick}
flexWrap="wrap"
alignItems={["flex-start", "center"]}
gridArea="header"
overflow="hidden"
>
<Row gapX="1" overflow="hidden" alignItems="center">
{authors.length > 0 && (
<>
<Author
flexShrink={0}
sigilPadding={6}
size={24}
dontShowTime
date={time}
ship={authors[0]}
showImage
/>
{authors.length > 1 && (
<Text lineHeight="tall">+ {authors.length - 1} more</Text>
)}
</>
)}
<Box whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">
<Text lineHeight="tall" mr="1">
{description} {channelTitle}
</Text>
</Box>
</Row>
<Row ml={[0, 1]} mb={[1, 0]} gapX="1" alignItems="center">
{groupTitle && (
<>
<Text lineHeight="tall" fontSize="1" gray>
{groupTitle}
</Text>
<Dot color="gray" />
</>
)}
{time && (
<Timestamp
lineHeight="tall"
fontSize="1"
relative
stamp={moment(time)}
color="gray"
date={false}
/>
)}
</Row>
</Row>
);
}

View File

@ -121,7 +121,7 @@ export default function Inbox(props: {
);
return (
<Col ref={scrollRef} position="relative" height="100%" overflowY="auto">
<Col p="1" ref={scrollRef} position="relative" height="100%" overflowY="auto">
<Invites pendingJoin={props.pendingJoin} api={api} />
{[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => {
const timeboxes = notificationsByDayMap.get(day)!;
@ -175,26 +175,15 @@ function DaySection({
return (
<>
<Box position="sticky" zIndex={3} top="-1px" bg="white">
<Box p="2" bg="scales.black05">
<Text>
{label}
</Text>
</Box>
</Box>
{_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i: number) =>
_.map(nots.sort(sortIndexedNotification), (not, j: number) => (
<React.Fragment key={j}>
{(i !== 0 || j !== 0) && (
<Box flexShrink={0} height="4px" bg="scales.black05" />
)}
<Notification
api={api}
notification={not}
archived={archive}
time={date}
/>
</React.Fragment>
<Notification
key={j}
api={api}
notification={not}
archived={archive}
time={date}
/>
))
)}
</>

View File

@ -59,13 +59,6 @@ export function Invites(props: InvitesProps): ReactElement {
return (
<>
{Object.keys(invitesAndStatus).length > 0 && (
<Box position="sticky" zIndex={3} top="-1px" bg="white" flexShrink="0">
<Box p="2" bg="scales.black05">
<Text>Invites</Text>
</Box>
</Box>
)}
{Object.keys(invitesAndStatus)
.sort(alphabeticalOrder)
.map((resource) => {
@ -89,10 +82,9 @@ export function Invites(props: InvitesProps): ReactElement {
invite={invite}
app={app}
uid={uid}
join={join}
resource={resource}
/>
);
);
}
})
}

View File

@ -1,6 +1,6 @@
import React, { ReactNode, useCallback, useMemo, useState } from 'react';
import { Row, Box } from '@tlon/indigo-react';
import _ from 'lodash';
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import { Row, Box } from "@tlon/indigo-react";
import _ from "lodash";
import {
GraphNotificationContents,
IndexedNotification,
@ -9,16 +9,16 @@ import {
GroupNotificationsConfig,
Groups,
Associations,
Contacts
} from '@urbit/api';
import GlobalApi from '~/logic/api/global';
import { getParentIndex } from '~/logic/lib/notification';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
import { GroupNotification } from './group';
import { GraphNotification } from './graph';
import { BigInteger } from 'big-integer';
import { useHovering } from '~/logic/lib/util';
import useHarkState from '~/logic/state/hark';
Contacts,
} from "@urbit/api";
import GlobalApi from "~/logic/api/global";
import { getParentIndex } from "~/logic/lib/notification";
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
import { GroupNotification } from "./group";
import { GraphNotification } from "./graph";
import { BigInteger } from "big-integer";
import { useHovering } from "~/logic/lib/util";
import useHarkState from "~/logic/state/hark";
interface NotificationProps {
notification: IndexedNotification;
@ -33,73 +33,108 @@ function getMuted(
graphs: NotificationGraphConfig
) {
const { index, notification } = idxNotif;
if ('graph' in idxNotif.index) {
if ("graph" in idxNotif.index) {
const { graph } = idxNotif.index.graph;
if(!('graph' in notification.contents)) {
if (!("graph" in notification.contents)) {
throw new Error();
}
const parent = getParentIndex(index.graph, notification.contents.graph);
return _.findIndex(
graphs?.watching || [],
g => g.graph === graph && g.index === parent
) === -1;
return (
_.findIndex(
graphs?.watching || [],
(g) => g.graph === graph && g.index === parent
) === -1
);
}
if ('group' in index) {
return _.findIndex(groups || [], g => g === index.group.group) === -1;
if ("group" in index) {
return _.findIndex(groups || [], (g) => g === index.group.group) === -1;
}
return false;
}
function NotificationWrapper(props: {
export function NotificationWrapper(props: {
api: GlobalApi;
time: BigInteger;
notif: IndexedNotification;
time?: BigInteger;
notification?: IndexedNotification;
children: ReactNode;
archived: boolean;
}) {
const { api, time, notif, children } = props;
const { api, time, notification, children } = props;
const onArchive = useCallback(async () => {
return api.hark.archive(time, notif.index);
}, [time, notif]);
if (!(time && notification)) {
return;
}
return api.hark.archive(time, notification.index);
}, [time, notification]);
const groupConfig = useHarkState(state => state.notificationsGroupConfig);
const graphConfig = useHarkState(state => state.notificationsGraphConfig);
const groupConfig = useHarkState((state) => state.notificationsGroupConfig);
const graphConfig = useHarkState((state) => state.notificationsGraphConfig);
const isMuted = getMuted(
notif,
groupConfig,
graphConfig
);
const isMuted =
time && notification && getMuted(notification, groupConfig, graphConfig);
const onChangeMute = useCallback(async () => {
const func = isMuted ? 'unmute' : 'mute';
return api.hark[func](notif);
}, [notif, api, isMuted]);
if (!notification) {
return;
}
const func = isMuted ? "unmute" : "mute";
return api.hark[func](notification);
}, [notification, api, isMuted]);
const onClick = () => {
if (!(time && notification) || notification.notification.read) {
return;
}
return api.hark.read(time, notification.index);
};
const { hovering, bind } = useHovering();
const changeMuteDesc = isMuted ? 'Unmute' : 'Mute';
const changeMuteDesc = isMuted ? "Unmute" : "Mute";
return (
<Box
width="100%"
onClick={onClick}
bg={
(notification ? notification?.notification?.read : false)
? "washedGray"
: "washedBlue"
}
borderRadius={2}
display="grid"
gridTemplateColumns="1fr 200px"
gridTemplateColumns={["1fr", "1fr 200px"]}
gridTemplateRows="auto"
gridTemplateAreas="'header actions' 'main main'"
pb={2}
gridTemplateAreas={["'header' 'main'", "'header actions' 'main main'"]}
p={2}
m={2}
{...bind}
>
{children}
<Row gapX="2" p="2" pt='3' gridArea="actions" justifyContent="flex-end" opacity={[1, hovering ? 1 : 0]}>
<StatelessAsyncAction name={changeMuteDesc} onClick={onChangeMute} backgroundColor="transparent">
{changeMuteDesc}
</StatelessAsyncAction>
{!props.archived && (
<StatelessAsyncAction name={time.toString()} onClick={onArchive} backgroundColor="transparent">
Dismiss
</StatelessAsyncAction>
<Row
display={["none", "flex"]}
alignItems="center"
gapX="2"
gridArea="actions"
justifyContent="flex-end"
opacity={[1, hovering ? 1 : 0]}
>
{time && notification && (
<>
<StatelessAsyncAction
name={changeMuteDesc}
onClick={onChangeMute}
backgroundColor="transparent"
>
{changeMuteDesc}
</StatelessAsyncAction>
<StatelessAsyncAction
name={time.toString()}
onClick={onArchive}
backgroundColor="transparent"
>
Dismiss
</StatelessAsyncAction>
</>
)}
</Row>
</Box>
@ -110,23 +145,18 @@ export function Notification(props: NotificationProps) {
const { notification, associations, archived } = props;
const { read, contents, time } = notification.notification;
const Wrapper = ({ children }) => (
<NotificationWrapper
archived={archived}
notif={notification}
time={props.time}
api={props.api}
>
{children}
</NotificationWrapper>
);
const wrapperProps = {
notification,
time: props.time,
api: props.api,
};
if ('graph' in notification.index) {
if ("graph" in notification.index) {
const index = notification.index.graph;
const c: GraphNotificationContents = (contents as any).graph;
return (
<Wrapper>
<NotificationWrapper {...wrapperProps}>
<GraphNotification
api={props.api}
index={index}
@ -136,14 +166,14 @@ export function Notification(props: NotificationProps) {
timebox={props.time}
time={time}
/>
</Wrapper>
</NotificationWrapper>
);
}
if ('group' in notification.index) {
if ("group" in notification.index) {
const index = notification.index.group;
const c: GroupNotificationContents = (contents as any).group;
return (
<Wrapper>
<NotificationWrapper {...wrapperProps}>
<GroupNotification
api={props.api}
index={index}
@ -153,7 +183,7 @@ export function Notification(props: NotificationProps) {
archived={archived}
time={time}
/>
</Wrapper>
</NotificationWrapper>
);
}

View File

@ -18,7 +18,7 @@ import { PropFunc } from '~/types';
interface AuthorProps {
ship: string;
date: number;
date?: number;
showImage?: boolean;
children?: ReactNode;
unread?: boolean;
@ -113,11 +113,13 @@ export default function Author(props: AuthorProps & PropFunc<typeof Box>): React
lineHeight='tall'
fontFamily={showNickname ? 'sans' : 'mono'}
fontWeight={showNickname ? '500' : '400'}
mr={showNickname ? 0 : "2px"}
mt={showNickname ? 0 : "0px"}
onClick={doCopy}
>
{copyDisplay}
</Box>
{ !dontShowTime && (
{ !dontShowTime && time && (
<Timestamp
relative={isRelativeTime}
stamp={stamp}

View File

@ -1,79 +1,329 @@
import React, { ReactElement, ReactNode } from 'react';
import { Text, Box, Icon, Row } from '@tlon/indigo-react';
import React, { ReactElement, ReactNode, useCallback } from "react";
import {
Text,
Box,
Icon,
Row,
LoadingSpinner,
Button,
} from "@tlon/indigo-react";
import { css } from "@styled-system/css";
import _ from "lodash";
import { useHistory } from "react-router-dom";
import { cite } from '~/logic/lib/util';
import { MetadataUpdatePreview, JoinProgress, Invite, JoinRequest } from '@urbit/api';
import { GroupSummary } from '~/views/landscape/components/GroupSummary';
import { InviteSkeleton } from './InviteSkeleton';
import { JoinSkeleton } from './JoinSkeleton';
import GlobalApi from '~/logic/api/global';
import { cite, isDm } from "~/logic/lib/util";
import {
MetadataUpdatePreview,
joinProgress,
JoinProgress,
Invite,
JoinRequest,
resourceFromPath,
Metadata,
} from "@urbit/api";
import { GroupSummary } from "~/views/landscape/components/GroupSummary";
import { NotificationWrapper } from "~/views/apps/notifications/notification";
import { Header } from "~/views/apps/notifications/header";
import { InviteSkeleton } from "./InviteSkeleton";
import { JoinSkeleton } from "./JoinSkeleton";
import GlobalApi from "~/logic/api/global";
import { PropFunc } from "~/types";
import { MetadataIcon } from "~/views/landscape/components/MetadataIcon";
import { useContact } from "~/logic/state/contact";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import useGroupState, {useGroup} from "~/logic/state/group";
import useContactState from "~/logic/state/contact";
import useMetadataState, {useAssocForGraph} from "~/logic/state/metadata";
import useGraphState from "~/logic/state/graph";
import { useRunIO } from "~/logic/lib/useRunIO";
import { StatelessAsyncButton } from "../StatelessAsyncButton";
import styled from "styled-components";
interface GroupInviteProps {
preview: MetadataUpdatePreview;
preview?: MetadataUpdatePreview;
status?: JoinRequest;
app?: string;
uid?: string;
invite?: Invite;
resource: string;
api: GlobalApi;
onAccept: () => Promise<any>;
onDecline: () => Promise<any>;
}
export function GroupInvite(props: GroupInviteProps): ReactElement {
const { resource, api, preview, invite, status, onAccept, onDecline } = props;
const { metadata, members } = props.preview;
function Elbow(
props: { size?: number; color?: string } & PropFunc<typeof Box>
) {
const { size = 12, color = "lightGray", ...rest } = props;
let inner: ReactNode = null;
let Outer: (p: { children: ReactNode }) => JSX.Element = p => (
<>{p.children}</>
return (
<Box
{...rest}
overflow="hidden"
width={size}
height={size}
position="relative"
>
<Box
border="2px solid"
borderRadius={2}
borderColor={color}
position="absolute"
left="0px"
bottom="0px"
width={size * 2}
height={size * 2}
/>
</Box>
);
}
const description: string[] = [
"Contacting host...",
"Retrieving data...",
"Finished join",
"Unable to join, you do not have the correct permissions",
"Internal error, please file an issue",
];
function inviteUrl(hidden: boolean, resource: string, metadata?: Metadata) {
if (!hidden) {
return `/~landscape${resource}`;
}
if (metadata?.config.graph === "chat") {
return `/~landscape/messages/resource/${metadata?.config?.graph}${resource}`;
} else {
return `/~landscape/home/resource/${metadata?.config?.graph}${resource}`;
}
}
function InviteMetadata(props: {
preview?: MetadataUpdatePreview;
resource: string;
}) {
const { resource, preview } = props;
const { ship, name } = resourceFromPath(resource);
const dm = isDm(resource);
if (dm) {
return null;
}
const container = (children: ReactNode) => (
<Row overflow="hidden" height="4" gapX="2" alignItems="center">
{children}
</Row>
);
if (status) {
inner = (
<Text mr="1">
You are joining <Text fontWeight="medium">{metadata.title}</Text>
</Text>
);
Outer = ({ children }) => (
<JoinSkeleton resource={resource} api={api} gapY="3" status={status}>
{children}
</JoinSkeleton>
);
} else if (invite) {
Outer = ({ children }) => (
<InviteSkeleton
onDecline={onDecline}
onAccept={onAccept}
acceptDesc="Join Group"
declineDesc="Decline Invitation"
gapY="3"
>
{children}
</InviteSkeleton>
);
inner = (
if (preview) {
const { title } = preview.metadata;
const { members } = preview;
return container(
<>
<Text mr="1" mono>
{cite(`~${invite!.ship}`)}
<MetadataIcon height={4} width={4} metadata={preview.metadata} />
<Text fontWeight="medium">{title}</Text>
<Text gray fontWeight="medium">
{members} Member{members > 1 ? "s" : ""}
</Text>
<Text mr="1">invited you to </Text>
<Text fontWeight="medium">{metadata.title}</Text>
</>
);
}
return (
<Outer>
<Row py="1" alignItems="center">
<Icon display="block" mr={2} icon="Bullet" color="blue" />
{inner}
</Row>
<Box px="4">
<GroupSummary
gray
metadata={metadata}
memberCount={members}
channelCount={preview?.['channel-count']}
/>
</Box>
</Outer>
return container(
<>
<Text whiteSpace="nowrap" textOverflow="ellipsis" ml="1px" mb="2px" mono>
{cite(ship)}/{name}
</Text>
</>
);
}
function InviteStatus(props: { status?: JoinRequest }) {
const { status } = props;
if (!status) {
return null;
}
const current = status && joinProgress.indexOf(status.progress);
const desc = _.isNumber(current) && description[current];
return (
<Row gapX="1" alignItems="center" height={4}>
{ status.progress === 'done' ? <Icon icon="Checkmark" /> : <LoadingSpinner dark /> }
<Text gray>{desc}</Text>
</Row>
);
}
export function useInviteAccept(
resource: string,
api: GlobalApi,
app?: string,
uid?: string
) {
const { ship, name } = resourceFromPath(resource);
const history = useHistory();
const associations = useMetadataState((s) => s.associations);
const groups = useGroupState((s) => s.groups);
const graphKeys = useGraphState((s) => s.graphKeys);
const waiter = useWaitForProps({ associations, graphKeys, groups });
return useRunIO<void, boolean>(
async () => {
if (!(app && uid)) {
return false;
}
if (resource in groups) {
await api.invite.decline(app, uid);
return false;
}
await api.groups.join(ship, name);
await api.invite.accept(app, uid);
await waiter((p) => {
return (
(resource in p.groups &&
resource in (p.associations?.graph ?? {}) &&
p.graphKeys.has(resource.slice(7))) ||
resource in (p.associations?.groups ?? {})
);
});
return true;
},
(success: boolean) => {
if (!success) {
return;
}
const redir = inviteUrl(
groups?.[resource]?.hidden,
resource,
associations?.graph?.[resource]?.metadata
);
if (redir) {
// weird race condition
setTimeout(() => {
history.push(redir);
}, 200);
}
},
resource
);
}
function InviteActions(props: {
status?: JoinRequest;
resource: string;
api: GlobalApi;
app?: string;
uid?: string;
}) {
const { resource, api, app, uid } = props;
const inviteAccept = useInviteAccept(resource, api, app, uid);
const inviteDecline = useCallback(async () => {
if (!(app && uid)) {
return;
}
await api.invite.decline(app, uid);
}, [app, uid]);
const hideJoin = useCallback(async () => {
await api.groups.hide(resource);
}, [api, resource]);
const { status } = props;
if (status) {
if(status.progress === 'done') {
return null;
}
return (
<Row gapX="2" alignItems="center" height={4}>
<StatelessAsyncButton
height={4}
backgroundColor="white"
onClick={hideJoin}
>
Cancel
</StatelessAsyncButton>
</Row>
);
}
return (
<Row gapX="2" alignItems="center" height={4}>
<StatelessAsyncButton
color="blue"
height={4}
backgroundColor="white"
onClick={inviteAccept}
>
Accept
</StatelessAsyncButton>
<StatelessAsyncButton
height={4}
backgroundColor="white"
onClick={inviteDecline}
>
Decline
</StatelessAsyncButton>
</Row>
);
}
const responsiveStyle = ({ gapXY = 0 as number | number[] }) => {
return css({
flexDirection: ["column", "row"],
"& > *": {
marginTop: _.isArray(gapXY) ? [gapXY[0], 0] : [gapXY, 0],
marginLeft: _.isArray(gapXY) ? [0, ...gapXY.slice(1)] : [0, gapXY],
},
"& > :first-child": {
marginTop: 0,
marginLeft: 0,
},
});
};
const ResponsiveRow = styled(Row)(responsiveStyle);
export function GroupInvite(props: GroupInviteProps): ReactElement {
const { resource, api, preview, invite, status, app, uid } = props;
const dm = isDm(resource);
const history = useHistory();
const invitedTo = dm ? "DM" : "group";
const graphAssoc = useAssocForGraph(resource);
const headerProps = status
? { description: `You are joining a ${invitedTo}` }
: { description: `invited you to a ${invitedTo}`, authors: [invite!.ship] };
const onClick = () => {
if(status?.progress === 'done') {
const redir = inviteUrl(app !== 'groups', resource, graphAssoc?.metadata);
if(redir) {
history.push(redir);
}
}
}
return (
<NotificationWrapper api={api}>
<Header content {...headerProps} />
<Row onClick={onClick} height={[null, 4]} alignItems="flex-start" gridArea="main">
<Elbow mx="2" />
<ResponsiveRow
gapXY={[1, 2]}
height={[null, 4]}
alignItems={["flex-start", "center"]}
>
<InviteMetadata preview={preview} resource={resource} />
<InviteStatus status={status} />
<InviteActions
api={api}
resource={resource}
status={status}
app={app}
uid={uid}
/>
</ResponsiveRow>
</Row>
</NotificationWrapper>
);
}

View File

@ -7,113 +7,26 @@ import {
JoinRequests,
Groups,
Associations,
JoinRequest,
} from "@urbit/api";
import { Invite } from "@urbit/api/invite";
import { Text, Icon, Row } from "@tlon/indigo-react";
import { cite, useShowNickname } from "~/logic/lib/util";
import GlobalApi from "~/logic/api/global";
import { resourceFromPath } from "~/logic/lib/group";
import { GroupInvite } from "./Group";
import { InviteSkeleton } from "./InviteSkeleton";
import { JoinSkeleton } from "./JoinSkeleton";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import useGroupState from "~/logic/state/group";
import useContactState from "~/logic/state/contact";
import useMetadataState from "~/logic/state/metadata";
import useGraphState from "~/logic/state/graph";
import { useRunIO } from "~/logic/lib/useRunIO";
interface InviteItemProps {
invite?: Invite;
resource: string;
pendingJoin?: string;
pendingJoin?: JoinRequest;
app?: string;
uid?: string;
api: GlobalApi;
}
export function useInviteAccept(
resource: string,
api: GlobalApi,
app?: string,
uid?: string,
invite?: Invite,
) {
const { ship, name } = resourceFromPath(resource);
const history = useHistory();
const associations = useMetadataState((s) => s.associations);
const groups = useGroupState((s) => s.groups);
const graphKeys = useGraphState((s) => s.graphKeys);
const waiter = useWaitForProps({ associations, graphKeys, groups });
return useRunIO<void, boolean>(
async () => {
if (!(app && invite && uid)) {
return false;
}
if (resource in groups) {
await api.invite.decline(app, uid);
return false;
}
await api.groups.join(ship, name);
await api.invite.accept(app, uid);
await waiter((p) => {
return (
(resource in p.groups &&
resource in (p.associations?.graph ?? {}) &&
p.graphKeys.has(resource.slice(7))) ||
resource in (p.associations?.groups ?? {})
);
});
return true;
},
(success: boolean) => {
if (!success) {
return;
}
if (groups?.[resource]?.hidden) {
const { metadata } = associations.graph[resource];
if (metadata && "graph" in metadata.config) {
if (metadata.config.graph === "chat") {
history.push(
`/~landscape/messages/resource/${metadata.config.graph}${resource}`
);
} else {
history.push(
`/~landscape/home/resource/${metadata.config.graph}${resource}`
);
}
} else {
console.error("unknown metadata: ", metadata);
}
} else {
history.push(`/~landscape${resource}`);
}
},
resource
);
}
export function InviteItem(props: InviteItemProps) {
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
const { pendingJoin, invite, resource, uid, app, api } = props;
const { name } = resourceFromPath(resource);
const contacts = useContactState((state) => state.contacts);
const contact = contacts?.[`~${invite?.ship}`] ?? {};
const showNickname = useShowNickname(contact);
const inviteAccept = useInviteAccept(resource, api, app, uid, invite);
const inviteDecline = useCallback(async () => {
if (!(app && uid)) {
return;
}
await api.invite.decline(app, uid);
}, [app, uid]);
const handlers = { onAccept: inviteAccept, onDecline: inviteDecline };
useEffect(() => {
if (!app || app === "groups") {
@ -132,86 +45,17 @@ export function InviteItem(props: InviteItemProps) {
return null;
}
if (preview) {
return (
<GroupInvite
resource={resource}
api={api}
preview={preview}
invite={invite}
status={pendingJoin}
{...handlers}
/>
);
} else if (invite && name.startsWith("dm--")) {
return (
<InviteSkeleton
gapY="3"
{...handlers}
acceptDesc="Join DM"
declineDesc="Decline DM"
>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text
mr="1"
mono={!showNickname}
fontWeight={showNickname ? "500" : "400"}
>
{showNickname ? contact?.nickname : cite(`~${invite!.ship}`)}
</Text>
<Text mr="1">invited you to a DM</Text>
</Row>
</InviteSkeleton>
);
} else if (status && name.startsWith("dm--")) {
return (
<JoinSkeleton api={api} resource={resource} status={status} gapY="3">
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1">Joining direct message...</Text>
</Row>
</JoinSkeleton>
);
} else if (invite) {
return (
<InviteSkeleton
acceptDesc="Accept Invite"
declineDesc="Decline Invite"
resource={resource}
{...handlers}
gapY="3"
>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text
mr="1"
mono={!showNickname}
fontWeight={showNickname ? "500" : "400"}
>
{showNickname ? contact?.nickname : cite(`~${invite!.ship}`)}
</Text>
<Text mr="1">
invited you to ~{invite.resource.ship}/{invite.resource.name}
</Text>
</Row>
</InviteSkeleton>
);
} else if (pendingJoin) {
const [, , ship, name] = resource.split("/");
return (
<JoinSkeleton api={api} resource={resource} status={pendingJoin}>
<Row py="1" alignItems="center">
<Icon display="block" color="blue" icon="Bullet" mr="2" />
<Text mr="1">You are joining</Text>
<Text mono>
{cite(ship)}/{name}
</Text>
</Row>
</JoinSkeleton>
);
return (
<GroupInvite
resource={resource}
api={api}
preview={preview}
invite={invite}
status={pendingJoin}
uid={uid}
app={app}
/>
);
}
return null;
}
export default InviteItem;

View File

@ -23,6 +23,7 @@ const Timestamp = (props: TimestampProps): ReactElement | null => {
relative,
dateNotRelative,
fontSize,
lineHeight,
...rest
} = {
time: true,
@ -62,7 +63,7 @@ const Timestamp = (props: TimestampProps): ReactElement | null => {
title={stamp.format(DateFormat + ' ' + TimeFormat)}
>
{time && (
<Text flexShrink={0} color={color} fontSize={fontSize}>
<Text lineHeight={lineHeight} flexShrink={0} color={color} fontSize={fontSize}>
{timestamp}
</Text>
)}
@ -70,6 +71,7 @@ const Timestamp = (props: TimestampProps): ReactElement | null => {
<Text
flexShrink={0}
color={color}
lineHeight={lineHeight}
fontSize={fontSize}
display={time ? ['none', hovering ? 'block' : 'none'] : 'block'}
>