mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-15 18:12:47 +03:00
Merge remote-tracking branch 'origin/release/next-js'
This commit is contained in:
commit
f9cf711c69
@ -32,7 +32,7 @@ same (if [developing on a local development ship][local]). Then, from
|
||||
'pkg/interface':
|
||||
|
||||
```
|
||||
npm install
|
||||
npm ci
|
||||
npm run start
|
||||
```
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
@ -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
12
pkg/interface/preinstall.sh
Executable 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
|
@ -4,6 +4,11 @@ import { dateToDa, decToUd } from '../lib/util';
|
||||
import { NotifIndex, IndexedNotification, Association, GraphNotifDescription } from '@urbit/api';
|
||||
import { BigInteger } from 'big-integer';
|
||||
import { getParentIndex } from '../lib/notification';
|
||||
import useHarkState from '../state/hark';
|
||||
|
||||
function getHarkSize() {
|
||||
return useHarkState.getState().notifications.size ?? 0;
|
||||
}
|
||||
|
||||
export class HarkApi extends BaseApi<StoreState> {
|
||||
private harkAction(action: any): Promise<any> {
|
||||
@ -172,10 +177,10 @@ export class HarkApi extends BaseApi<StoreState> {
|
||||
}
|
||||
|
||||
async getMore(): Promise<boolean> {
|
||||
const offset = this.store.state['notifications']?.size || 0;
|
||||
const offset = getHarkSize();
|
||||
const count = 3;
|
||||
await this.getSubset(offset, count, false);
|
||||
return offset === (this.store.state.notifications?.size || 0);
|
||||
return offset === getHarkSize();
|
||||
}
|
||||
|
||||
async getSubset(offset:number, count:number, isArchive: boolean) {
|
||||
|
18
pkg/interface/src/logic/lib/formGroup.ts
Normal file
18
pkg/interface/src/logic/lib/formGroup.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
export type SubmitHandler = () => Promise<any>;
|
||||
interface IFormGroupContext {
|
||||
addSubmit: (id: string, submit: SubmitHandler) => void;
|
||||
onDirty: (id: string, touched: boolean) => void;
|
||||
onErrors: (id: string, errors: boolean) => void;
|
||||
submitAll: () => Promise<any>;
|
||||
}
|
||||
|
||||
const fallback: IFormGroupContext = {
|
||||
addSubmit: () => {},
|
||||
onDirty: () => {},
|
||||
onErrors: () => {},
|
||||
submitAll: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
export const FormGroupContext = React.createContext(fallback);
|
@ -1,6 +1,6 @@
|
||||
import bigInt, { BigInteger } from 'big-integer';
|
||||
import f from 'lodash/fp';
|
||||
import { Unreads } from '@urbit/api';
|
||||
import { Unreads, NotificationGraphConfig } from '@urbit/api';
|
||||
|
||||
export function getLastSeen(
|
||||
unreads: Unreads,
|
||||
@ -34,3 +34,13 @@ export function getNotificationCount(
|
||||
.map(index => unread[index]?.notifications?.length || 0)
|
||||
.reduce(f.add, 0);
|
||||
}
|
||||
|
||||
export function isWatching(
|
||||
config: NotificationGraphConfig,
|
||||
graph: string,
|
||||
index = "/"
|
||||
) {
|
||||
return !!config.watching.find(
|
||||
watch => watch.graph === graph && watch.index === index
|
||||
);
|
||||
}
|
||||
|
@ -4,3 +4,7 @@ const ua = window.navigator.userAgent;
|
||||
export const IS_IOS = ua.includes('iPhone');
|
||||
|
||||
export const IS_SAFARI = ua.includes('Safari') && !ua.includes('Chrome');
|
||||
|
||||
export const IS_ANDROID = ua.includes('Android');
|
||||
|
||||
export const IS_MOBILE = IS_IOS || IS_ANDROID;
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -8,12 +8,6 @@ export interface ContactState extends BaseState<ContactState> {
|
||||
isContactPublic: boolean;
|
||||
nackedContacts: Set<Patp>;
|
||||
// fetchIsAllowed: (entity, name, ship, personal) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function useContact(ship: string) {
|
||||
return useContactState(
|
||||
useCallback(s => s.contacts[ship] as Contact | null, [ship])
|
||||
);
|
||||
}
|
||||
|
||||
const useContactState = createState<ContactState>('Contact', {
|
||||
@ -35,4 +29,10 @@ const useContactState = createState<ContactState>('Contact', {
|
||||
// },
|
||||
}, ['nackedContacts']);
|
||||
|
||||
export function useContact(ship: string) {
|
||||
return useContactState(
|
||||
useCallback(s => s.contacts[ship] as Contact | null, [ship])
|
||||
);
|
||||
}
|
||||
|
||||
export default useContactState;
|
||||
|
@ -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) {
|
||||
|
@ -15,7 +15,7 @@ export interface HarkState extends BaseState<HarkState> {
|
||||
notifications: BigIntOrderedMap<Timebox>;
|
||||
notificationsCount: number;
|
||||
notificationsGraphConfig: NotificationGraphConfig; // TODO unthread this everywhere
|
||||
notificationsGroupConfig: []; // TODO type this
|
||||
notificationsGroupConfig: string[];
|
||||
unreads: Unreads;
|
||||
};
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { MetadataUpdatePreview, Associations } from "@urbit/api";
|
||||
import { useCallback } from 'react';
|
||||
import _ from 'lodash';
|
||||
import { MetadataUpdatePreview, Association, Associations } from "@urbit/api";
|
||||
|
||||
import { BaseState, createState } from "./base";
|
||||
|
||||
@ -9,6 +11,19 @@ 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]));
|
||||
}
|
||||
|
||||
export function useGraphsForGroup(group: string) {
|
||||
const graphs = useMetadataState(s => s.associations.graph);
|
||||
return _.pickBy(graphs, (a: Association) => a.group === group);
|
||||
}
|
||||
|
||||
const useMetadataState = createState<MetadataState>('Metadata', {
|
||||
associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} },
|
||||
// preview: async (group): Promise<MetadataUpdatePreview> => {
|
||||
|
@ -58,7 +58,7 @@ const useSettingsState = createState<SettingsState>('Settings', {
|
||||
categories: leapCategories,
|
||||
},
|
||||
tutorial: {
|
||||
seen: false,
|
||||
seen: true,
|
||||
joined: undefined
|
||||
}
|
||||
});
|
||||
|
@ -53,6 +53,7 @@ const Root = withState(styled.div`
|
||||
}
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
touch-action: none;
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
|
@ -1,4 +1,5 @@
|
||||
/* eslint-disable max-lines-per-function */
|
||||
import bigInt from 'big-integer';
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@ -19,7 +20,8 @@ import {
|
||||
writeText,
|
||||
useShowNickname,
|
||||
useHideAvatar,
|
||||
useHovering
|
||||
useHovering,
|
||||
daToUnix
|
||||
} from '~/logic/lib/util';
|
||||
import {
|
||||
Group,
|
||||
@ -295,15 +297,20 @@ class ChatMessage extends Component<ChatMessageProps> {
|
||||
);
|
||||
}
|
||||
|
||||
const date = daToUnix(bigInt(msg.index.split('/')[1]));
|
||||
const nextDate = nextMsg ? (
|
||||
daToUnix(bigInt(nextMsg.index.split('/')[1]))
|
||||
) : null;
|
||||
|
||||
const dayBreak =
|
||||
nextMsg &&
|
||||
new Date(msg['time-sent']).getDate() !==
|
||||
new Date(nextMsg['time-sent']).getDate();
|
||||
new Date(date).getDate() !==
|
||||
new Date(nextDate).getDate();
|
||||
|
||||
const containerClass = `${isPending ? 'o-40' : ''} ${className}`;
|
||||
|
||||
const timestamp = moment
|
||||
.unix(msg['time-sent'] / 1000)
|
||||
.unix(date / 1000)
|
||||
.format(renderSigil ? 'h:mm A' : 'h:mm');
|
||||
|
||||
const messageProps = {
|
||||
@ -339,7 +346,7 @@ class ChatMessage extends Component<ChatMessageProps> {
|
||||
style={style}
|
||||
>
|
||||
{dayBreak && !isLastRead ? (
|
||||
<DayBreak when={msg['time-sent']} shimTop={renderSigil} />
|
||||
<DayBreak when={date} shimTop={renderSigil} />
|
||||
) : null}
|
||||
{renderSigil ? (
|
||||
<MessageWrapper {...messageProps}>
|
||||
@ -357,7 +364,7 @@ class ChatMessage extends Component<ChatMessageProps> {
|
||||
association={association}
|
||||
api={api}
|
||||
dayBreak={dayBreak}
|
||||
when={msg['time-sent']}
|
||||
when={date}
|
||||
ref={unreadMarkerRef}
|
||||
/>
|
||||
) : null}
|
||||
@ -387,8 +394,10 @@ export const MessageAuthor = ({
|
||||
const dark = theme === 'dark' || (theme === 'auto' && osDark);
|
||||
const contacts = useContactState((state) => state.contacts);
|
||||
|
||||
const date = daToUnix(bigInt(msg.index.split('/')[1]));
|
||||
|
||||
const datestamp = moment
|
||||
.unix(msg['time-sent'] / 1000)
|
||||
.unix(date / 1000)
|
||||
.format(DATESTAMP_FORMAT);
|
||||
const contact =
|
||||
((msg.author === window.ship && showOurContact) ||
|
||||
|
@ -98,8 +98,15 @@ h2 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.embed-container:not(.embed-container .embed-container):not(.links) {
|
||||
padding: 0px 8px 8px 8px;
|
||||
}
|
||||
|
||||
.embed-container iframe {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mh-16 {
|
||||
|
@ -46,7 +46,7 @@ const ScrollbarLessBox = styled(Box)`
|
||||
const tutSelector = f.pick(['tutorialProgress', 'nextTutStep', 'hideGroups']);
|
||||
|
||||
export default function LaunchApp(props) {
|
||||
const connection = { props };
|
||||
const { connection } = props;
|
||||
const baseHash = useLaunchState(state => state.baseHash);
|
||||
const [hashText, setHashText] = useState(baseHash);
|
||||
const [exitingTut, setExitingTut] = useState(false);
|
||||
|
@ -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;
|
||||
}
|
||||
@ -68,104 +83,87 @@ 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
|
||||
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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const GraphNodeContent = ({
|
||||
group,
|
||||
association,
|
||||
post,
|
||||
mod,
|
||||
index,
|
||||
}) => {
|
||||
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') {
|
||||
function ContentSummary({ icon, name, author, to }) {
|
||||
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 (
|
||||
<Row
|
||||
width='100%'
|
||||
flexShrink={0}
|
||||
flexGrow={1}
|
||||
flexWrap='wrap'
|
||||
marginLeft='-32px'
|
||||
<Link to={to}>
|
||||
<Col
|
||||
gapY="1"
|
||||
flexDirection={["column", "row"]}
|
||||
alignItems={["flex-start", "center"]}
|
||||
>
|
||||
<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
|
||||
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>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
export const GraphNodeContent = ({ post, mod, index, hidden, association }) => {
|
||||
const { contents } = post;
|
||||
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 (
|
||||
<ContentSummary to={url} icon="Links" name={title} author={post.author} />
|
||||
);
|
||||
}
|
||||
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') {
|
||||
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} />
|
||||
<>
|
||||
{_.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}
|
||||
/>
|
||||
)}
|
||||
<Row width='100%' p='1' flexDirection='column'>
|
||||
<Col gapY="2" py={hideAuthors ? 0 : 2} width="100%">
|
||||
{_.map(posts, (post) => (
|
||||
<GraphNodeContent
|
||||
key={post.index}
|
||||
post={post}
|
||||
mod={mod}
|
||||
description={description}
|
||||
association={association}
|
||||
index={index}
|
||||
group={group}
|
||||
remoteContentPolicy={{}}
|
||||
association={association}
|
||||
hidden={hidden}
|
||||
/>
|
||||
</Row>
|
||||
))}
|
||||
</Col>
|
||||
</Row>
|
||||
</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}
|
||||
<Col onClick={onClick} gapY="2" flexGrow={1} width="100%" gridArea="main">
|
||||
<GraphNodes
|
||||
hideAuthors={hideAuthors}
|
||||
posts={contents.slice(0, 4)}
|
||||
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}
|
||||
index={contents?.[0].index}
|
||||
association={association}
|
||||
hidden={groups[association.group]?.hidden}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
{contents.length > 4 && (
|
||||
<Text mb="2" gray>
|
||||
+ {contents.length - 4} more
|
||||
</Text>
|
||||
)}
|
||||
</Col>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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}`;
|
||||
|
||||
return (
|
||||
<Text mono={!showNickname}>
|
||||
{name}
|
||||
{!props.last && ', '}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header(props: {
|
||||
authors: string[];
|
||||
archived?: boolean;
|
||||
channel?: string;
|
||||
group: string;
|
||||
export function Header(
|
||||
props: {
|
||||
channelTitle?: string;
|
||||
groupTitle?: 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;
|
||||
time?: number;
|
||||
authors?: string[];
|
||||
content?: boolean;
|
||||
} & PropFunc<typeof Row>
|
||||
): ReactElement {
|
||||
const {
|
||||
description,
|
||||
channelTitle = "",
|
||||
groupTitle,
|
||||
authors = [],
|
||||
content = false,
|
||||
time,
|
||||
} = props;
|
||||
|
||||
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"
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -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
|
||||
key={j}
|
||||
api={api}
|
||||
notification={not}
|
||||
archived={archive}
|
||||
time={date}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
|
@ -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,7 +82,6 @@ export function Invites(props: InvitesProps): ReactElement {
|
||||
invite={invite}
|
||||
app={app}
|
||||
uid={uid}
|
||||
join={join}
|
||||
resource={resource}
|
||||
/>
|
||||
);
|
||||
|
@ -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, Icon } from "@tlon/indigo-react";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
GraphNotificationContents,
|
||||
IndexedNotification,
|
||||
@ -9,16 +9,17 @@ 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";
|
||||
import {IS_MOBILE} from "~/logic/lib/platform";
|
||||
|
||||
interface NotificationProps {
|
||||
notification: IndexedNotification;
|
||||
@ -33,72 +34,98 @@ 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(
|
||||
return (
|
||||
_.findIndex(
|
||||
graphs?.watching || [],
|
||||
g => g.graph === graph && g.index === parent
|
||||
) === -1;
|
||||
(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 24px", "1fr 200px"]}
|
||||
gridTemplateRows="auto"
|
||||
gridTemplateAreas="'header actions' 'main main'"
|
||||
pb={2}
|
||||
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
|
||||
<Row
|
||||
alignItems="flex-start"
|
||||
gapX="2"
|
||||
gridArea="actions"
|
||||
justifyContent="flex-end"
|
||||
opacity={[1, (hovering || IS_MOBILE) ? 1 : 0]}
|
||||
>
|
||||
{time && notification && (
|
||||
<StatelessAsyncAction
|
||||
name={time.toString()}
|
||||
borderRadius={1}
|
||||
onClick={onArchive}
|
||||
backgroundColor="white"
|
||||
>
|
||||
<Icon lineHeight="24px" size={16} icon="X" />
|
||||
</StatelessAsyncAction>
|
||||
)}
|
||||
</Row>
|
||||
@ -110,23 +137,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 +158,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 +175,7 @@ export function Notification(props: NotificationProps) {
|
||||
archived={archived}
|
||||
time={time}
|
||||
/>
|
||||
</Wrapper>
|
||||
</NotificationWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,7 @@ import _ from 'lodash';
|
||||
import { Link, Switch, Route } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
import { Box, Col, Text, Row } from '@tlon/indigo-react';
|
||||
import { Box, Icon, Col, Text, Row } from '@tlon/indigo-react';
|
||||
|
||||
import { Body } from '~/views/components/Body';
|
||||
import { PropFunc } from '~/types/util';
|
||||
@ -15,6 +15,7 @@ import { useTutorialModal } from '~/views/components/useTutorialModal';
|
||||
import useHarkState from '~/logic/state/hark';
|
||||
import useMetadataState from '~/logic/state/metadata';
|
||||
import useGroupState from '~/logic/state/group';
|
||||
import {StatelessAsyncAction} from '~/views/components/StatelessAsyncAction';
|
||||
|
||||
const baseUrl = '/~notifications';
|
||||
|
||||
@ -46,8 +47,8 @@ export default function NotificationsScreen(props: any): ReactElement {
|
||||
const onSubmit = async ({ groups } : NotificationFilter) => {
|
||||
setFilter({ groups });
|
||||
};
|
||||
const onReadAll = useCallback(() => {
|
||||
props.api.hark.readAll();
|
||||
const onReadAll = useCallback(async () => {
|
||||
await props.api.hark.readAll();
|
||||
}, []);
|
||||
const groupFilterDesc =
|
||||
filter.groups.length === 0
|
||||
@ -81,53 +82,25 @@ export default function NotificationsScreen(props: any): ReactElement {
|
||||
borderBottomColor="lightGray"
|
||||
>
|
||||
|
||||
<Text ref={anchorRef}>Notifications</Text>
|
||||
<Text fontWeight="bold" fontSize="2" lineHeight="1" ref={anchorRef}>
|
||||
Notifications
|
||||
</Text>
|
||||
<Row
|
||||
justifyContent="space-between"
|
||||
gapX="3"
|
||||
>
|
||||
<Box
|
||||
mr="1"
|
||||
<StatelessAsyncAction
|
||||
overflow="hidden"
|
||||
color="black"
|
||||
onClick={onReadAll}
|
||||
cursor="pointer"
|
||||
>
|
||||
<Text mr="1" color="blue">
|
||||
Mark All Read
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Dropdown
|
||||
alignX="right"
|
||||
alignY="top"
|
||||
options={
|
||||
<Col
|
||||
p="2"
|
||||
backgroundColor="white"
|
||||
border={1}
|
||||
borderRadius={1}
|
||||
borderColor="lightGray"
|
||||
gapY="2"
|
||||
>
|
||||
<FormikOnBlur
|
||||
initialValues={filter}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<GroupSearch
|
||||
id="groups"
|
||||
label="Filter Groups"
|
||||
caption="Only show notifications from this group"
|
||||
/>
|
||||
</FormikOnBlur>
|
||||
</Col>
|
||||
}
|
||||
>
|
||||
</StatelessAsyncAction>
|
||||
<Link to="/~settings#notifications">
|
||||
<Box>
|
||||
<Text mr="1" gray>
|
||||
Filter:
|
||||
</Text>
|
||||
<Text>{groupFilterDesc}</Text>
|
||||
<Icon lineHeight="1" icon="Adjust" />
|
||||
</Box>
|
||||
</Dropdown>
|
||||
</Link>
|
||||
</Row>
|
||||
</Row>
|
||||
{!view && <Inbox
|
||||
|
@ -64,20 +64,20 @@ export function ProfileHeaderImageEdit(props: any): ReactElement {
|
||||
{contact?.cover ? (
|
||||
<div>
|
||||
{editCover ? (
|
||||
<ImageInput id='cover' marginTop='-8px' />
|
||||
<ImageInput id='cover' marginTop='-8px' width='288px' />
|
||||
) : (
|
||||
<Row>
|
||||
<Button mr='2' onClick={() => setEditCover(true)}>
|
||||
Replace Header
|
||||
</Button>
|
||||
<Button onClick={(e) => handleClear(e)}>
|
||||
<Button onClick={e => handleClear(e)}>
|
||||
{removedCoverLabel}
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ImageInput id='cover' marginTop='-8px' />
|
||||
<ImageInput id='cover' marginTop='-8px' width='288px' />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -134,7 +134,13 @@ export function ProfileActions(props: any): ReactElement {
|
||||
history.push(`/~profile/${ship}/edit`);
|
||||
}}
|
||||
>
|
||||
Edit {isPublic ? 'Public' : 'Private'} Profile
|
||||
Edit
|
||||
<Text
|
||||
fontWeight='500'
|
||||
cursor='pointer'
|
||||
display={['none','inline']}>
|
||||
{isPublic ? ' Public' : ' Private'} Profile
|
||||
</Text>
|
||||
</Text>
|
||||
<SetStatusBarModal
|
||||
isControl
|
||||
@ -183,7 +189,7 @@ export function Profile(props: any): ReactElement | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<Center p={[0, 4]} height='100%' width='100%'>
|
||||
<Center p={[3, 4]} height='100%' width='100%'>
|
||||
<Box maxWidth='600px' width='100%' position='relative'>
|
||||
{ isEdit ? (
|
||||
<EditProfile
|
||||
|
@ -49,20 +49,20 @@
|
||||
font-family: 'Source Code Pro';
|
||||
}
|
||||
|
||||
.publish .CodeMirror-selected { background:#BAE3FE !important; color: black; }
|
||||
.publish .CodeMirror-selected { background:#BAE3FE !important; color: inherit; }
|
||||
|
||||
.publish .cm-s-tlon span { font-family: "Source Code Pro"}
|
||||
.publish .cm-s-tlon span.cm-meta { color: var(--gray); }
|
||||
.publish .cm-s-tlon span.cm-number { color: var(--gray); }
|
||||
.publish .cm-s-tlon span.cm-keyword { line-height: 1em; font-weight: bold; color: var(--gray); }
|
||||
.publish .cm-s-tlon span.cm-atom { font-weight: bold; color: var(--gray); }
|
||||
.publish .cm-s-tlon span.cm-def { color: black; }
|
||||
.publish .cm-s-tlon span.cm-variable { color: black; }
|
||||
.publish .cm-s-tlon span.cm-variable-2 { color: black; }
|
||||
.publish .cm-s-tlon span.cm-variable-3, .publish .cm-s-tlon span.cm-type { color: black; }
|
||||
.publish .cm-s-tlon span.cm-property { color: black; }
|
||||
.publish .cm-s-tlon span.cm-operator { color: black; }
|
||||
.publish .cm-s-tlon span.cm-comment { color: black; background-color: var(--light-gray); display: inline-block; border-radius: 2px;}
|
||||
.publish .cm-s-tlon span.cm-def { color: inherit; }
|
||||
.publish .cm-s-tlon span.cm-variable { color: inherit; }
|
||||
.publish .cm-s-tlon span.cm-variable-2 { color: inherit; }
|
||||
.publish .cm-s-tlon span.cm-variable-3, .publish .cm-s-tlon span.cm-type { color: inherit; }
|
||||
.publish .cm-s-tlon span.cm-property { color: inherit; }
|
||||
.publish .cm-s-tlon span.cm-operator { color: inherit; }
|
||||
.publish .cm-s-tlon span.cm-comment { color: inherit; background-color: var(--light-gray); display: inline-block; border-radius: 2px;}
|
||||
.publish .cm-s-tlon span.cm-string { color: var(--dark-gray); }
|
||||
.publish .cm-s-tlon span.cm-string-2 { color: var(--gray); }
|
||||
.publish .cm-s-tlon span.cm-qualifier { color: #555; }
|
||||
|
@ -1,32 +1,36 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Row,
|
||||
Label,
|
||||
Col,
|
||||
ManagedRadioButtonField as Radio,
|
||||
ManagedRadioButtonField as Radio
|
||||
} from '@tlon/indigo-react';
|
||||
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { ImageInput } from '~/views/components/ImageInput';
|
||||
import { ColorInput } from '~/views/components/ColorInput';
|
||||
import { StorageState } from '~/types';
|
||||
|
||||
export type BgType = 'none' | 'url' | 'color';
|
||||
|
||||
export function BackgroundPicker({
|
||||
bgType,
|
||||
bgUrl,
|
||||
api,
|
||||
api
|
||||
}: {
|
||||
bgType: BgType;
|
||||
bgUrl?: string;
|
||||
api: GlobalApi;
|
||||
}): ReactElement {
|
||||
const rowSpace = { my: 0, alignItems: 'center' };
|
||||
const colProps = { my: 3, mr: 4, gapY: 1 };
|
||||
const colProps = {
|
||||
my: 3,
|
||||
mr: 4,
|
||||
gapY: 1,
|
||||
minWidth: '266px',
|
||||
width: ['100%', '288px']
|
||||
};
|
||||
return (
|
||||
<Col>
|
||||
<Label>Landscape Background</Label>
|
||||
@ -40,7 +44,7 @@ export function BackgroundPicker({
|
||||
id="bgUrl"
|
||||
placeholder="Drop or upload a file, or paste a link here"
|
||||
name="bgUrl"
|
||||
url={bgUrl || ""}
|
||||
url={bgUrl || ''}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
@ -0,0 +1,135 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Icon,
|
||||
ManagedToggleSwitchField,
|
||||
StatelessToggleSwitchField,
|
||||
Col,
|
||||
Center,
|
||||
} from "@tlon/indigo-react";
|
||||
import _ from "lodash";
|
||||
|
||||
import useMetadataState, { useGraphsForGroup } from "~/logic/state/metadata";
|
||||
import { Association, resourceFromPath } from "@urbit/api";
|
||||
import { MetadataIcon } from "~/views/landscape/components/MetadataIcon";
|
||||
import useGraphState from "~/logic/state/graph";
|
||||
import { useField } from "formik";
|
||||
import useHarkState from "~/logic/state/hark";
|
||||
import { getModuleIcon } from "~/logic/lib/util";
|
||||
import {isWatching} from "~/logic/lib/hark";
|
||||
|
||||
export function GroupChannelPicker(props: {}) {
|
||||
const associations = useMetadataState((s) => s.associations);
|
||||
|
||||
return (
|
||||
<Col gapY="3">
|
||||
{_.map(associations.groups, (assoc: Association, group: string) => (
|
||||
<GroupWithChannels key={group} association={assoc} />
|
||||
))}
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupWithChannels(props: { association: Association }) {
|
||||
const { association } = props;
|
||||
const { metadata } = association;
|
||||
|
||||
const groupWatched = useHarkState((s) =>
|
||||
s.notificationsGroupConfig.includes(association.group)
|
||||
);
|
||||
|
||||
const [{ value }, meta, { setValue }] = useField(
|
||||
`groups["${association.group}"]`
|
||||
);
|
||||
|
||||
|
||||
const onChange = () => {
|
||||
setValue(!value);
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setValue(groupWatched);
|
||||
}, []);
|
||||
|
||||
const graphs = useGraphsForGroup(association.group);
|
||||
const joinedGraphs = useGraphState((s) => s.graphKeys);
|
||||
const joinedGroupGraphs = _.pickBy(graphs, (_, graph: string) => {
|
||||
const { ship, name } = resourceFromPath(graph);
|
||||
return joinedGraphs.has(`${ship.slice(1)}/${name}`);
|
||||
});
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns="24px 24px 1fr 24px 24px"
|
||||
gridTemplateRows="auto"
|
||||
gridGap="2"
|
||||
gridTemplateAreas="'arrow icon title graphToggle groupToggle'"
|
||||
>
|
||||
{Object.keys(joinedGroupGraphs).length > 0 && (
|
||||
<Center
|
||||
cursor="pointer"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
gridArea="arrow"
|
||||
>
|
||||
<Icon icon={open ? "ChevronSouth" : "ChevronEast"} />
|
||||
</Center>
|
||||
)}
|
||||
<MetadataIcon
|
||||
size="24px"
|
||||
gridArea="icon"
|
||||
metadata={association.metadata}
|
||||
/>
|
||||
<Box gridArea="title">
|
||||
<Text>{metadata.title}</Text>
|
||||
</Box>
|
||||
<Box gridArea="groupToggle">
|
||||
<StatelessToggleSwitchField selected={value} onChange={onChange} />
|
||||
</Box>
|
||||
{open &&
|
||||
_.map(joinedGroupGraphs, (a: Association, graph: string) => (
|
||||
<Channel key={graph} association={a} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
function Channel(props: { association: Association }) {
|
||||
const { association } = props;
|
||||
const { metadata } = association;
|
||||
const watching = useHarkState((s) => {
|
||||
const config = s.notificationsGraphConfig;
|
||||
return isWatching(config, association.resource);
|
||||
});
|
||||
|
||||
const [{ value }, meta, { setValue }] = useField(
|
||||
`graph["${association.resource}"]`
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(watching);
|
||||
}, [watching]);
|
||||
|
||||
const onChange = () => {
|
||||
setValue(!value);
|
||||
};
|
||||
|
||||
const icon = getModuleIcon(metadata.config?.graph);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Center gridColumn="2">
|
||||
<Icon icon={icon} />
|
||||
</Center>
|
||||
<Box gridColumn="3">
|
||||
<Text> {metadata.title}</Text>
|
||||
</Box>
|
||||
<Box gridColumn="4">
|
||||
<StatelessToggleSwitchField selected={value} onChange={onChange} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
@ -10,11 +10,19 @@ import GlobalApi from "~/logic/api/global";
|
||||
import useHarkState from "~/logic/state/hark";
|
||||
import _ from "lodash";
|
||||
import {AsyncButton} from "~/views/components/AsyncButton";
|
||||
import {GroupChannelPicker} from "./GroupChannelPicker";
|
||||
import {isWatching} from "~/logic/lib/hark";
|
||||
|
||||
interface FormSchema {
|
||||
mentions: boolean;
|
||||
dnd: boolean;
|
||||
watchOnSelf: boolean;
|
||||
graph: {
|
||||
[rid: string]: boolean;
|
||||
};
|
||||
groups: {
|
||||
[rid: string]: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export function NotificationPreferences(props: {
|
||||
@ -23,6 +31,7 @@ export function NotificationPreferences(props: {
|
||||
const { api } = props;
|
||||
const dnd = useHarkState(state => state.doNotDisturb);
|
||||
const graphConfig = useHarkState(state => state.notificationsGraphConfig);
|
||||
const groupConfig = useHarkState(s => s.notificationsGroupConfig);
|
||||
const initialValues = {
|
||||
mentions: graphConfig.mentions,
|
||||
dnd: dnd,
|
||||
@ -41,6 +50,16 @@ export function NotificationPreferences(props: {
|
||||
if (values.dnd !== dnd && !_.isUndefined(values.dnd)) {
|
||||
promises.push(api.hark.setDoNotDisturb(values.dnd))
|
||||
}
|
||||
_.forEach(values.graph, (listen: boolean, graph: string) => {
|
||||
if(listen !== isWatching(graphConfig, graph)) {
|
||||
promises.push(api.hark[listen ? "listenGraph" : "ignoreGraph"](graph, "/"))
|
||||
}
|
||||
});
|
||||
_.forEach(values.groups, (listen: boolean, group: string) => {
|
||||
if(listen !== groupConfig.includes(group)) {
|
||||
promises.push(api.hark[listen ? "listenGroup" : "ignoreGroup"](group));
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
actions.setStatus({ success: null });
|
||||
@ -81,6 +100,15 @@ export function NotificationPreferences(props: {
|
||||
id="mentions"
|
||||
caption="Notify me if someone mentions my @p in a channel I've joined"
|
||||
/>
|
||||
<Col gapY="3">
|
||||
<Text lineHeight="tall">
|
||||
Activity
|
||||
</Text>
|
||||
<Text gray>
|
||||
Set which groups will send you notifications.
|
||||
</Text>
|
||||
<GroupChannelPicker />
|
||||
</Col>
|
||||
<AsyncButton primary width="fit-content">
|
||||
Save
|
||||
</AsyncButton>
|
||||
|
@ -83,7 +83,7 @@ export default function S3Form(props: S3FormProps): ReactElement {
|
||||
style={{ textDecoration: 'none' }}
|
||||
borderBottom='1'
|
||||
ml='1'
|
||||
href='https://urbit.org/using/operations/using-your-ship/#bucket-setup'
|
||||
href='https://urbit.org/using/os/s3/'
|
||||
>
|
||||
Learn more
|
||||
</Anchor>
|
||||
|
@ -9,16 +9,16 @@ import { Group } from '@urbit/api';
|
||||
import { uxToHex, cite, useShowNickname, deSig } from '~/logic/lib/util';
|
||||
import useSettingsState, {selectCalmState} from "~/logic/state/settings";
|
||||
import useLocalState from "~/logic/state/local";
|
||||
import OverlaySigil from './OverlaySigil';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import Timestamp from './Timestamp';
|
||||
import useContactState from '~/logic/state/contact';
|
||||
import { useCopy } from '~/logic/lib/useCopy';
|
||||
import ProfileOverlay from './ProfileOverlay';
|
||||
import { PropFunc } from '~/types';
|
||||
|
||||
interface AuthorProps {
|
||||
ship: string;
|
||||
date: number;
|
||||
date?: number;
|
||||
showImage?: boolean;
|
||||
children?: ReactNode;
|
||||
unread?: boolean;
|
||||
@ -61,6 +61,7 @@ export default function Author(props: AuthorProps & PropFunc<typeof Box>): React
|
||||
const { hideAvatars } = useSettingsState(selectCalmState);
|
||||
const name = showNickname && contact ? contact.nickname : cite(ship);
|
||||
const stamp = moment(date);
|
||||
const { copyDisplay, doCopy, didCopy } = useCopy(`~${ship}`, name);
|
||||
|
||||
const [showOverlay, setShowOverlay] = useState(false);
|
||||
|
||||
@ -108,13 +109,17 @@ export default function Author(props: AuthorProps & PropFunc<typeof Box>): React
|
||||
ml={showImage ? 2 : 0}
|
||||
color='black'
|
||||
fontSize='1'
|
||||
cursor='pointer'
|
||||
lineHeight='tall'
|
||||
fontFamily={showNickname ? 'sans' : 'mono'}
|
||||
fontWeight={showNickname ? '500' : '400'}
|
||||
mr={showNickname ? 0 : "2px"}
|
||||
mt={showNickname ? 0 : "0px"}
|
||||
onClick={doCopy}
|
||||
>
|
||||
{name}
|
||||
{copyDisplay}
|
||||
</Box>
|
||||
{ !dontShowTime && (
|
||||
{ !dontShowTime && time && (
|
||||
<Timestamp
|
||||
relative={isRelativeTime}
|
||||
stamp={stamp}
|
||||
|
@ -96,7 +96,7 @@ export function CommentItem(props: CommentItemProps): ReactElement {
|
||||
unread={props.unread}
|
||||
group={group}
|
||||
>
|
||||
<Row px="2" gapX="2" alignItems="center">
|
||||
<Row px="2" gapX="2" height="18px">
|
||||
<Action bg="white" onClick={doCopy}>{copyDisplay}</Action>
|
||||
{adminLinks}
|
||||
</Row>
|
||||
|
175
pkg/interface/src/views/components/FormGroup.tsx
Normal file
175
pkg/interface/src/views/components/FormGroup.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
import React, {
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useState,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { Button, Box, Row, Col } from "@tlon/indigo-react";
|
||||
import _ from "lodash";
|
||||
import { useFormikContext } from "formik";
|
||||
import { PropFunc } from "~/types";
|
||||
import { FormGroupContext, SubmitHandler } from "~/logic/lib/formGroup";
|
||||
import { StatelessAsyncButton } from "./StatelessAsyncButton";
|
||||
import { Prompt } from "react-router-dom";
|
||||
import { usePreventWindowUnload } from "~/logic/lib/util";
|
||||
|
||||
export function useFormGroupContext(id: string) {
|
||||
const ctx = React.useContext(FormGroupContext);
|
||||
const addSubmit = useCallback(
|
||||
(submit: SubmitHandler) => {
|
||||
ctx.addSubmit(id, submit);
|
||||
},
|
||||
[ctx.addSubmit, id]
|
||||
);
|
||||
const onDirty = useCallback(
|
||||
(dirty: boolean) => {
|
||||
ctx.onDirty(id, dirty);
|
||||
},
|
||||
[ctx.onDirty, id]
|
||||
);
|
||||
|
||||
const onErrors = useCallback(
|
||||
(errors: boolean) => {
|
||||
ctx.onErrors(id, errors);
|
||||
},
|
||||
[ctx.onErrors, id]
|
||||
);
|
||||
|
||||
const addReset = useCallback(
|
||||
(r: () => void) => {
|
||||
ctx.addReset(id, r);
|
||||
},
|
||||
[ctx.addReset, id]
|
||||
);
|
||||
|
||||
return {
|
||||
onDirty,
|
||||
addSubmit,
|
||||
onErrors,
|
||||
addReset,
|
||||
};
|
||||
}
|
||||
|
||||
export function FormGroupChild(props: { id: string }) {
|
||||
const { id } = props;
|
||||
const { addSubmit, onDirty, onErrors, addReset } = useFormGroupContext(id);
|
||||
const {
|
||||
submitForm,
|
||||
dirty,
|
||||
errors,
|
||||
resetForm,
|
||||
initialValues,
|
||||
values
|
||||
} = useFormikContext();
|
||||
|
||||
useEffect(() => {
|
||||
async function submit() {
|
||||
await submitForm();
|
||||
resetForm({ touched: {}, values });
|
||||
}
|
||||
addSubmit(submit);
|
||||
|
||||
}, [submitForm, values]);
|
||||
|
||||
useEffect(() => {
|
||||
onDirty(dirty);
|
||||
}, [dirty, onDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
onErrors(_.keys(_.pickBy(errors, (s) => !!s)).length > 0);
|
||||
}, [errors, onErrors]);
|
||||
|
||||
useEffect(() => {
|
||||
const reset = () => {
|
||||
resetForm({ errors: {}, touched: {}, values: initialValues, status: {} });
|
||||
};
|
||||
addReset(reset);
|
||||
}, [resetForm, initialValues]);
|
||||
|
||||
return <Box display="none" />;
|
||||
}
|
||||
|
||||
export function FormGroup(props: { onReset?: () => void; } & PropFunc<typeof Box>) {
|
||||
const { children, onReset, ...rest } = props;
|
||||
const [submits, setSubmits] = useState({} as { [id: string]: SubmitHandler });
|
||||
const [resets, setResets] = useState({} as Record<string, () => void>);
|
||||
const [dirty, setDirty] = useState({} as Record<string, boolean>);
|
||||
const [errors, setErrors] = useState({} as Record<string, boolean>);
|
||||
const addSubmit = useCallback((id: string, s: SubmitHandler) => {
|
||||
setSubmits((ss) => ({ ...ss, [id]: s }));
|
||||
}, []);
|
||||
|
||||
const resetAll = useCallback(() => {
|
||||
_.map(resets, (r) => r());
|
||||
onReset && onReset();
|
||||
}, [resets, onReset]);
|
||||
|
||||
const submitAll = useCallback(async () => {
|
||||
await Promise.all(
|
||||
_.map(
|
||||
_.pickBy(submits, (_v, k) => dirty[k]),
|
||||
(f) => f()
|
||||
)
|
||||
);
|
||||
}, [submits, dirty]);
|
||||
|
||||
const onDirty = useCallback(
|
||||
(id: string, t: boolean) => {
|
||||
setDirty((ts) => ({ ...ts, [id]: t }));
|
||||
},
|
||||
[setDirty]
|
||||
);
|
||||
|
||||
const onErrors = useCallback((id: string, e: boolean) => {
|
||||
setErrors((es) => ({ ...es, [id]: e }));
|
||||
}, []);
|
||||
const addReset = useCallback((id: string, reset: () => void) => {
|
||||
setResets((rs) => ({ ...rs, [id]: reset }));
|
||||
}, []);
|
||||
|
||||
const context = { addSubmit, submitAll, onErrors, onDirty, addReset };
|
||||
|
||||
const hasErrors = useMemo(
|
||||
() => _.keys(_.pickBy(errors, (s) => !!s)).length > 0,
|
||||
[errors]
|
||||
);
|
||||
const isDirty = useMemo(
|
||||
() => _.keys(_.pickBy(dirty, _.identity)).length > 0,
|
||||
[dirty]
|
||||
);
|
||||
usePreventWindowUnload(isDirty);
|
||||
|
||||
return (
|
||||
<Box {...rest} position="relative">
|
||||
<Prompt
|
||||
when={isDirty}
|
||||
message="Are you sure you want to leave? You have unsaved changes"
|
||||
/>
|
||||
|
||||
<FormGroupContext.Provider value={context}>
|
||||
{children}
|
||||
</FormGroupContext.Provider>
|
||||
<Row
|
||||
justifyContent="flex-end"
|
||||
width="100%"
|
||||
position="sticky"
|
||||
bottom="0px"
|
||||
p="3"
|
||||
gapX="2"
|
||||
backgroundColor="white"
|
||||
borderTop="1"
|
||||
borderTopColor="washedGray"
|
||||
>
|
||||
<Button onClick={resetAll}>Cancel</Button>
|
||||
<StatelessAsyncButton
|
||||
onClick={submitAll}
|
||||
disabled={hasErrors || !isDirty}
|
||||
primary
|
||||
>
|
||||
Save Changes
|
||||
</StatelessAsyncButton>
|
||||
</Row>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -7,11 +7,11 @@ import {
|
||||
Row,
|
||||
Button,
|
||||
Label,
|
||||
ErrorLabel,
|
||||
BaseInput
|
||||
BaseInput,
|
||||
Text,
|
||||
Icon
|
||||
} from '@tlon/indigo-react';
|
||||
|
||||
import { StorageState } from '~/types';
|
||||
import useStorage from '~/logic/lib/useStorage';
|
||||
|
||||
type ImageInputProps = Parameters<typeof Box>[0] & {
|
||||
@ -20,13 +20,100 @@ type ImageInputProps = Parameters<typeof Box>[0] & {
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const prompt = (field, uploading, meta, clickUploadButton) => {
|
||||
if (!field.value && !uploading && meta.error === undefined) {
|
||||
return (
|
||||
<Text
|
||||
black
|
||||
fontWeight='500'
|
||||
position='absolute'
|
||||
left={2}
|
||||
top={2}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
Paste a link here, or{' '}
|
||||
<Text
|
||||
fontWeight='500'
|
||||
cursor='pointer'
|
||||
color='blue'
|
||||
style={{ pointerEvents: 'all' }}
|
||||
onClick={clickUploadButton}
|
||||
>
|
||||
upload
|
||||
</Text>{' '}
|
||||
a file
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const uploadingStatus = (uploading, meta) => {
|
||||
if (uploading && meta.error === undefined) {
|
||||
return (
|
||||
<Text position='absolute' left={2} top={2} gray>
|
||||
Uploading...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const errorRetry = (meta, uploading, clickUploadButton) => {
|
||||
if (meta.error !== undefined) {
|
||||
return (
|
||||
<Text
|
||||
position='absolute'
|
||||
left={2}
|
||||
top={2}
|
||||
color='red'
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{meta.error}{', '}please{' '}
|
||||
<Text
|
||||
fontWeight='500'
|
||||
cursor='pointer'
|
||||
color='blue'
|
||||
style={{ pointerEvents: 'all' }}
|
||||
onClick={clickUploadButton}
|
||||
>
|
||||
retry
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const clearButton = (field, uploading, clearEvt) => {
|
||||
if (field.value && !uploading) {
|
||||
return (
|
||||
<Box
|
||||
position='absolute'
|
||||
right={0}
|
||||
top={0}
|
||||
px={1}
|
||||
height='100%'
|
||||
cursor='pointer'
|
||||
onClick={clearEvt}
|
||||
backgroundColor='white'
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
borderRadius='0 4px 4px 0'
|
||||
border='1px solid'
|
||||
borderColor='lightGray'
|
||||
>
|
||||
<Icon icon='X' />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function ImageInput(props: ImageInputProps): ReactElement {
|
||||
const { id, label, caption, placeholder } = props;
|
||||
|
||||
const { id, label, caption } = props;
|
||||
const { uploadDefault, canUpload, uploading } = useStorage();
|
||||
|
||||
const [field, meta, { setValue, setError }] = useField(id);
|
||||
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const onImageUpload = useCallback(async () => {
|
||||
@ -43,10 +130,14 @@ export function ImageInput(props: ImageInputProps): ReactElement {
|
||||
}
|
||||
}, [ref.current, uploadDefault, canUpload, setValue]);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
const clickUploadButton = useCallback(() => {
|
||||
ref.current?.click();
|
||||
}, [ref]);
|
||||
|
||||
const clearEvt = useCallback(() => {
|
||||
setValue('');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" {...props}>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
@ -55,25 +146,25 @@ export function ImageInput(props: ImageInputProps): ReactElement {
|
||||
{caption}
|
||||
</Label>
|
||||
) : null}
|
||||
<Row mt="2" alignItems="flex-end">
|
||||
<Row mt="2" alignItems="flex-end" position='relative' width='100%'>
|
||||
{prompt(field, uploading, meta, clickUploadButton)}
|
||||
{clearButton(field, uploading, clearEvt)}
|
||||
{uploadingStatus(uploading, meta)}
|
||||
{errorRetry(meta, uploading, clickUploadButton)}
|
||||
<Box background='white' borderRadius={2} width='100%'>
|
||||
<Input
|
||||
width='100%'
|
||||
type={'text'}
|
||||
hasError={meta.touched && meta.error !== undefined}
|
||||
placeholder={placeholder}
|
||||
{...field}
|
||||
/>
|
||||
</Box>
|
||||
{canUpload && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
ml={1}
|
||||
border={1}
|
||||
borderColor="lightGray"
|
||||
onClick={onClick}
|
||||
flexShrink={0}
|
||||
>
|
||||
{uploading ? 'Uploading' : 'Upload'}
|
||||
</Button>
|
||||
display='none'
|
||||
onClick={clickUploadButton}
|
||||
/>
|
||||
<BaseInput
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
@ -85,9 +176,6 @@ export function ImageInput(props: ImageInputProps): ReactElement {
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
<ErrorLabel mt="2" hasError={Boolean(meta.touched && meta.error)}>
|
||||
{meta.error}
|
||||
</ErrorLabel>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@ -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}</>
|
||||
);
|
||||
|
||||
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"
|
||||
return (
|
||||
<Box
|
||||
{...rest}
|
||||
overflow="hidden"
|
||||
width={size}
|
||||
height={size}
|
||||
position="relative"
|
||||
>
|
||||
{children}
|
||||
</InviteSkeleton>
|
||||
<Box
|
||||
border="2px solid"
|
||||
borderRadius={3}
|
||||
borderColor={color}
|
||||
position="absolute"
|
||||
left="0px"
|
||||
bottom="0px"
|
||||
width={size * 2}
|
||||
height={size * 2}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
inner = (
|
||||
}
|
||||
|
||||
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 (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>
|
||||
);
|
||||
}
|
||||
|
@ -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,7 +45,6 @@ export function InviteItem(props: InviteItemProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
return (
|
||||
<GroupInvite
|
||||
resource={resource}
|
||||
@ -140,78 +52,10 @@ export function InviteItem(props: InviteItemProps) {
|
||||
preview={preview}
|
||||
invite={invite}
|
||||
status={pendingJoin}
|
||||
{...handlers}
|
||||
uid={uid}
|
||||
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;
|
||||
|
@ -3,7 +3,7 @@ import _ from 'lodash';
|
||||
import { Text, Box } from '@tlon/indigo-react';
|
||||
import { Contact, Contacts, Content, Group } from '@urbit/api';
|
||||
import RichText from '~/views/components/RichText';
|
||||
import { cite, useShowNickname, uxToHex } from '~/logic/lib/util';
|
||||
import { cite, useShowNickname, uxToHex, deSig } from '~/logic/lib/util';
|
||||
import ProfileOverlay from '~/views/components/ProfileOverlay';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import useContactState, {useContact} from '~/logic/state/contact';
|
||||
@ -45,7 +45,7 @@ export function Mention(props: {
|
||||
api: any;
|
||||
}) {
|
||||
const { ship, first, api, ...rest } = props;
|
||||
const contact = useContact(ship);
|
||||
const contact = useContact(`~${deSig(ship)}`);
|
||||
const showNickname = useShowNickname(contact);
|
||||
const name = showNickname ? contact?.nickname : cite(ship);
|
||||
|
||||
|
@ -6,9 +6,7 @@ import EmbedContainer from 'react-oembed-container';
|
||||
import useSettingsState from '~/logic/state/settings';
|
||||
import { RemoteContentPolicy } from '~/types/local-update';
|
||||
import { VirtualContextProps, withVirtual } from "~/logic/lib/virtualContext";
|
||||
import { IS_IOS } from '~/logic/lib/platform';
|
||||
import withState from '~/logic/lib/withState';
|
||||
import {Link} from 'react-router-dom';
|
||||
|
||||
type RemoteContentProps = VirtualContextProps & {
|
||||
url: string;
|
||||
@ -130,20 +128,26 @@ return;
|
||||
});
|
||||
}
|
||||
|
||||
wrapInLink(contents, textOnly = false) {
|
||||
wrapInLink(contents, textOnly = false, unfold = false, unfoldEmbed = null, embedContainer = null) {
|
||||
const { style } = this.props;
|
||||
return (
|
||||
<Box borderRadius="1" backgroundColor="washedGray" maxWidth="min(100%, 20rem)">
|
||||
<Row
|
||||
alignItems="center"
|
||||
maxWidth="min(100%, 20rem)"
|
||||
gapX="1" borderRadius="1" backgroundColor="washedGray">
|
||||
gapX="1">
|
||||
{ textOnly && (<Icon ml="2" display="block" icon="ArrowExternal" />)}
|
||||
{ !textOnly && unfoldEmbed && (
|
||||
<Icon
|
||||
ml='2'
|
||||
display='block'
|
||||
onClick={unfoldEmbed}
|
||||
icon={unfold ? 'TriangleSouth' : 'TriangleEast'}/>
|
||||
)}
|
||||
<BaseAnchor
|
||||
display="flex"
|
||||
p="2"
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
href={this.props.url}
|
||||
flexShrink={0}
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
@ -157,6 +161,8 @@ return;
|
||||
{contents}
|
||||
</BaseAnchor>
|
||||
</Row>
|
||||
{embedContainer}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@ -170,7 +176,6 @@ return;
|
||||
remoteContentPolicy,
|
||||
url,
|
||||
text,
|
||||
unfold = false,
|
||||
renderUrl = true,
|
||||
imageProps = {},
|
||||
audioProps = {},
|
||||
@ -208,12 +213,15 @@ return;
|
||||
return (
|
||||
<>
|
||||
{renderUrl
|
||||
? this.wrapInLink(<TruncatedText {...textProps}>{text || url}</TruncatedText>)
|
||||
: null}
|
||||
? this.wrapInLink(
|
||||
<TruncatedText {...textProps}>{url}</TruncatedText>,
|
||||
false,
|
||||
this.state.unfold,
|
||||
this.unfoldEmbed,
|
||||
<audio
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
controls
|
||||
className="db"
|
||||
className={this.state.unfold ? "db" : "dn"}
|
||||
src={url}
|
||||
style={style}
|
||||
onLoad={onLoad}
|
||||
@ -222,19 +230,23 @@ return;
|
||||
width="100%"
|
||||
{...audioProps}
|
||||
{...props}
|
||||
/>
|
||||
/>)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
} else if (isVideo && remoteContentPolicy.videoShown) {
|
||||
return (
|
||||
<>
|
||||
{renderUrl
|
||||
? this.wrapInLink(<TruncatedText {...textProps}>{text || url}</TruncatedText>)
|
||||
: null}
|
||||
? this.wrapInLink(
|
||||
<TruncatedText {...textProps}>{url}</TruncatedText>,
|
||||
false,
|
||||
this.state.unfold,
|
||||
this.unfoldEmbed,
|
||||
<video
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
controls
|
||||
className="db"
|
||||
className={this.state.unfold ? 'db' : 'dn pa2'}
|
||||
src={url}
|
||||
style={style}
|
||||
onLoad={onLoad}
|
||||
@ -243,33 +255,16 @@ return;
|
||||
width="100%"
|
||||
{...videoProps}
|
||||
{...props}
|
||||
/>
|
||||
/>)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
} else if (isOembed && remoteContentPolicy.oembedShown) {
|
||||
if (!this.state.embed || this.state.embed?.html === '') {
|
||||
this.loadOembed();
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{renderUrl
|
||||
? this.wrapInLink(<TruncatedText {...textProps}>{(this.state.embed && this.state.embed.title)
|
||||
? this.state.embed.title
|
||||
: (text || url)}</TruncatedText>, true)
|
||||
: null}
|
||||
{this.state.embed !== 'error' && this.state.embed?.html && !unfold ? <Button
|
||||
display='inline-flex'
|
||||
border={1}
|
||||
height={3}
|
||||
ml={1}
|
||||
onClick={this.unfoldEmbed}
|
||||
flexShrink={0}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{this.state.unfold ? 'collapse' : 'expand'}
|
||||
</Button> : null}
|
||||
<Box
|
||||
const renderEmbed = !(this.state.embed !== 'error' && this.state.embed?.html);
|
||||
const embed = <Box
|
||||
mb='2'
|
||||
width='100%'
|
||||
flexShrink={0}
|
||||
@ -281,6 +276,11 @@ return;
|
||||
{...oembedProps}
|
||||
{...props}
|
||||
>
|
||||
<TruncatedText
|
||||
display={(renderUrl && this.state.embed?.title && this.state.embed.title !== url) ? 'inline-block' : 'none'}
|
||||
fontWeight='bold' width='100%'>
|
||||
{this.state.embed?.title}
|
||||
</TruncatedText>
|
||||
{this.state.embed && this.state.embed.html && this.state.unfold
|
||||
? <EmbedContainer markup={this.state.embed.html}>
|
||||
<div className="embed-container" ref={(el) => {
|
||||
@ -291,7 +291,18 @@ return;
|
||||
></div>
|
||||
</EmbedContainer>
|
||||
: null}
|
||||
</Box>
|
||||
</Box>;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{renderUrl
|
||||
? this.wrapInLink(
|
||||
<TruncatedText {...textProps}>{url}</TruncatedText>,
|
||||
renderEmbed,
|
||||
this.state.unfold,
|
||||
this.unfoldEmbed,
|
||||
embed
|
||||
) : embed}
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
|
@ -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'}
|
||||
>
|
||||
|
@ -1,11 +1,16 @@
|
||||
import React, { useMemo, useRef, useCallback, useEffect, useState } from 'react';
|
||||
import React, {
|
||||
useMemo,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState
|
||||
} from 'react';
|
||||
import { useLocation, useHistory } from 'react-router-dom';
|
||||
import * as ob from 'urbit-ob';
|
||||
import Mousetrap from 'mousetrap';
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { Box, Row, Text } from '@tlon/indigo-react';
|
||||
import { Associations, Contacts, Groups, Invites } from '@urbit/api';
|
||||
|
||||
import makeIndex from '~/logic/lib/omnibox';
|
||||
import OmniboxInput from './OmniboxInput';
|
||||
import OmniboxResult from './OmniboxResult';
|
||||
@ -16,7 +21,6 @@ import defaultApps from '~/logic/lib/default-apps';
|
||||
import { useOutsideClick } from '~/logic/lib/useOutsideClick';
|
||||
import { Portal } from '../Portal';
|
||||
import useSettingsState, { SettingsState } from '~/logic/state/settings';
|
||||
import { Tile } from '~/types';
|
||||
import useContactState from '~/logic/state/contact';
|
||||
import useGroupState from '~/logic/state/group';
|
||||
import useHarkState from '~/logic/state/hark';
|
||||
@ -30,14 +34,21 @@ interface OmniboxProps {
|
||||
notifications: number;
|
||||
}
|
||||
|
||||
const SEARCHED_CATEGORIES = ['commands', 'ships', 'other', 'groups', 'subscriptions', 'apps'];
|
||||
const SEARCHED_CATEGORIES = [
|
||||
'commands',
|
||||
'ships',
|
||||
'other',
|
||||
'groups',
|
||||
'subscriptions',
|
||||
'apps'
|
||||
];
|
||||
const settingsSel = (s: SettingsState) => s.leap;
|
||||
|
||||
export function Omnibox(props: OmniboxProps) {
|
||||
export function Omnibox(props: OmniboxProps): ReactElement {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const leapConfig = useSettingsState(settingsSel);
|
||||
const omniboxRef = useRef<HTMLDivElement | null>(null)
|
||||
const omniboxRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
@ -46,19 +57,22 @@ export function Omnibox(props: OmniboxProps) {
|
||||
const notifications = useHarkState(state => state.notifications);
|
||||
const invites = useInviteState(state => state.invites);
|
||||
const tiles = useLaunchState(state => state.tiles);
|
||||
const [leapCursor, setLeapCursor] = useState('pointer');
|
||||
|
||||
const contacts = useMemo(() => {
|
||||
const maybeShip = `~${deSig(query)}`;
|
||||
return ob.isValidPatp(maybeShip)
|
||||
? { ...contactState, [maybeShip]: {} }
|
||||
: contactState;
|
||||
const selflessContactState = omit(contactState, `~${window.ship}`);
|
||||
return ob.isValidPatp(maybeShip) && maybeShip !== `~${window.ship}`
|
||||
? { ...selflessContactState, [maybeShip]: {} }
|
||||
: selflessContactState;
|
||||
}, [contactState, query]);
|
||||
|
||||
const groups = useGroupState(state => state.groups);
|
||||
const associations = useMetadataState(state => state.associations);
|
||||
|
||||
const selectedGroup = useMemo(
|
||||
() => location.pathname.startsWith('/~landscape/ship/')
|
||||
() =>
|
||||
location.pathname.startsWith('/~landscape/ship/')
|
||||
? '/' + location.pathname.split('/').slice(2, 5).join('/')
|
||||
: null,
|
||||
[location.pathname]
|
||||
@ -71,16 +85,9 @@ export function Omnibox(props: OmniboxProps) {
|
||||
tiles,
|
||||
selectedGroup,
|
||||
groups,
|
||||
leapConfig,
|
||||
leapConfig
|
||||
);
|
||||
}, [
|
||||
selectedGroup,
|
||||
leapConfig,
|
||||
contacts,
|
||||
associations,
|
||||
groups,
|
||||
tiles
|
||||
]);
|
||||
}, [selectedGroup, leapConfig, contacts, associations, groups, tiles]);
|
||||
|
||||
const onOutsideClick = useCallback(() => {
|
||||
props.show && props.toggle();
|
||||
@ -104,12 +111,17 @@ export function Omnibox(props: OmniboxProps) {
|
||||
}, [props.show]);
|
||||
|
||||
const initialResults = useMemo(() => {
|
||||
return new Map(SEARCHED_CATEGORIES.map((category) => {
|
||||
return new Map(
|
||||
SEARCHED_CATEGORIES.map((category) => {
|
||||
if (category === 'other') {
|
||||
return ['other', index.get('other').filter(({ app }) => app !== 'tutorial')];
|
||||
return [
|
||||
'other',
|
||||
index.get('other').filter(({ app }) => app !== 'tutorial')
|
||||
];
|
||||
}
|
||||
return [category, []];
|
||||
}));
|
||||
})
|
||||
);
|
||||
}, [index]);
|
||||
|
||||
const results = useMemo(() => {
|
||||
@ -120,13 +132,16 @@ export function Omnibox(props: OmniboxProps) {
|
||||
const resultsMap = new Map();
|
||||
SEARCHED_CATEGORIES.map((category) => {
|
||||
const categoryIndex = index.get(category);
|
||||
resultsMap.set(category,
|
||||
resultsMap.set(
|
||||
category,
|
||||
categoryIndex.filter((result) => {
|
||||
return (
|
||||
result.title.toLowerCase().includes(q) ||
|
||||
result.link.toLowerCase().includes(q) ||
|
||||
result.app.toLowerCase().includes(q) ||
|
||||
(result.host !== null ? result.host.toLowerCase().includes(q) : false)
|
||||
(result.host !== null
|
||||
? result.host.toLowerCase().includes(q)
|
||||
: false)
|
||||
);
|
||||
})
|
||||
);
|
||||
@ -134,21 +149,26 @@ export function Omnibox(props: OmniboxProps) {
|
||||
return resultsMap;
|
||||
}, [query, index]);
|
||||
|
||||
const navigate = useCallback((app: string, link: string) => {
|
||||
const navigate = useCallback(
|
||||
(app: string, link: string) => {
|
||||
props.toggle();
|
||||
if (defaultApps.includes(app.toLowerCase())
|
||||
|| app === 'profile'
|
||||
|| app === 'messages'
|
||||
|| app === 'tutorial'
|
||||
|| app === 'Links'
|
||||
|| app === 'Terminal'
|
||||
|| app === 'home'
|
||||
|| app === 'inbox') {
|
||||
if (
|
||||
defaultApps.includes(app.toLowerCase()) ||
|
||||
app === 'profile' ||
|
||||
app === 'messages' ||
|
||||
app === 'tutorial' ||
|
||||
app === 'Links' ||
|
||||
app === 'Terminal' ||
|
||||
app === 'home' ||
|
||||
app === 'inbox'
|
||||
) {
|
||||
history.push(link);
|
||||
} else {
|
||||
window.location.href = link;
|
||||
}
|
||||
}, [history, props.toggle]);
|
||||
},
|
||||
[history, props.toggle]
|
||||
);
|
||||
|
||||
const setPreviousSelected = useCallback(() => {
|
||||
const flattenedResults = Array.from(results.values()).flat();
|
||||
@ -193,7 +213,13 @@ export function Omnibox(props: OmniboxProps) {
|
||||
}
|
||||
}, [selected, results]);
|
||||
|
||||
const control = useCallback((evt) => {
|
||||
const setSelection = (app, link) => {
|
||||
setLeapCursor('pointer');
|
||||
setSelected([app, link]);
|
||||
};
|
||||
|
||||
const control = useCallback(
|
||||
(evt) => {
|
||||
if (evt.key === 'Escape') {
|
||||
if (query.length > 0) {
|
||||
setQuery('');
|
||||
@ -203,16 +229,16 @@ export function Omnibox(props: OmniboxProps) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
evt.key === 'ArrowUp' ||
|
||||
(evt.shiftKey && evt.key === 'Tab')) {
|
||||
if (evt.key === 'ArrowUp' || (evt.shiftKey && evt.key === 'Tab')) {
|
||||
evt.preventDefault();
|
||||
setPreviousSelected();
|
||||
setLeapCursor('none');
|
||||
return;
|
||||
}
|
||||
if (evt.key === 'ArrowDown' || evt.key === 'Tab') {
|
||||
evt.preventDefault();
|
||||
setNextSelected();
|
||||
setLeapCursor('none');
|
||||
return;
|
||||
}
|
||||
if (evt.key === 'Enter') {
|
||||
@ -224,10 +250,12 @@ export function Omnibox(props: OmniboxProps) {
|
||||
} else {
|
||||
navigate(
|
||||
Array.from(results.values()).flat()[0].app,
|
||||
Array.from(results.values()).flat()[0].link);
|
||||
Array.from(results.values()).flat()[0].link
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
},
|
||||
[
|
||||
props.toggle,
|
||||
selected,
|
||||
navigate,
|
||||
@ -236,7 +264,8 @@ export function Omnibox(props: OmniboxProps) {
|
||||
results,
|
||||
setPreviousSelected,
|
||||
setNextSelected
|
||||
]);
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const flattenedResultLinks = Array.from(results.values())
|
||||
@ -252,49 +281,67 @@ export function Omnibox(props: OmniboxProps) {
|
||||
}, []);
|
||||
|
||||
// Sort Omnibox results alphabetically
|
||||
const sortResults = (a: Record<'title', string>, b: Record<'title', string>) => {
|
||||
const sortResults = (
|
||||
a: Record<'title', string>,
|
||||
b: Record<'title', string>
|
||||
) => {
|
||||
// Do not sort unless searching (preserves order of menu actions)
|
||||
if (query === '') { return 0 };
|
||||
if (a.title < b.title) { return -1 };
|
||||
if (a.title > b.title) { return 1 };
|
||||
if (query === '') {
|
||||
return 0;
|
||||
}
|
||||
if (a.title < b.title) {
|
||||
return -1;
|
||||
}
|
||||
if (a.title > b.title) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const renderResults = useCallback(() => {
|
||||
return <Box
|
||||
return (
|
||||
<Box
|
||||
maxHeight={['200px', '400px']}
|
||||
overflowY="auto"
|
||||
overflowX="hidden"
|
||||
overflowY='auto'
|
||||
overflowX='hidden'
|
||||
borderBottomLeftRadius='2'
|
||||
borderBottomRightRadius='2'
|
||||
>
|
||||
{SEARCHED_CATEGORIES
|
||||
.map(category => Object({ category, categoryResults: results.get(category) }))
|
||||
{SEARCHED_CATEGORIES.map(category =>
|
||||
Object({ category, categoryResults: results.get(category) })
|
||||
)
|
||||
.filter(category => category.categoryResults.length > 0)
|
||||
.map(({ category, categoryResults }, i) => {
|
||||
const categoryTitle = (category === 'other')
|
||||
? null : <Row pl='2' height='5' alignItems='center' bg='washedGray'><Text gray bold>{category.charAt(0).toUpperCase() + category.slice(1)}</Text></Row>;
|
||||
const categoryTitle =
|
||||
category === 'other' ? null : (
|
||||
<Row pl='2' height='5' alignItems='center' bg='washedGray'>
|
||||
<Text gray bold>
|
||||
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
</Text>
|
||||
</Row>
|
||||
);
|
||||
const sel = selected?.length ? selected[1] : '';
|
||||
return (<Box key={i} width='max(50vw, 300px)' maxWidth='600px'>
|
||||
return (
|
||||
<Box key={i} width='max(50vw, 300px)' maxWidth='600px'>
|
||||
{categoryTitle}
|
||||
{categoryResults
|
||||
.sort(sortResults)
|
||||
.map((result, i2) => (
|
||||
{categoryResults.sort(sortResults).map((result, i2) => (
|
||||
<OmniboxResult
|
||||
key={i2}
|
||||
icon={result.app}
|
||||
text={result.title}
|
||||
subtext={result.host}
|
||||
link={result.link}
|
||||
cursor={leapCursor}
|
||||
navigate={() => navigate(result.app, result.link)}
|
||||
setSelection={() => setSelection(result.app, result.link)}
|
||||
selected={sel}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Box>;
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}, [results, navigate, selected, contactState, notifications, invites]);
|
||||
|
||||
return (
|
||||
|
@ -1,4 +1,3 @@
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import { BaseInput } from '@tlon/indigo-react';
|
||||
|
||||
@ -13,8 +12,7 @@ export class OmniboxInput extends Component {
|
||||
el.blur();
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
width='100%'
|
||||
p='2'
|
||||
backgroundColor='white'
|
||||
@ -35,4 +33,3 @@ export class OmniboxInput extends Component {
|
||||
}
|
||||
|
||||
export default OmniboxInput;
|
||||
|
||||
|
@ -21,7 +21,8 @@ export class OmniboxResult extends Component {
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { props, state } = this;
|
||||
if (prevProps &&
|
||||
if (
|
||||
prevProps &&
|
||||
!state.hovered &&
|
||||
prevProps.selected !== props.selected &&
|
||||
props.selected === props.link
|
||||
@ -31,42 +32,132 @@ export class OmniboxResult extends Component {
|
||||
}
|
||||
|
||||
getIcon(icon, selected, link, invites, notifications, text, color) {
|
||||
const iconFill = (this.state.hovered || (selected === link)) ? 'white' : 'black';
|
||||
const bulletFill = (this.state.hovered || (selected === link)) ? 'white' : 'blue';
|
||||
const iconFill =
|
||||
this.state.hovered || selected === link ? 'white' : 'black';
|
||||
const bulletFill =
|
||||
this.state.hovered || selected === link ? 'white' : 'blue';
|
||||
|
||||
const inviteCount = [].concat(...Object.values(invites).map(obj => Object.values(obj)));
|
||||
const inviteCount = [].concat(
|
||||
...Object.values(invites).map((obj) => Object.values(obj))
|
||||
);
|
||||
|
||||
let graphic = <div />;
|
||||
if (defaultApps.includes(icon.toLowerCase())
|
||||
|| icon.toLowerCase() === 'links'
|
||||
|| icon.toLowerCase() === 'terminal')
|
||||
{
|
||||
icon = (icon === 'Link') ? 'Collection' :
|
||||
(icon === 'Terminal') ? 'Dojo' : icon;
|
||||
graphic = <Icon display="inline-block" verticalAlign="middle" icon={icon} mr='2' size='18px' color={iconFill} />;
|
||||
if (
|
||||
defaultApps.includes(icon.toLowerCase()) ||
|
||||
icon.toLowerCase() === 'links' ||
|
||||
icon.toLowerCase() === 'terminal'
|
||||
) {
|
||||
icon =
|
||||
icon === 'Link' ? 'Collection' : icon === 'Terminal' ? 'Dojo' : icon;
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon={icon}
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
} else if (icon === 'inbox') {
|
||||
graphic = <Box display='flex' verticalAlign='middle' position="relative">
|
||||
<Icon display='inline-block' verticalAlign='middle' icon='Inbox' mr='2' size='18px' color={iconFill} />
|
||||
graphic = (
|
||||
<Box display='flex' verticalAlign='middle' position='relative'>
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon='Inbox'
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
{(notifications > 0 || inviteCount.length > 0) && (
|
||||
<Icon display='inline-block' icon='Bullet' style={{ position: 'absolute', top: -5, left: 5 }} color={bulletFill} />
|
||||
<Icon
|
||||
display='inline-block'
|
||||
icon='Bullet'
|
||||
style={{ position: 'absolute', top: -5, left: 5 }}
|
||||
color={bulletFill}
|
||||
/>
|
||||
)}
|
||||
</Box>;
|
||||
</Box>
|
||||
);
|
||||
} else if (icon === 'logout') {
|
||||
graphic = <Icon display="inline-block" verticalAlign="middle" icon='SignOut' mr='2' size='18px' color={iconFill} />;
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon='SignOut'
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
} else if (icon === 'profile') {
|
||||
text = text.startsWith('Profile') ? window.ship : text;
|
||||
graphic = <Sigil color={color} classes='dib flex-shrink-0 v-mid mr2' ship={text} size={18} icon padding={2} />;
|
||||
graphic = (
|
||||
<Sigil
|
||||
color={color}
|
||||
classes='dib flex-shrink-0 v-mid mr2'
|
||||
ship={text}
|
||||
size={18}
|
||||
icon
|
||||
padding={2}
|
||||
/>
|
||||
);
|
||||
} else if (icon === 'home') {
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Home' mr='2' size='18px' color={iconFill} />;
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon='Home'
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
} else if (icon === 'notifications') {
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Inbox' mr='2' size='18px' color={iconFill} />;
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon='Inbox'
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
} else if (icon === 'messages') {
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Users' mr='2' size='18px' color={iconFill} />;
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon='Users'
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
} else if (icon === 'tutorial') {
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Tutorial' mr='2' size='18px' color={iconFill} />;
|
||||
}
|
||||
else {
|
||||
graphic = <Icon display='inline-block' icon='NullIcon' verticalAlign="middle" mr='2' size="16px" color={iconFill} />;
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
icon='Tutorial'
|
||||
mr='2'
|
||||
size='18px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
graphic = (
|
||||
<Icon
|
||||
display='inline-block'
|
||||
icon='NullIcon'
|
||||
verticalAlign='middle'
|
||||
mr='2'
|
||||
size='16px'
|
||||
color={iconFill}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return graphic;
|
||||
@ -77,30 +168,57 @@ export class OmniboxResult extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { icon, text, subtext, link, navigate, selected, invites, notificationsCount, contacts } = this.props;
|
||||
const {
|
||||
icon,
|
||||
text,
|
||||
subtext,
|
||||
link,
|
||||
cursor,
|
||||
navigate,
|
||||
selected,
|
||||
invites,
|
||||
notificationsCount,
|
||||
contacts,
|
||||
setSelection
|
||||
} = this.props;
|
||||
|
||||
const color = contacts?.[text] ? `#${uxToHex(contacts[text].color)}` : "#000000";
|
||||
const graphic = this.getIcon(icon, selected, link, invites, notificationsCount, text, color);
|
||||
const color = contacts?.[text]
|
||||
? `#${uxToHex(contacts[text].color)}`
|
||||
: '#000000';
|
||||
const graphic = this.getIcon(
|
||||
icon,
|
||||
selected,
|
||||
link,
|
||||
invites,
|
||||
notificationsCount,
|
||||
text,
|
||||
color
|
||||
);
|
||||
|
||||
return (
|
||||
<Row
|
||||
py='2'
|
||||
px='2'
|
||||
cursor='pointer'
|
||||
onMouseEnter={() => this.setHover(true)}
|
||||
cursor={cursor}
|
||||
onMouseMove={() => setSelection()}
|
||||
onMouseLeave={() => this.setHover(false)}
|
||||
backgroundColor={
|
||||
this.state.hovered || selected === link ? 'blue' : 'white'
|
||||
}
|
||||
onClick={navigate}
|
||||
width="100%"
|
||||
justifyContent="space-between"
|
||||
width='100%'
|
||||
justifyContent='space-between'
|
||||
ref={this.result}
|
||||
>
|
||||
<Box display="flex" verticalAlign="middle" maxWidth="60%" flexShrink={0}>
|
||||
<Box
|
||||
display='flex'
|
||||
verticalAlign='middle'
|
||||
maxWidth='60%'
|
||||
flexShrink={0}
|
||||
>
|
||||
{graphic}
|
||||
<Text
|
||||
mono={(icon == 'profile' && text.startsWith('~'))}
|
||||
mono={icon == 'profile' && text.startsWith('~')}
|
||||
color={this.state.hovered || selected === link ? 'white' : 'black'}
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
@ -110,19 +228,20 @@ export class OmniboxResult extends Component {
|
||||
whiteSpace='pre'
|
||||
mr='1'
|
||||
>
|
||||
{text.startsWith("~") ? cite(text) : text}
|
||||
{text.startsWith('~') ? cite(text) : text}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text pr='2'
|
||||
display="inline-block"
|
||||
verticalAlign="middle"
|
||||
<Text
|
||||
pr='2'
|
||||
display='inline-block'
|
||||
verticalAlign='middle'
|
||||
color={this.state.hovered || selected === link ? 'white' : 'black'}
|
||||
width='100%'
|
||||
minWidth={0}
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="pre"
|
||||
overflow="hidden"
|
||||
maxWidth="40%"
|
||||
textOverflow='ellipsis'
|
||||
whiteSpace='pre'
|
||||
overflow='hidden'
|
||||
maxWidth='40%'
|
||||
textAlign='right'
|
||||
>
|
||||
{subtext}
|
||||
|
@ -15,6 +15,7 @@ import GlobalApi from '~/logic/api/global';
|
||||
import { resourceFromPath } from '~/logic/lib/group';
|
||||
import { FormSubmit } from '~/views/components/FormSubmit';
|
||||
import { ChannelWritePerms } from '../ChannelWritePerms';
|
||||
import {FormGroupChild} from '~/views/components/FormGroup';
|
||||
|
||||
function PermissionsSummary(props: {
|
||||
writersSize: number;
|
||||
@ -158,7 +159,8 @@ export function GraphPermissions(props: GraphPermissionsProps) {
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form style={{ display: 'contents' }}>
|
||||
<Col mt="4" flexShrink={0} gapY="5">
|
||||
<FormGroupChild id="permissions" />
|
||||
<Col mx="4" mt="4" flexShrink={0} gapY="5">
|
||||
<Col gapY="1" mt="0">
|
||||
<Text id="permissions" fontWeight="bold" fontSize="2">
|
||||
Permissions
|
||||
@ -187,7 +189,6 @@ export function GraphPermissions(props: GraphPermissionsProps) {
|
||||
caption="If enabled, all members of the group can comment on this channel"
|
||||
/>
|
||||
)}
|
||||
<FormSubmit>Update Permissions</FormSubmit>
|
||||
</Col>
|
||||
</Form>
|
||||
</Formik>
|
||||
|
@ -1,19 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Formik, Form } from 'formik';
|
||||
import React from "react";
|
||||
import { Formik, Form } from "formik";
|
||||
|
||||
import {
|
||||
ManagedTextInputField as Input,
|
||||
Col,
|
||||
Label,
|
||||
Text
|
||||
} from '@tlon/indigo-react';
|
||||
import { Association } from '@urbit/api';
|
||||
Text,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Association } from "@urbit/api";
|
||||
|
||||
import { FormError } from '~/views/components/FormError';
|
||||
import { ColorInput } from '~/views/components/ColorInput';
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { FormSubmit } from '~/views/components/FormSubmit';
|
||||
import { FormError } from "~/views/components/FormError";
|
||||
import { ColorInput } from "~/views/components/ColorInput";
|
||||
import { uxToHex } from "~/logic/lib/util";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { FormSubmit } from "~/views/components/FormSubmit";
|
||||
import { FormGroupChild } from "~/views/components/FormGroup";
|
||||
|
||||
interface FormSchema {
|
||||
title: string;
|
||||
@ -30,9 +31,9 @@ export function ChannelDetails(props: ChannelDetailsProps) {
|
||||
const { association, api } = props;
|
||||
const { metadata } = association;
|
||||
const initialValues: FormSchema = {
|
||||
title: metadata?.title || '',
|
||||
description: metadata?.description || '',
|
||||
color: metadata?.color || '0x0'
|
||||
title: metadata?.title || "",
|
||||
description: metadata?.description || "",
|
||||
color: metadata?.color || "0x0",
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormSchema, actions) => {
|
||||
@ -44,8 +45,9 @@ export function ChannelDetails(props: ChannelDetailsProps) {
|
||||
|
||||
return (
|
||||
<Formik initialValues={initialValues} onSubmit={onSubmit}>
|
||||
<Form style={{ display: 'contents' }}>
|
||||
<Col mb="4" flexShrink={0} gapY="4">
|
||||
<Form style={{ display: "contents" }}>
|
||||
<FormGroupChild id="details" />
|
||||
<Col mx="4" mb="4" flexShrink={0} gapY="4">
|
||||
<Col mb={3}>
|
||||
<Text id="details" fontSize="2" fontWeight="bold">
|
||||
Channel Details
|
||||
@ -69,9 +71,6 @@ export function ChannelDetails(props: ChannelDetailsProps) {
|
||||
label="Color"
|
||||
caption="Change the color of this channel"
|
||||
/>
|
||||
<FormSubmit>
|
||||
Update Details
|
||||
</FormSubmit>
|
||||
<FormError message="Failed to update settings" />
|
||||
</Col>
|
||||
</Form>
|
||||
|
@ -28,7 +28,7 @@ export function ChannelNotifications(props: ChannelNotificationsProps) {
|
||||
const anchorRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
return (
|
||||
<Col mb="6" gapY="4" flexShrink={0}>
|
||||
<Col mx="4" mb="6" gapY="4" flexShrink={0}>
|
||||
<Text ref={anchorRef} id="notifications" fontSize="2" fontWeight="bold">
|
||||
Channel Notifications
|
||||
</Text>
|
||||
|
@ -13,7 +13,7 @@ export function ChannelPopoverRoutesSidebar(props: {
|
||||
|
||||
return (
|
||||
<Col
|
||||
display={['none', 'flex-column']}
|
||||
display={['none', 'flex']}
|
||||
minWidth="200px"
|
||||
borderRight="1"
|
||||
borderRightColor="washedGray"
|
||||
|
@ -19,6 +19,7 @@ import { useHistory, Link } from 'react-router-dom';
|
||||
import { ChannelNotifications } from './Notifications';
|
||||
import { StatelessAsyncButton } from '~/views/components/StatelessAsyncButton';
|
||||
import { isChannelAdmin, isHost } from '~/logic/lib/group';
|
||||
import {FormGroup} from '~/views/components/FormGroup';
|
||||
|
||||
interface ChannelPopoverRoutesProps {
|
||||
baseUrl: string;
|
||||
@ -83,10 +84,10 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
|
||||
isOwner={isOwner}
|
||||
baseUrl={props.baseUrl}
|
||||
/>
|
||||
<Col height="100%" overflowY="auto" p="5" flexGrow={1}>
|
||||
<FormGroup onReset={onDismiss} height="100%" overflowY="auto" pt="5" flexGrow={1}>
|
||||
<ChannelNotifications {...props} />
|
||||
{!isOwner && (
|
||||
<Col mb="6" flexShrink={0}>
|
||||
<Col mx="4" mb="6" flexShrink={0}>
|
||||
<Text id="unsubscribe" fontSize="2" fontWeight="bold">
|
||||
Unsubscribe from Channel
|
||||
</Text>
|
||||
@ -107,7 +108,7 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
|
||||
<ChannelDetails {...props} />
|
||||
<GraphPermissions {...props} />
|
||||
{ isOwner ? (
|
||||
<Col mt="5" mb="6" flexShrink={0}>
|
||||
<Col mx="4" mt="5" mb="6" flexShrink={0}>
|
||||
<Text id="archive" fontSize="2" fontWeight="bold">
|
||||
Archive channel
|
||||
</Text>
|
||||
@ -124,7 +125,7 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
|
||||
</Col>
|
||||
|
||||
) : (
|
||||
<Col mt="5" mb="6" flexShrink={0}>
|
||||
<Col mx="4" my="6" flexShrink={0}>
|
||||
<Text id="remove" fontSize="2" fontWeight="bold">
|
||||
Remove channel from group
|
||||
</Text>
|
||||
@ -143,7 +144,7 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</FormGroup>
|
||||
</Row>
|
||||
</ModalOverlay>
|
||||
);
|
||||
|
@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import RemarkDisableTokenizers from 'remark-disable-tokenizers';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import { Text } from '@tlon/indigo-react';
|
||||
import { Text, Anchor } from '@tlon/indigo-react';
|
||||
import { GroupLink } from '~/views/components/GroupLink';
|
||||
import { Row } from '@tlon/indigo-react';
|
||||
|
||||
@ -22,7 +22,6 @@ const DISABLED_INLINE_TOKENS = [
|
||||
'autoLink',
|
||||
'url',
|
||||
'email',
|
||||
'link',
|
||||
'reference'
|
||||
];
|
||||
|
||||
@ -75,6 +74,9 @@ const renderers = {
|
||||
{value}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
link: (props) => {
|
||||
return <Anchor src={props.href} borderBottom="1" color="black">{props.children}</Anchor>
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -79,6 +79,7 @@ export default function PostReplies(props) {
|
||||
baseUrl={baseUrl}
|
||||
history={history}
|
||||
isParent={true}
|
||||
parentPost={parentNode?.post}
|
||||
vip={vip}
|
||||
group={group}
|
||||
/>
|
||||
|
@ -81,6 +81,9 @@ export function JoinGroup(props: JoinGroupProps): ReactElement {
|
||||
if(group === TUTORIAL_GROUP_RESOURCE) {
|
||||
await api.settings.putEntry('tutorial', 'joined', Date.now());
|
||||
}
|
||||
if (group in groups) {
|
||||
return history.push(`/~landscape${group}`);
|
||||
}
|
||||
await api.groups.join(ship, name);
|
||||
try {
|
||||
await waiter((p) => {
|
||||
@ -111,6 +114,9 @@ export function JoinGroup(props: JoinGroupProps): ReactElement {
|
||||
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
|
||||
const [ship, name] = values.group.split('/');
|
||||
const path = `/ship/${ship}/${name}`;
|
||||
if (path in groups) {
|
||||
return history.push(`/~landscape${path}`);
|
||||
}
|
||||
// skip if it's unmanaged
|
||||
try {
|
||||
const prev = await api.metadata.preview(path);
|
||||
|
@ -386,9 +386,9 @@ function Participant(props: {
|
||||
{(contact.patp !== window.ship) && (<StatelessAsyncAction onClick={onKick} bg="transparent">
|
||||
<Text color="red">Kick from {title}</Text>
|
||||
</StatelessAsyncAction>)}
|
||||
<StatelessAsyncAction onClick={onPromote} bg="transparent">
|
||||
{!contact.pending && <StatelessAsyncAction onClick={onPromote} bg="transparent">
|
||||
Promote to Admin
|
||||
</StatelessAsyncAction>
|
||||
</StatelessAsyncAction>}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
@ -18,7 +18,11 @@ export function useGraphModule(
|
||||
}
|
||||
|
||||
const notifications = graphUnreads?.[s]?.['/']?.notifications;
|
||||
if ( notifications > 0 ) {
|
||||
if (
|
||||
notifications &&
|
||||
((typeof notifications === 'number' && notifications > 0)
|
||||
|| notifications.length)
|
||||
) {
|
||||
return 'notification';
|
||||
}
|
||||
|
||||
|
@ -86,7 +86,11 @@ export function SidebarItem(props: {
|
||||
let color = 'lightGray';
|
||||
|
||||
if (isSynced) {
|
||||
if (hasUnread || hasNotification) {
|
||||
color = 'black';
|
||||
} else {
|
||||
color = 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
const fontWeight = (hasUnread || hasNotification) ? '500' : 'normal';
|
||||
@ -132,7 +136,7 @@ export function SidebarItem(props: {
|
||||
{DM ? img : (
|
||||
<Icon
|
||||
display="block"
|
||||
color={isSynced ? 'black' : 'gray'}
|
||||
color={isSynced ? 'black' : 'lightGray'}
|
||||
icon={getModuleIcon(mod) as any}
|
||||
/>
|
||||
)
|
||||
|
1
pkg/npm/.gitignore
vendored
1
pkg/npm/.gitignore
vendored
@ -1 +0,0 @@
|
||||
package-lock.json
|
46
pkg/npm/api/package-lock.json
generated
Normal file
46
pkg/npm/api/package-lock.json
generated
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@urbit/api",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.13.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.13.7.tgz",
|
||||
"integrity": "sha512-h+ilqoX998mRVM5FtB5ijRuHUDVt5l3yfoOi2uh18Z/O3hvyaHQ39NpxVkCIG5yFs+mLq/ewFp8Bss6zmWv6ZA==",
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
},
|
||||
"@types/lodash": {
|
||||
"version": "4.14.168",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz",
|
||||
"integrity": "sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q=="
|
||||
},
|
||||
"@urbit/eslint-config": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@urbit/eslint-config/-/eslint-config-1.0.0.tgz",
|
||||
"integrity": "sha512-Xmzb6MvM7KorlPJEq/hURZZ4BHSVy/7CoQXWogsBSTv5MOZnMqwNKw6yt24k2AO/2UpHwjGptimaNLqFfesJbw=="
|
||||
},
|
||||
"big-integer": {
|
||||
"version": "1.6.48",
|
||||
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.48.tgz",
|
||||
"integrity": "sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w=="
|
||||
},
|
||||
"immer": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.1.tgz",
|
||||
"integrity": "sha512-7CCw1DSgr8kKYXTYOI1qMM/f5qxT5vIVMeGLDCDX8CSxsggr1Sjdoha4OhsP0AZ1UvWbyZlILHvLjaynuu02Mg=="
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.7",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
|
||||
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
|
||||
}
|
||||
}
|
||||
}
|
1080
pkg/npm/eslint-config/package-lock.json
generated
Normal file
1080
pkg/npm/eslint-config/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
6067
pkg/npm/http-api/package-lock.json
generated
Normal file
6067
pkg/npm/http-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user