Merge pull request #4774 from urbit/lf/notif-v2

notifications: FE refactor/redesign
This commit is contained in:
matildepark 2021-04-16 11:48:56 -04:00 committed by GitHub
commit 6e77821a09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 822 additions and 657 deletions

View File

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

View File

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

View File

@ -63,6 +63,16 @@ export function unixToDa(unix: number) {
return DA_UNIX_EPOCH.add(timeSinceEpoch); 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) { export function makePatDa(patda: string) {
return bigInt(udToDec(patda)); return bigInt(udToDec(patda));
} }

View File

@ -16,7 +16,7 @@ const useGroupState = createState<GroupState>('Group', {
}, ['groups']); }, ['groups']);
export function useGroup(group: string) { 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) { 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"; import { BaseState, createState } from "./base";
@ -9,6 +10,14 @@ export interface MetadataState extends BaseState<MetadataState> {
// preview: (group: string) => Promise<MetadataUpdatePreview>; // 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', { const useMetadataState = createState<MetadataState>('Metadata', {
associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} }, associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} },
// preview: async (group): Promise<MetadataUpdatePreview> => { // preview: async (group): Promise<MetadataUpdatePreview> => {

View File

@ -1,65 +1,80 @@
import React, { ReactNode, useCallback } from 'react'; import React, { ReactNode, useCallback } from "react";
import moment from 'moment'; import moment from "moment";
import { Row, Box, Col, Text, Anchor, Icon, Action } from '@tlon/indigo-react'; import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react";
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from "react-router-dom";
import _ from 'lodash'; import _ from "lodash";
import { import {
GraphNotifIndex, GraphNotifIndex,
GraphNotificationContents, GraphNotificationContents,
Associations, Associations,
Rolodex, Rolodex,
Groups Groups,
} from '~/types'; } from "~/types";
import { Header } from './header'; import { Header } from "./header";
import { cite, deSig, pluralize, useShowNickname } from '~/logic/lib/util'; import {
import Author from '~/views/components/Author'; cite,
import GlobalApi from '~/logic/api/global'; deSig,
import { getSnippet } from '~/logic/lib/publish'; pluralize,
import styled from 'styled-components'; useShowNickname,
import { MentionText } from '~/views/components/MentionText'; isDm,
import ChatMessage from '../chat/components/ChatMessage'; } from "~/logic/lib/util";
import useContactState from '~/logic/state/contact'; import { GraphContentWide } from "~/views/landscape/components/Graph/GraphContentWide";
import useGroupState from '~/logic/state/group'; import Author from "~/views/components/Author";
import useMetadataState from '~/logic/state/metadata'; import GlobalApi from "~/logic/api/global";
import {PermalinkEmbed} from '../permalinks/embed'; import styled from "styled-components";
import {parsePermalink, referenceToPermalink} from '~/logic/lib/permalinks'; 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) { function getGraphModuleIcon(module: string) {
if (module === 'link') { if (module === "link") {
return 'Collection'; return "Collection";
} }
if(module === 'post') { if (module === "post") {
return 'Groups'; return "Groups";
} }
return _.capitalize(module); return _.capitalize(module);
} }
const FilterBox = styled(Box)` function describeNotification(
background: linear-gradient( description: string,
to bottom, plural: boolean,
transparent, isDm: boolean,
${(p) => p.theme.colors.white} singleAuthor: boolean
); ): string {
`;
function describeNotification(description: string, plural: boolean): string {
switch (description) { switch (description) {
case 'post': case "post":
return 'replied to you'; return singleAuthor ? "replied to you" : "Your post received replies";
case 'link': case "link":
return `added ${pluralize('new link', plural)} to`; return `New link${plural ? "s" : ""} in`;
case 'comment': case "comment":
return `left ${pluralize('comment', plural)} on`; return `New comment${plural ? "s" : ""} on`;
case 'edit-comment': case "note":
return `updated ${pluralize('comment', plural)} on`; return `New Note${plural ? "s" : ""} in`;
case 'note': case "edit-note":
return `posted ${pluralize('note', plural)} to`; return `updated ${pluralize("note", plural)} in`;
case 'edit-note': case "mention":
return `updated ${pluralize('note', plural)} in`; return singleAuthor ? "mentioned you in" : "You were mentioned in";
case 'mention': case "message":
return 'mentioned you on'; if (isDm) {
case 'message': return "messaged you";
return `sent ${pluralize('message', plural)} to`; }
return `New message${plural ? "s" : ""} in`;
default: default:
return description; return description;
} }
@ -68,104 +83,87 @@ function describeNotification(description: string, plural: boolean): string {
const GraphUrl = ({ contents, api }) => { const GraphUrl = ({ contents, api }) => {
const [{ text }, link] = contents; const [{ text }, link] = contents;
if ("reference" in link) {
if('reference' in link) {
return ( return (
<PermalinkEmbed <PermalinkEmbed
transcluded={1} transcluded={1}
link={referenceToPermalink(link).link} link={referenceToPermalink(link).link}
api={api} api={api}
showOurContact showOurContact
/>); />
);
} }
return ( return (
<Box borderRadius='2' p='2' bg='scales.black05'> <Box borderRadius="2" p="2" bg="scales.black05">
<Anchor underline={false} target='_blank' color='black' href={link.url}> <Anchor underline={false} target="_blank" color="black" href={link.url}>
<Icon verticalAlign='bottom' mr='2' icon='ArrowExternal' /> <Icon verticalAlign="bottom" mr="2" icon="ArrowExternal" />
{text} {text}
</Anchor> </Anchor>
</Box> </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 = ({ export const GraphNodeContent = ({ post, mod, index, hidden, association }) => {
group,
association,
post,
mod,
index,
}) => {
const { contents } = post; const { contents } = post;
const idx = index.slice(1).split('/'); const idx = index.slice(1).split("/");
if (mod === 'link') { const { group, resource } = association;
if (idx.length === 1) { const url = getNodeUrl(mod, hidden, group, resource, index);
return <GraphUrl contents={contents} />; if (mod === "link" && idx.length === 1) {
} else if (idx.length === 3) { const [{ text: title }] = contents;
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') {
return ( return (
<Row <ContentSummary to={url} icon="Links" name={title} author={post.author} />
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>
); );
} }
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( function getNodeUrl(
@ -175,78 +173,103 @@ function getNodeUrl(
graph: string, graph: string,
index: string index: string
) { ) {
if (hidden && mod === 'chat') { if (hidden && mod === "chat") {
groupPath = '/messages'; groupPath = "/messages";
} else if (hidden) { } else if (hidden) {
groupPath = '/home'; groupPath = "/home";
} }
const graphUrl = `/~landscape${groupPath}/resource/${mod}${graph}`; const graphUrl = `/~landscape${groupPath}/resource/${mod}${graph}`;
const idx = index.slice(1).split('/'); const idx = index.slice(1).split("/");
if (mod === 'publish') { if (mod === "publish") {
const [noteId] = idx; console.log(idx);
return `${graphUrl}/note/${noteId}`; const [noteId, kind, commId] = idx;
} else if (mod === 'link') { const selected = kind === "2" ? `?selected=${commId}` : "";
const [linkId] = idx; return `${graphUrl}/note/${noteId}${selected}`;
return `${graphUrl}/index/${linkId}`; } else if (mod === "link") {
} else if (mod === 'chat') { const [linkId, commId] = idx;
if(idx.length > 0) { return `${graphUrl}/index/${linkId}${commId ? `?selected=${commId}` : ""}`;
} else if (mod === "chat") {
if (idx.length > 0) {
return `${graphUrl}?msg=${idx[0]}`; return `${graphUrl}?msg=${idx[0]}`;
} }
return graphUrl; return graphUrl;
} else if( mod === 'post') { } else if (mod === "post") {
return `/~landscape${groupPath}/feed${index}`; 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); interface PostsByAuthor {
const association = useMetadataState( author: string;
useCallback(s => s.associations.graph[graph], [graph]) 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 ( return (
<Row onClick={onClick} gapX='2' pt={showContact ? 2 : 0}> <>
<Col flexGrow={1} alignItems='flex-start'> {_.map(postsByConsecAuthor, ({ posts, author }, idx) => {
{showContact && ( const time = posts[0]?.["time-sent"];
<Author showImage ship={author} date={time} group={group} /> return (
)} <Col key={idx} flexGrow={1} alignItems="flex-start">
<Row width='100%' p='1' flexDirection='column'> {!hideAuthors && (
<GraphNodeContent <Author
post={post} size={24}
mod={mod} sigilPadding={6}
description={description} showImage
association={association} ship={author}
index={index} date={time}
group={group} />
remoteContentPolicy={{}} )}
/> <Col gapY="2" py={hideAuthors ? 0 : 2} width="100%">
</Row> {_.map(posts, (post) => (
</Col> <GraphNodeContent
</Row> 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; api: GlobalApi;
}) { }) {
const { contents, index, read, time, api, timebox } = props; 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 { graph, group } = index;
const icon = getGraphModuleIcon(index.module); const association = useAssocForGraph(graph)!;
const desc = describeNotification(index.description, contents.length !== 1); 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(() => { const onClick = useCallback(() => {
if (props.archived || read) { if (
return; !(
(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]); }, [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 ( return (
<> <>
<Header <Header
onClick={onClick}
archived={props.archived}
time={time} time={time}
read={read} authors={authorsInHeader ? authors : []}
authors={authors} channelTitle={channelTitle}
moduleIcon={icon}
channel={graph}
group={group}
description={desc} description={desc}
groupTitle={groupTitle}
content
/> />
<Box flexGrow={1} width='100%' pl={5} gridArea='main'> <Col onClick={onClick} gapY="2" flexGrow={1} width="100%" gridArea="main">
{_.map(contents, (content, idx) => ( <GraphNodes
<GraphNode hideAuthors={hideAuthors}
post={content} posts={contents.slice(0, 4)}
author={content.author} mod={index.module}
mod={index.module} description={index.description}
time={content?.['time-sent']} index={contents?.[0].index}
description={index.description} association={association}
index={content.index} hidden={groups[association.group]?.hidden}
graph={graph} />
group={groups[group]} {contents.length > 4 && (
groupPath={group} <Text mb="2" gray>
read={read} + {contents.length - 4} more
onRead={onClick} </Text>
showContact={idx === 0} )}
/> </Col>
))}
</Box>
</> </>
); );
} }

View File

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

View File

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

View File

@ -121,7 +121,7 @@ export default function Inbox(props: {
); );
return ( 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} /> <Invites pendingJoin={props.pendingJoin} api={api} />
{[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => { {[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => {
const timeboxes = notificationsByDayMap.get(day)!; const timeboxes = notificationsByDayMap.get(day)!;
@ -175,26 +175,15 @@ function DaySection({
return ( 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(timeboxes.sort(sortTimeboxes), ([date, nots], i: number) =>
_.map(nots.sort(sortIndexedNotification), (not, j: number) => ( _.map(nots.sort(sortIndexedNotification), (not, j: number) => (
<React.Fragment key={j}> <Notification
{(i !== 0 || j !== 0) && ( key={j}
<Box flexShrink={0} height="4px" bg="scales.black05" /> api={api}
)} notification={not}
<Notification archived={archive}
api={api} time={date}
notification={not} />
archived={archive}
time={date}
/>
</React.Fragment>
)) ))
)} )}
</> </>

View File

@ -59,13 +59,6 @@ export function Invites(props: InvitesProps): ReactElement {
return ( 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) {Object.keys(invitesAndStatus)
.sort(alphabeticalOrder) .sort(alphabeticalOrder)
.map((resource) => { .map((resource) => {
@ -89,10 +82,9 @@ export function Invites(props: InvitesProps): ReactElement {
invite={invite} invite={invite}
app={app} app={app}
uid={uid} uid={uid}
join={join}
resource={resource} resource={resource}
/> />
); );
} }
}) })
} }

View File

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

View File

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

View File

@ -1,79 +1,329 @@
import React, { ReactElement, ReactNode } from 'react'; import React, { ReactElement, ReactNode, useCallback } from "react";
import { Text, Box, Icon, Row } from '@tlon/indigo-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 { cite, isDm } from "~/logic/lib/util";
import { MetadataUpdatePreview, JoinProgress, Invite, JoinRequest } from '@urbit/api'; import {
import { GroupSummary } from '~/views/landscape/components/GroupSummary'; MetadataUpdatePreview,
import { InviteSkeleton } from './InviteSkeleton'; joinProgress,
import { JoinSkeleton } from './JoinSkeleton'; JoinProgress,
import GlobalApi from '~/logic/api/global'; 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 { interface GroupInviteProps {
preview: MetadataUpdatePreview; preview?: MetadataUpdatePreview;
status?: JoinRequest; status?: JoinRequest;
app?: string;
uid?: string;
invite?: Invite; invite?: Invite;
resource: string; resource: string;
api: GlobalApi; api: GlobalApi;
onAccept: () => Promise<any>;
onDecline: () => Promise<any>;
} }
export function GroupInvite(props: GroupInviteProps): ReactElement { function Elbow(
const { resource, api, preview, invite, status, onAccept, onDecline } = props; props: { size?: number; color?: string } & PropFunc<typeof Box>
const { metadata, members } = props.preview; ) {
const { size = 12, color = "lightGray", ...rest } = props;
let inner: ReactNode = null; return (
let Outer: (p: { children: ReactNode }) => JSX.Element = p => ( <Box
<>{p.children}</> {...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) { if (preview) {
inner = ( const { title } = preview.metadata;
<Text mr="1"> const { members } = preview;
You are joining <Text fontWeight="medium">{metadata.title}</Text> return container(
</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 = (
<> <>
<Text mr="1" mono> <MetadataIcon height={4} width={4} metadata={preview.metadata} />
{cite(`~${invite!.ship}`)} <Text fontWeight="medium">{title}</Text>
<Text gray fontWeight="medium">
{members} Member{members > 1 ? "s" : ""}
</Text> </Text>
<Text mr="1">invited you to </Text>
<Text fontWeight="medium">{metadata.title}</Text>
</> </>
); );
} }
return (
<Outer> return container(
<Row py="1" alignItems="center"> <>
<Icon display="block" mr={2} icon="Bullet" color="blue" /> <Text whiteSpace="nowrap" textOverflow="ellipsis" ml="1px" mb="2px" mono>
{inner} {cite(ship)}/{name}
</Row> </Text>
<Box px="4"> </>
<GroupSummary );
gray }
metadata={metadata}
memberCount={members} function InviteStatus(props: { status?: JoinRequest }) {
channelCount={preview?.['channel-count']} const { status } = props;
/>
</Box> if (!status) {
</Outer> 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, JoinRequests,
Groups, Groups,
Associations, Associations,
JoinRequest,
} from "@urbit/api"; } from "@urbit/api";
import { Invite } from "@urbit/api/invite"; 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 GlobalApi from "~/logic/api/global";
import { resourceFromPath } from "~/logic/lib/group";
import { GroupInvite } from "./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 { interface InviteItemProps {
invite?: Invite; invite?: Invite;
resource: string; resource: string;
pendingJoin?: string; pendingJoin?: JoinRequest;
app?: string; app?: string;
uid?: string; uid?: string;
api: GlobalApi; 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) { export function InviteItem(props: InviteItemProps) {
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null); const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
const { pendingJoin, invite, resource, uid, app, api } = props; 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(() => { useEffect(() => {
if (!app || app === "groups") { if (!app || app === "groups") {
@ -132,86 +45,17 @@ export function InviteItem(props: InviteItemProps) {
return null; return null;
} }
if (preview) { return (
return ( <GroupInvite
<GroupInvite resource={resource}
resource={resource} api={api}
api={api} preview={preview}
preview={preview} invite={invite}
invite={invite} status={pendingJoin}
status={pendingJoin} uid={uid}
{...handlers} app={app}
/> />
); );
} 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 null;
}
export default InviteItem; export default InviteItem;

View File

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