Merge branch 'next/groups' into lf/landscape-floor

This commit is contained in:
Hunter Miller 2022-03-31 12:28:29 -05:00 committed by GitHub
commit 1d1b9ae863
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 25445 additions and 56880 deletions

View File

@ -134,23 +134,32 @@
==
--
::
++ on-peek
++ on-peek
~/ %hark-store-peek
|= =path
^- (unit (unit cage))
?+ path (on-peek:def path)
::
[%x %recent %inbox @ @ ~]
=/ date=@da
(slav %ud i.t.t.t.path)
=/ length=@ud
(slav %ud i.t.t.t.t.path)
:^ ~ ~ %hark-update
!> ^- update:store
:- %more
%+ turn (tab:orm archive `date length)
|= [time=@da =timebox:store]
^- update:store
[%timebox archive+time ~(val by timebox)]
[%x %recent %inbox @ @ ~]
=/ date=@da
(slav %ud i.t.t.t.path)
=/ length=@ud
(slav %ud i.t.t.t.t.path)
:^ ~ ~ %hark-update
!> ^- update:store
:- %more
%+ turn (tab:orm archive `date length)
|= [time=@da =timebox:store]
^- update:store
[%timebox archive+time ~(val by timebox)]
::
[%x %all-stats ~]
:^ ~ ~ %hark-update
!> ^- update:store
:- %more
^- (list update:store)
:~ [%all-stats places]
==
==
::
++ on-poke

View File

@ -10,7 +10,7 @@ const GIT_DESC = execSync('git describe --always', { encoding: 'utf8' }).trim();
module.exports = {
mode: 'production',
entry: {
app: './src/index.js',
app: './src/index.tsx',
serviceworker: './src/serviceworker.js'
},
module: {

File diff suppressed because it is too large Load Diff

View File

@ -107,6 +107,7 @@
"lint-staged": "^11.0.0",
"loki": "^0.28.1",
"moment-locales-webpack-plugin": "^1.2.0",
"react-refresh": "^0.11.0",
"sass": "^1.32.5",
"sass-loader": "^8.0.2",
"storybook-addon-designs": "^6.0.0",

View File

@ -1,7 +1,11 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { bootstrapApi } from './logic/api/bootstrap';
import './register-sw';
import './storage-wipe';
import App from './views/App';
// Start subscriptions as soon as possible before rendering anything
bootstrapApi();
ReactDOM.render(<App />, document.getElementById('root'));

View File

@ -9,6 +9,7 @@ import useLaunchState from '../state/launch';
import useSettingsState from '../state/settings';
import useLocalState from '../state/local';
import useStorageState from '../state/storage';
import gcpManager from '../lib/gcpManager';
export async function bootstrapApi() {
airlock.onError = (e) => {
@ -31,20 +32,21 @@ export async function bootstrapApi() {
useLocalState.setState({ subscription: 'connected' });
};
[useGraphState].map(s => s.getState()?.clear?.());
useGraphState.getState().getShallowChildren(`~${window.ship}`, 'dm-inbox');
await useMetadataState.getState().initialize(airlock);
const promises = [
useHarkState,
useMetadataState,
const subs = [
useGroupState,
useContactState,
useHarkState,
useSettingsState,
useLaunchState,
useInviteState,
useGraphState,
useStorageState
useStorageState,
useLaunchState,
useGraphState
].map(state => state.getState().initialize(airlock));
await Promise.all(promises);
}
await Promise.all(subs);
useSettingsState.getState().getAll();
gcpManager.start();
}

View File

@ -0,0 +1,35 @@
import { StorageClient, StorageUpload, UploadParams } from './StorageClient';
import type S3 from 'aws-sdk/clients/s3';
export default class S3Client implements StorageClient {
config: S3.ClientConfiguration;
client: S3 | null = null;
S3: typeof import('aws-sdk/clients/s3');
constructor(config: S3.ClientConfiguration) {
this.config = config;
}
async initAndUpload(params: UploadParams) {
if (!this.S3) {
await this.loadLib();
}
if (!this.client) {
this.client = new this.S3(this.config);
}
return this.client.upload(params).promise();
}
upload(params: UploadParams): StorageUpload {
const upload = this.initAndUpload.bind(this);
return {
promise: () => upload(params)
};
}
async loadLib() {
this.S3 = (await import('aws-sdk/clients/s3')).default;
}
}

View File

@ -1,5 +1,5 @@
import _ from 'lodash';
import { useState, ClipboardEvent } from 'react';
import { useState, ClipboardEvent, useCallback } from 'react';
import { useFileDrag } from './useDrag';
import useStorage, { IuseStorage } from './useStorage';
@ -32,20 +32,9 @@ export function useFileUpload({ multiple = true, ...params }: useFileUploadParam
canUpload, uploadDefault
} = storage;
const [source, setSource] = useState<FileUploadSource>('paste');
const drag = useFileDrag(f => uploadFiles(f, 'drag'));
function onPaste(event: ClipboardEvent) {
if (!event.clipboardData || !event.clipboardData.files.length) {
return;
}
event.preventDefault();
event.stopPropagation();
uploadFiles(event.clipboardData.files, 'paste');
}
function uploadFiles(files: FileList | File[], uploadSource: FileUploadSource) {
const uploadFiles = useCallback((files: FileList | File[], uploadSource: FileUploadSource) => {
if (isFileUploadHandler(params)) {
return params.onFiles(files, storage, uploadSource);
}
@ -67,6 +56,19 @@ export function useFileUpload({ multiple = true, ...params }: useFileUploadParam
onError && onError(err);
});
});
}, [canUpload, storage, params]);
const drag = useFileDrag(f => uploadFiles(f, 'drag'));
function onPaste(event: ClipboardEvent) {
if (!event.clipboardData || !event.clipboardData.files.length) {
return;
}
event.preventDefault();
event.stopPropagation();
uploadFiles(event.clipboardData.files, 'paste');
}
return {

View File

@ -1,4 +1,4 @@
import S3 from 'aws-sdk/clients/s3';
import S3Client from './S3Client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import useStorageState from '../state/storage';
import GcpClient from './GcpClient';
@ -10,7 +10,7 @@ export interface IuseStorage {
upload: (file: File, bucket: string) => Promise<string>;
uploadDefault: (file: File) => Promise<string>;
uploading: boolean;
promptUpload: () => Promise<string>;
promptUpload: (onError?: (err: Error) => void) => Promise<string>;
}
const useStorage = ({ accept = '*' } = { accept: '*' }): IuseStorage => {
@ -32,7 +32,7 @@ const useStorage = ({ accept = '*' } = { accept: '*' }): IuseStorage => {
!s3.credentials.secretAccessKey) {
return;
}
client.current = new S3({
client.current = new S3Client({
credentials: s3.credentials,
endpoint: s3.credentials.endpoint
});
@ -85,7 +85,7 @@ const useStorage = ({ accept = '*' } = { accept: '*' }): IuseStorage => {
}, [s3, upload]);
const promptUpload = useCallback(
(): Promise<string> => {
(onError?: (err: Error) => void): Promise<string> => {
return new Promise((resolve, reject) => {
const fileSelector = document.createElement('input');
fileSelector.setAttribute('type', 'file');
@ -95,6 +95,9 @@ const useStorage = ({ accept = '*' } = { accept: '*' }): IuseStorage => {
const files = fileSelector.files;
if (!files || files.length <= 0) {
reject();
} else if (onError) {
uploadDefault(files[0]).then(resolve).catch(err => onError(err));
document.body.removeChild(fileSelector);
} else {
uploadDefault(files[0]).then(resolve);
document.body.removeChild(fileSelector);

View File

@ -5,7 +5,6 @@ import f from 'lodash/fp';
import { Association, Patp } from '@urbit/api';
import { enableMapSet } from 'immer';
/* eslint-disable max-lines */
import anyAscii from 'any-ascii';
import bigInt, { BigInteger } from 'big-integer';
import { IconRef, Workspace } from '~/types';
@ -410,7 +409,8 @@ export function getContactDetails(contact: any) {
return { nickname, color, member, avatar };
}
export function stringToSymbol(str: string) {
export async function stringToSymbol(str: string) {
const anyAscii = (await import('any-ascii')).default;
const ascii = anyAscii(str);
let result = '';
for (let i = 0; i < ascii.length; i++) {

View File

@ -2,7 +2,7 @@ import { patp2dec } from 'urbit-ob';
import shallow from 'zustand/shallow';
import {
Association, BigIntOrderedMap, deSig, GraphNode, Graphs, FlatGraphs, resourceFromPath, ThreadGraphs, getGraph, getShallowChildren, setScreen,
addDmMessage, addPost, Content, getDeepOlderThan, getFirstborn, getNewest, getNode, getOlderSiblings, getYoungerSiblings, markPending, Post, addNode, GraphNodePoke
addDmMessage, addPost, Content, getDeepOlderThan, getFirstborn, getNewest, getNode, getOlderSiblings, getYoungerSiblings, markPending, Post, addNode, GraphNodePoke, getKeys
} from '@urbit/api';
import { useCallback } from 'react';
import { createState, createSubscription, reduceStateN, pokeOptimisticallyN } from './base';
@ -25,6 +25,7 @@ export interface GraphState {
pendingDms: Set<string>;
screening: boolean;
graphTimesentMap: Record<number, string>;
getKeys(): Promise<void>;
getShallowChildren: (ship: string, name: string, index?: string) => Promise<void>;
getDeepOlderThan: (ship: string, name: string, count: number, start?: string) => Promise<void>;
getNewest: (ship: string, resource: string, count: number, index?: string) => Promise<void>;
@ -149,15 +150,11 @@ const useGraphState = createState<GraphState>('Graph', (set, get) => ({
setScreen: (screen: boolean) => {
const poke = setScreen(screen);
pokeOptimisticallyN(useGraphState, poke, reduceDm);
},
getKeys: async () => {
const keys = await airlock.scry(getKeys());
GraphReducer(keys);
}
// getKeys: async () => {
// const api = useApi();
// const keys = await api.scry({
// app: 'graph-store',
// path: '/keys'
// });
// graphReducer(keys);
// },
// getTags: async () => {
// const api = useApi();
// const tags = await api.scry({

View File

@ -35,6 +35,7 @@ export interface HarkState {
doNotDisturb: boolean;
poke: (poke: Poke<any>) => Promise<void>;
getMore: () => Promise<boolean>;
getUnreads: () => Promise<void>;
opened: () => void;
readCount: (path: string) => Promise<void>;
// getTimeSubset: (start?: Date, end?: Date) => Promise<void>;
@ -121,6 +122,13 @@ const useHarkState = createState<HarkState>(
reduceStateN(useHarkState.getState(), update, [reduce]);
return get().archive?.size === oldSize;
},
getUnreads: async (): Promise<void> => {
const update = await api.scry({
app: 'hark-store',
path: '/all-stats'
});
reduceStateN(useHarkState.getState(), update, [reduce]);
},
unseen: {},
seen: {},
notificationsCount: 0,

View File

@ -1,18 +1,12 @@
import Mousetrap from 'mousetrap';
import shallow from 'zustand/shallow';
import 'mousetrap-global-bind';
import * as React from 'react';
import Helmet from 'react-helmet';
import { Router, withRouter } from 'react-router-dom';
import styled, { ThemeProvider } from 'styled-components';
import FingerprintJS from '@fingerprintjs/fingerprintjs';
import gcpManager from '~/logic/lib/gcpManager';
import { svgDataURL } from '~/logic/lib/util';
import history from '~/logic/lib/history';
import { favicon } from '~/logic/state/contact';
import useLocalState, { selectLocalState } from '~/logic/state/local';
import useSettingsState, { selectSettingsState, SettingsState } from '~/logic/state/settings';
import useGraphState, { GraphState } from '~/logic/state/graph';
import { SettingsState } from '~/logic/state/settings';
import { ShortcutContextProvider } from '~/logic/lib/shortcutContext';
import ErrorBoundary from '~/views/components/ErrorBoundary';
@ -23,9 +17,9 @@ import './css/fonts.css';
import './css/indigo-static.css';
import { Content } from './landscape/components/Content';
import './landscape/css/custom.css';
import { bootstrapApi } from '~/logic/api/bootstrap';
import { uxToHex } from '@urbit/api';
import { useThemeWatcher } from '~/logic/lib/useThemeWatcher';
import useLocalState from '~/logic/state/local';
function ensureValidHex(color) {
if (!color)
@ -91,31 +85,13 @@ const Root = styled.div<RootProps>`
const StatusBarWithRouter = withRouter(StatusBar);
const selLocal = selectLocalState(['toggleOmnibox']);
const selSettings = selectSettingsState(['display', 'getAll']);
const selGraph = (s: GraphState) => s.getShallowChildren;
const App: React.FunctionComponent = () => {
const { getAll } = useSettingsState(selSettings, shallow);
const { toggleOmnibox } = useLocalState(selLocal);
const getShallowChildren = useGraphState(selGraph);
const { theme, display } = useThemeWatcher();
React.useEffect(() => {
bootstrapApi();
getShallowChildren(`~${window.ship}`, 'dm-inbox');
getId().then((value) => {
useLocalState.setState({ browserId: value });
});
getAll();
gcpManager.start();
Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => {
e.preventDefault();
e.stopImmediatePropagation();
toggleOmnibox();
});
}, []);
return (

View File

@ -1,6 +1,13 @@
import { Box, Icon, LoadingSpinner, Row } from '@tlon/indigo-react';
import { Box, Col, Icon, LoadingSpinner, Row, Text } from '@tlon/indigo-react';
import { Contact, Content, evalCord } from '@urbit/api';
import React, { FC, PropsWithChildren, useRef, useState } from 'react';
import VisibilitySensor from 'react-visibility-sensor';
import React, {
FC,
PropsWithChildren,
useEffect,
useRef,
useState
} from 'react';
import tokenizeMessage from '~/logic/lib/tokenizeMessage';
import { IuseStorage } from '~/logic/lib/useStorage';
import { MOBILE_BROWSER_REGEX } from '~/logic/lib/util';
@ -11,41 +18,57 @@ import { ChatAvatar } from './ChatAvatar';
import { useChatStore } from './ChatPane';
import { useImperativeHandle } from 'react';
import { FileUploadSource, useFileUpload } from '~/logic/lib/useFileUpload';
import { Portal } from '~/views/components/Portal';
import styled from 'styled-components';
import { useOutsideClick } from '~/logic/lib/useOutsideClick';
type ChatInputProps = PropsWithChildren<IuseStorage & {
hideAvatars: boolean;
ourContact?: Contact;
placeholder: string;
onSubmit: (contents: Content[]) => void;
}>;
const FixedOverlay = styled(Col)`
position: fixed;
-webkit-transition: all 0.1s ease-out;
-moz-transition: all 0.1s ease-out;
-o-transition: all 0.1s ease-out;
transition: all 0.1s ease-out;
`;
type ChatInputProps = PropsWithChildren<
IuseStorage & {
hideAvatars: boolean;
ourContact?: Contact;
placeholder: string;
onSubmit: (contents: Content[]) => void;
uploadError: string;
setUploadError: (val: string) => void;
handleUploadError: (err: Error) => void;
}
>;
const InputBox: FC = ({ children }) => (
<Row
alignItems='center'
position='relative'
alignItems="center"
position="relative"
flexGrow={1}
flexShrink={0}
borderTop={1}
borderTopColor='lightGray'
backgroundColor='white'
className='cf'
borderTopColor="lightGray"
backgroundColor="white"
className="cf"
zIndex={0}
>
{ children }
{children}
</Row>
);
const IconBox = ({ children, ...props }) => (
<Box
ml='12px'
ml="12px"
mr={3}
flexShrink={0}
height='16px'
width='16px'
flexBasis='16px'
height="16px"
width="16px"
flexBasis="16px"
{...props}
>
{ children }
{children}
</Box>
);
@ -68,99 +91,157 @@ const MobileSubmitButton = ({ enabled, onSubmit }) => (
</Box>
);
export const ChatInput = React.forwardRef(({ ourContact, hideAvatars, placeholder, onSubmit }: ChatInputProps, ref) => {
const chatEditor = useRef<CodeMirrorShim>(null);
useImperativeHandle(ref, () => chatEditor.current);
const [inCodeMode, setInCodeMode] = useState(false);
export const ChatInput = React.forwardRef(
(
{
ourContact,
hideAvatars,
placeholder,
onSubmit,
uploadError,
setUploadError,
handleUploadError
}: ChatInputProps,
ref
) => {
const chatEditor = useRef<CodeMirrorShim>(null);
useImperativeHandle(ref, () => chatEditor.current);
const [inCodeMode, setInCodeMode] = useState(false);
const [showPortal, setShowPortal] = useState(false);
const [visible, setVisible] = useState(false);
const innerRef = useRef<HTMLDivElement>(null);
const outerRef = useRef<HTMLDivElement>(null);
const {
message,
setMessage
} = useChatStore();
const { canUpload, uploading, promptUpload, onPaste } = useFileUpload({
onSuccess: uploadSuccess
});
useEffect(() => {
if (!visible) {
setShowPortal(false);
}
}, [visible]);
function uploadSuccess(url: string, source: FileUploadSource) {
if (source === 'paste') {
setMessage(url);
} else {
onSubmit([{ url }]);
}
}
useOutsideClick(innerRef, () => setShowPortal(false));
function toggleCode() {
setInCodeMode(!inCodeMode);
}
const { message, setMessage } = useChatStore();
const { canUpload, uploading, promptUpload, onPaste } = useFileUpload({
onSuccess: uploadSuccess,
onError: handleUploadError
});
async function submit() {
const text = chatEditor.current?.getValue() || '';
if (text === '') {
return;
function uploadSuccess(url: string, source: FileUploadSource) {
if (source === 'paste') {
setMessage(url);
} else {
onSubmit([{ url }]);
}
setUploadError('');
}
if (inCodeMode) {
const output = await airlock.thread<string[]>(evalCord(text));
onSubmit([{ code: { output, expression: text } }]);
} else {
onSubmit(tokenizeMessage(text));
function toggleCode() {
setInCodeMode(!inCodeMode);
}
setInCodeMode(false);
setMessage('');
chatEditor.current.focus();
}
async function submit() {
const text = chatEditor.current?.getValue() || '';
return (
<InputBox>
<Row p='12px 4px 12px 12px' flexShrink={0} alignItems='center'>
<ChatAvatar contact={ourContact} hideAvatars={hideAvatars} />
</Row>
<ChatEditor
ref={chatEditor}
inCodeMode={inCodeMode}
submit={submit}
onPaste={(cm, e) => onPaste(e)}
placeholder={placeholder}
/>
<IconBox mr={canUpload ? '12px' : 3}>
<Icon
icon='Dojo'
cursor='pointer'
onClick={toggleCode}
color={inCodeMode ? 'blue' : 'black'}
/>
</IconBox>
{canUpload && (
<IconBox>
{uploading ? (
<LoadingSpinner />
) : (
<Icon
icon='Attachment'
cursor='pointer'
width='16'
height='16'
onClick={() =>
promptUpload().then(url => uploadSuccess(url, 'direct'))
}
if (text === '') {
return;
}
if (inCodeMode) {
const output = await airlock.thread<string[]>(evalCord(text));
onSubmit([{ code: { output, expression: text } }]);
} else {
onSubmit(tokenizeMessage(text));
}
setInCodeMode(false);
setMessage('');
chatEditor.current.focus();
}
return (
<Box ref={outerRef}>
<VisibilitySensor active={showPortal} onChange={setVisible}>
<InputBox>
{showPortal && (
<Portal>
<FixedOverlay
ref={innerRef}
backgroundColor="white"
color="washedGray"
border={1}
right={25}
bottom={75}
borderRadius={2}
borderColor="lightGray"
boxShadow="0px 0px 0px 3px"
zIndex={3}
fontSize={0}
width="250px"
padding={3}
justifyContent="center"
alignItems="center"
>
<Text>{uploadError}</Text>
<Text>Please check S3 settings.</Text>
</FixedOverlay>
</Portal>
)}
<Row p="12px 4px 12px 12px" flexShrink={0} alignItems="center">
<ChatAvatar contact={ourContact} hideAvatars={hideAvatars} />
</Row>
<ChatEditor
ref={chatEditor}
inCodeMode={inCodeMode}
submit={submit}
onPaste={(cm, e) => onPaste(e)}
placeholder={placeholder}
/>
)}
</IconBox>
)}
{MOBILE_BROWSER_REGEX.test(navigator.userAgent) && (
<MobileSubmitButton
enabled={message !== ''}
onSubmit={submit}
/>
)}
</InputBox>
);
});
<IconBox mr={canUpload ? '12px' : 3}>
<Icon
icon="Dojo"
cursor="pointer"
onClick={toggleCode}
color={inCodeMode ? 'blue' : 'black'}
/>
</IconBox>
{canUpload && (
<IconBox>
{uploadError == '' && uploading && <LoadingSpinner />}
{uploadError !== '' && (
<Icon
icon="ExclaimationMark"
cursor="pointer"
onClick={() => setShowPortal(true)}
/>
)}
{uploadError == '' && !uploading && (
<Icon
icon="Attachment"
cursor="pointer"
width="16"
height="16"
onClick={() =>
promptUpload(handleUploadError).then(url =>
uploadSuccess(url, 'direct')
)
}
/>
)}
</IconBox>
)}
{MOBILE_BROWSER_REGEX.test(navigator.userAgent) && (
<MobileSubmitButton enabled={message !== ''} onSubmit={submit} />
)}
</InputBox>
</VisibilitySensor>
</Box>
);
}
);
// @ts-ignore withLocalState prop passing weirdness
export default withLocalState<Omit<ChatInputProps, keyof IuseStorage>, 'hideAvatars', ChatInput>(
ChatInput,
['hideAvatars']
);
export default withLocalState<
Omit<ChatInputProps, keyof IuseStorage>,
'hideAvatars',
typeof ChatInput
>(ChatInput, ['hideAvatars']);

View File

@ -114,8 +114,18 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
const graphTimesentMap = useGraphTimesent(id);
const ourContact = useOurContact();
const { restore, setMessage } = useChatStore(s => ({ setMessage: s.setMessage, restore: s.restore }));
const [uploadError, setUploadError] = useState<string>('');
const handleUploadError = useCallback((err: Error) => {
setUploadError(err.message);
}, []);
const { canUpload, drag } = useFileUpload({
onSuccess: url => onSubmit([{ url }])
onSuccess: (url) => {
onSubmit([{ url }]);
setUploadError('');
},
onError: handleUploadError
});
useEffect(() => {
@ -171,6 +181,9 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
onSubmit={onSubmit}
ourContact={(promptShare.length === 0 && ourContact) || undefined}
placeholder="Message..."
uploadError={uploadError}
setUploadError={setUploadError}
handleUploadError={handleUploadError}
/>
)}
</Col>

View File

@ -1,5 +1,5 @@
import React, { useCallback, useState } from 'react';
import { Box, LoadingSpinner, Action, Row } from '@tlon/indigo-react';
import { Box, LoadingSpinner, Action, Row, Icon, Text } from '@tlon/indigo-react';
import { StatelessUrlInput } from '~/views/components/StatelessUrlInput';
import SubmitDragger from '~/views/components/SubmitDragger';
@ -21,6 +21,7 @@ export function LinkBlockInput(props: LinkBlockInputProps) {
const [url, setUrl] = useState(props.url || '');
const [valid, setValid] = useState(false);
const [focussed, setFocussed] = useState(false);
const [error, setError] = useState<string>('');
const addPost = useGraphState(selGraph);
@ -39,10 +40,16 @@ export function LinkBlockInput(props: LinkBlockInputProps) {
const handleChange = useCallback((val: string) => {
setUrl(val);
setValid(URLparser.test(val) || Boolean(parsePermalink(val)));
setError('');
}, []);
const handleError = useCallback((err: Error) => {
setError(err.message);
}, []);
const { uploading, canUpload, promptUpload, drag } = useFileUpload({
onSuccess: handleChange
onSuccess: handleChange,
onError: handleError
});
const doPost = () => {
@ -64,7 +71,7 @@ export function LinkBlockInput(props: LinkBlockInputProps) {
};
const onKeyPress = useCallback(
(e) => {
(e: any) => {
if (e.key === 'Enter') {
e.preventDefault();
doPost();
@ -89,22 +96,43 @@ export function LinkBlockInput(props: LinkBlockInputProps) {
backgroundColor="washedGray"
{...drag.bind}
>
{drag.dragging && <SubmitDragger />}
{drag.dragging && canUpload && <SubmitDragger />}
{uploading ? (
<Box
display="flex"
width="100%"
height="100%"
position="absolute"
left={0}
right={0}
bg="white"
zIndex={9}
alignItems="center"
justifyContent="center"
>
<LoadingSpinner />
</Box>
error != '' ? (
<Box
display="flex"
flexDirection="column"
width="100%"
height="100%"
padding={3}
position="absolute"
left={0}
right={0}
bg="white"
zIndex={9}
alignItems="center"
justifyContent="center"
>
<Icon icon="ExclaimationMarkBold" size={32} />
<Text bold>{error}</Text>
<Text>Please check your S3 settings.</Text>
</Box>
) : (
<Box
display="flex"
width="100%"
height="100%"
position="absolute"
left={0}
right={0}
bg="white"
zIndex={9}
alignItems="center"
justifyContent="center"
>
<LoadingSpinner />
</Box>
)
) : (
<StatelessUrlInput
value={url}
@ -114,6 +142,7 @@ export function LinkBlockInput(props: LinkBlockInputProps) {
focussed={focussed}
onBlur={onBlur}
promptUpload={promptUpload}
handleError={handleError}
onKeyPress={onKeyPress}
center
/>
@ -125,7 +154,11 @@ export function LinkBlockInput(props: LinkBlockInputProps) {
p="2"
justifyContent="row-end"
>
<Action onClick={doPost} disabled={!valid} backgroundColor="transparent">
<Action
onClick={doPost}
disabled={!valid}
backgroundColor="transparent"
>
Post
</Action>
</Row>

View File

@ -1,6 +1,13 @@
import { BaseInput, Box, Button, LoadingSpinner } from '@tlon/indigo-react';
import {
BaseInput,
Box,
Button,
Icon,
LoadingSpinner,
Text
} from '@tlon/indigo-react';
import { hasProvider } from 'oembed-parser';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { parsePermalink, permalinkToReference } from '~/logic/lib/permalinks';
import { StatelessUrlInput } from '~/views/components/StatelessUrlInput';
import SubmitDragger from '~/views/components/SubmitDragger';
@ -22,15 +29,18 @@ const LinkSubmit = (props: LinkSubmitProps) => {
const [linkTitle, setLinkTitle] = useState('');
const [disabled, setDisabled] = useState(false);
const [linkValid, setLinkValid] = useState(false);
const [error, setError] = useState<string>('');
const {
canUpload,
uploading,
promptUpload,
drag,
onPaste
} = useFileUpload({
onSuccess: setLinkValue,
const handleError = useCallback((err: Error) => {
setError(err.message);
}, []);
const { canUpload, uploading, promptUpload, drag, onPaste } = useFileUpload({
onSuccess: (url) => {
setLinkValue(url);
setError('');
},
onError: handleError,
multiple: false
});
@ -38,25 +48,21 @@ const LinkSubmit = (props: LinkSubmitProps) => {
const url = linkValue;
const text = linkTitle ? linkTitle : linkValue;
const contents = url.startsWith('web+urbitgraph:/')
? [{ text }, permalinkToReference(parsePermalink(url)!)]
: [{ text }, { url }];
? [{ text }, permalinkToReference(parsePermalink(url)!)]
: [{ text }, { url }];
setDisabled(true);
const parentIndex = props.parentIndex || '';
const post = createPost(window.ship, contents, parentIndex);
addPost(
`~${props.ship}`,
props.name,
post
);
addPost(`~${props.ship}`, props.name, post);
setDisabled(false);
setLinkValue('');
setLinkTitle('');
setLinkValid(false);
};
const validateLink = (link) => {
const validateLink = (link: any) => {
const URLparser = new RegExp(
/((?:([\w\d\.-]+)\:\/\/?){1}(?:(www)\.?){0,1}(((?:[\w\d-]+\.)*)([\w\d-]+\.[\w\d]+))){1}(?:\:(\d+)){0,1}((\/(?:(?:[^\/\s\?]+\/)*))(?:([^\?\/\s#]+?(?:.[^\?\s]+){0,1}){0,1}(?:\?([^\s#]+)){0,1})){0,1}(?:#([^#\s]+)){0,1}/
);
@ -70,9 +76,9 @@ const LinkSubmit = (props: LinkSubmitProps) => {
setLinkValue(link);
}
}
if(link.startsWith('web+urbitgraph://')) {
if (link.startsWith('web+urbitgraph://')) {
const permalink = parsePermalink(link);
if(!permalink) {
if (!permalink) {
setLinkValid(false);
return;
}
@ -86,17 +92,23 @@ const LinkSubmit = (props: LinkSubmitProps) => {
if (result.title && !linkTitle) {
setLinkTitle(result.title);
}
}).catch((error) => { /* noop*/ });
})
.catch((error) => {
/* noop*/
});
} else if (!linkTitle) {
setLinkTitle(decodeURIComponent(link
.split('/')
.pop()
.split('.')
.slice(0, -1)
.join('.')
.replace('_', ' ')
.replace(/\d{4}\.\d{1,2}\.\d{2}\.\.\d{2}\.\d{2}\.\d{2}-/, '')
));
setLinkTitle(
decodeURIComponent(
link
.split('/')
.pop()
.split('.')
.slice(0, -1)
.join('.')
.replace('_', ' ')
.replace(/\d{4}\.\d{1,2}\.\d{2}\.\.\d{2}\.\d{2}\.\d{2}-/, '')
)
);
}
}
return link;
@ -113,7 +125,7 @@ const LinkSubmit = (props: LinkSubmitProps) => {
useEffect(onLinkChange, [linkValue]);
const onKeyPress = (e) => {
const onKeyPress = (e: any) => {
if (e.key === 'Enter') {
e.preventDefault();
doPost();
@ -122,60 +134,86 @@ const LinkSubmit = (props: LinkSubmitProps) => {
return (
<>
{/* @ts-ignore archaic event type mismatch */}
{/* @ts-ignore archaic event type mismatch */}
<Box
flexShrink={0}
position='relative'
border='1px solid'
position="relative"
border="1px solid"
borderColor={submitFocused ? 'black' : 'lightGray'}
width='100%'
width="100%"
borderRadius={2}
{...drag.bind}
>
{uploading && <Box
display="flex"
width="100%"
height="100%"
position="absolute"
left={0}
right={0}
bg="white"
zIndex={9}
alignItems="center"
justifyContent="center"
>
<LoadingSpinner />
</Box>}
{drag.dragging && <SubmitDragger />}
<StatelessUrlInput
value={linkValue}
promptUpload={promptUpload}
canUpload={canUpload}
onSubmit={doPost}
onChange={setLinkValue}
error={linkValid ? 'Invalid URL' : undefined}
onKeyPress={onKeyPress}
onPaste={onPaste}
/>
<BaseInput
type="text"
pl={2}
backgroundColor="transparent"
width="100%"
color="black"
fontSize={1}
style={{
resize: 'none',
height: 40
}}
placeholder="Provide a title"
onChange={e => setLinkTitle(e.target.value)}
onBlur={() => setSubmitFocused(false)}
onFocus={() => setSubmitFocused(true)}
spellCheck="false"
onKeyPress={onKeyPress}
value={linkTitle}
/>
{uploading ? (
error !== '' ? (
<Box
display="flex"
flexDirection="column"
width="100%"
height="100%"
left={0}
right={0}
bg="white"
zIndex={9}
alignItems="center"
justifyContent="center"
py={2}
>
<Icon icon="ExclaimationMarkBold" size={32} />
<Text bold>{error}</Text>
<Text>Please check your S3 settings.</Text>
</Box>
) : (
<Box
display="flex"
width="100%"
height="100%"
left={0}
right={0}
bg="white"
zIndex={9}
alignItems="center"
justifyContent="center"
py={2}
>
<LoadingSpinner />
</Box>
)
) : (
<>
<StatelessUrlInput
value={linkValue}
promptUpload={promptUpload}
canUpload={canUpload}
onSubmit={doPost}
onChange={setLinkValue}
error={linkValid ? 'Invalid URL' : undefined}
onKeyPress={onKeyPress}
onPaste={onPaste}
handleError={handleError}
/>
<BaseInput
type="text"
pl={2}
backgroundColor="transparent"
width="100%"
color="black"
fontSize={1}
style={{
resize: 'none',
height: 40
}}
placeholder="Provide a title"
onChange={e => setLinkTitle(e.target.value)}
onBlur={() => setSubmitFocused(false)}
onFocus={() => setSubmitFocused(true)}
spellCheck="false"
onKeyPress={onKeyPress}
value={linkTitle}
/>
</>
)}
{drag.dragging && <SubmitDragger />}
</Box>
<Box mt={2} mb={4}>
<Button
@ -183,7 +221,9 @@ const LinkSubmit = (props: LinkSubmitProps) => {
flexShrink={0}
disabled={!linkValid || disabled}
onClick={doPost}
>Post link</Button>
>
Post link
</Button>
</Box>
</>
);

View File

@ -66,7 +66,7 @@ export function Notification(props: {
const key = `${harkLidToId(lid)}-${harkBinToId(notification.bin)}`;
const history = useHistory();
const isMobile = useLocalState((s) => s.mobile);
const isMobile = useLocalState(s => s.mobile);
const onArchive = useCallback(
async (e) => {
@ -80,8 +80,9 @@ export function Notification(props: {
);
const { hovering, bind } = useHovering();
const dedupedBody = uniqBy(notification.body, (item) => item.link);
const contents = map(dedupedBody, 'content').filter((c) => c.length > 0);
const dedupedBody = uniqBy(notification.body, item => item.link);
const orderedByTime = dedupedBody.sort((a, b) => b.time - a.time);
const contents = map(orderedByTime, 'content').filter(c => c.length > 0);
const first = notification.body[0];
if (!first) {
// should be unreachable

View File

@ -9,8 +9,9 @@ type StatelessUrlInputProps = PropFunc<typeof BaseInput> & {
focussed?: boolean;
disabled?: boolean;
onChange?: (value: string) => void;
promptUpload: () => Promise<string>;
promptUpload: (onError: (err: Error) => void) => Promise<string>;
canUpload: boolean;
handleError: (err: Error) => void;
center?: boolean;
};
@ -22,6 +23,7 @@ export function StatelessUrlInput(props: StatelessUrlInputProps) {
onChange = () => {},
promptUpload,
canUpload,
handleError,
center = false,
...rest
} = props;
@ -53,7 +55,7 @@ export function StatelessUrlInput(props: StatelessUrlInputProps) {
cursor="pointer"
color="blue"
style={{ pointerEvents: 'all' }}
onClick={() => promptUpload().then(onChange)}
onClick={() => promptUpload(handleError).then(onChange)}
>
upload
</Text>{' '}

View File

@ -7,7 +7,9 @@ import {
Row,
Text
} from '@tlon/indigo-react';
import React from 'react';
import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind';
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Sigil } from '~/logic/lib/sigil';
import { uxToHex } from '~/logic/lib/util';
@ -46,6 +48,14 @@ const StatusBar = () => {
<Sigil ship={ship} size={16} color={color} icon />
);
useEffect(() => {
Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => {
e.preventDefault();
e.stopImmediatePropagation();
toggleOmnibox();
});
}, []);
return (
<Box
display='grid'

View File

@ -6,6 +6,11 @@ import { PermalinkRoutes } from '~/views/apps/permalinks/app';
import { useShortcut } from '~/logic/state/settings';
import { Loading } from '~/views/components/Loading';
import LaunchApp from '~/views/apps/launch/App';
import Landscape from '~/views/landscape';
import Settings from '~/views/apps/settings/settings';
import Profile from '~/views/apps/profile/profile';
import Notifications from '~/views/apps/notifications/notifications';
import ErrorComponent from '~/views/components/Error';
import { getNotificationRedirectFromLink } from '~/logic/lib/notificationRedirects';
import { JoinRoute } from './Join/Join';
@ -19,12 +24,6 @@ export const Container = styled(Box)`
height: calc(100% - 62px);
`;
const Landscape = React.lazy(() => import('~/views/landscape/index'));
const Settings = React.lazy(() => import('~/views/apps/settings/settings'));
const Profile = React.lazy(() => import('~/views/apps/profile/profile'));
const Notifications = React.lazy(() => import('~/views/apps/notifications/notifications'));
const ErrorComponent = React.lazy(() => import('~/views/components/Error'));
export const Content = () => {
const history = useHistory();
const location = useLocation();

View File

@ -4,7 +4,7 @@ import Helmet from 'react-helmet';
import { Box } from '@tlon/indigo-react';
import {
Route,
RouteComponentProps, Switch
RouteComponentProps, Switch, useLocation
} from 'react-router-dom';
import { useShortcut } from '~/logic/state/settings';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
@ -21,20 +21,21 @@ import { PopoverRoutes } from './PopoverRoutes';
import { Skeleton } from './Skeleton';
import { EmptyGroupHome } from './Home/EmptyGroupHome';
import { Join } from './Join/Join';
import { Resource } from './Resource';
import { DmResource } from '~/views/apps/chat/DmResource';
import { UnjoinedResource } from '~/views/components/UnjoinedResource';
import { NewChannel } from './NewChannel';
import { GroupHome } from './Home/GroupHome';
import useGraphState from '~/logic/state/graph';
interface GroupsPaneProps {
baseUrl: string;
workspace: Workspace;
}
const DmResource = React.lazy(() => import('~/views/apps/chat/DmResource').then(module => ({ default: module.DmResource })));
const Resource = React.lazy(() => import('./Resource').then(module => ({ default: module.Resource })));
const UnjoinedResource = React.lazy(() => import('~/views/components/UnjoinedResource').then(module => ({ default: module.UnjoinedResource })));
const GroupHome = React.lazy(() => import('./Home/GroupHome').then(module => ({ default: module.GroupHome })));
const NewChannel = React.lazy(() => import('./NewChannel').then(module => ({ default: module.NewChannel })));
export function GroupsPane(props: GroupsPaneProps) {
const { baseUrl, workspace } = props;
const location = useLocation();
const associations = useMetadataState(state => state.associations);
const notificationsCount = useHarkState(state => state.notificationsCount);
@ -56,6 +57,20 @@ export function GroupsPane(props: GroupsPaneProps) {
[]
);
useEffect(() => {
const {
getKeys,
getShallowChildren
} = useGraphState.getState();
useHarkState.getState().getUnreads();
getKeys();
if (location.pathname.startsWith('/~landscape/messages')) {
getShallowChildren(`~${window.ship}`, 'dm-inbox');
}
}, []);
useEffect(() => {
if (workspace.type !== 'group') {
return;
@ -93,170 +108,170 @@ export function GroupsPane(props: GroupsPaneProps) {
);
return (
<Switch>
<Route
path={relativePath('/dm/:ship')}
render={({ match }) => {
const { ship } = match.params as Record<string, string>;
<Switch>
<Route
path={relativePath('/dm/:ship')}
render={({ match }) => {
const { ship } = match.params as Record<string, string>;
return (
<Skeleton
mobileHide
recentGroups={recentGroups}
selected={ship}
{...props}
baseUrl={match.path}
> <DmResource ship={ship} />
</Skeleton>
);
}}
/>
<Route
path={[relativePath('/resource/:app/(ship)?/:host/:name')]}
render={(routeProps) => {
const { app, host, name } = routeProps.match.params as Record<
string,
string
>;
const resource = `/ship/${host}/${name}`;
const association = associations.graph[resource];
const resourceUrl = `${baseUrl}/resource/${app}${resource}`;
if (!association) {
return <Loading />;
}
return (
<Skeleton
mobileHide
recentGroups={recentGroups}
selected={resource}
{...props}
baseUrl={resourceUrl}
>
<Resource
workspace={props.workspace}
association={association}
baseUrl={baseUrl}
/>
{popovers(routeProps, resourceUrl)}
</Skeleton>
);
}}
/>
<Route
path={relativePath('/join/:app/(ship)?/:host/:name')}
render={(routeProps) => {
const { app, host, name } = routeProps.match.params;
const appPath = `/ship/${host}/${name}`;
const association = associations.graph[appPath];
const resourceUrl = `${baseUrl}/join/${app}${appPath}`;
let title = groupAssociation?.metadata?.title ?? 'Groups';
if (!association) {
return <Loading />;
}
title += ` - ${association.metadata.title}`;
return (
<>
<Helmet defer={false}>
<title>{notificationsCount ? `(${String(notificationsCount)}) ` : ''}{ title }</title>
</Helmet>
return (
<Skeleton
recentGroups={recentGroups}
mobileHide
selected={appPath}
recentGroups={recentGroups}
selected={ship}
{...props}
baseUrl={baseUrl}
baseUrl={match.path}
> <DmResource ship={ship} />
</Skeleton>
);
}}
/>
<Route
path={[relativePath('/resource/:app/(ship)?/:host/:name')]}
render={(routeProps) => {
const { app, host, name } = routeProps.match.params as Record<
string,
string
>;
const resource = `/ship/${host}/${name}`;
const association = associations.graph[resource];
const resourceUrl = `${baseUrl}/resource/${app}${resource}`;
if (!association) {
return <Loading />;
}
return (
<Skeleton
mobileHide
recentGroups={recentGroups}
selected={resource}
{...props}
baseUrl={resourceUrl}
>
<UnjoinedResource
baseUrl={baseUrl}
<Resource
workspace={props.workspace}
association={association}
baseUrl={baseUrl}
/>
{popovers(routeProps, resourceUrl)}
</Skeleton>
</>
);
}}
/>
<Route
path={relativePath('/pending/:ship/:name')}
render={(routeProps) => {
const { ship, name } = routeProps.match.params as Record<string, string>;
const desc = {
group: `/ship/${ship}/${name}`,
kind: 'graph' as const
};
return (
<Skeleton
mobileHide
recentGroups={recentGroups}
{...props}
baseUrl={baseUrl}
>
<Box overflow="auto">
<Join desc={desc} />
</Box>
</Skeleton>
);
}}
/>
<Route
path={relativePath('/new')}
render={(routeProps) => {
return (
<Skeleton mobileHide recentGroups={recentGroups} {...props} baseUrl={baseUrl}>
<NewChannel
{...routeProps}
baseUrl={baseUrl}
group={groupPath}
workspace={workspace}
/>
{popovers(routeProps, baseUrl)}
</Skeleton>
);
}}
/>
<Route
path={[relativePath('/'), relativePath('/feed+')]}
render={(routeProps) => {
const shouldHideSidebar =
routeProps.location.pathname.includes('/feed');
const title = groupAssociation?.metadata?.title ?? 'Groups';
return (
<>
<Helmet defer={false}>
<title>
{notificationsCount ? `(${String(notificationsCount)}) ` : ''}
{ title }
</title>
</Helmet>
);
}}
/>
<Route
path={relativePath('/join/:app/(ship)?/:host/:name')}
render={(routeProps) => {
const { app, host, name } = routeProps.match.params;
const appPath = `/ship/${host}/${name}`;
const association = associations.graph[appPath];
const resourceUrl = `${baseUrl}/join/${app}${appPath}`;
let title = groupAssociation?.metadata?.title ?? 'Groups';
if (!association) {
return <Loading />;
}
title += ` - ${association.metadata.title}`;
return (
<>
<Helmet defer={false}>
<title>{notificationsCount ? `(${String(notificationsCount)}) ` : ''}{ title }</title>
</Helmet>
<Skeleton
recentGroups={recentGroups}
mobileHide
selected={appPath}
{...props}
baseUrl={baseUrl}
>
<UnjoinedResource
baseUrl={baseUrl}
association={association}
/>
{popovers(routeProps, resourceUrl)}
</Skeleton>
</>
);
}}
/>
<Route
path={relativePath('/pending/:ship/:name')}
render={(routeProps) => {
const { ship, name } = routeProps.match.params as Record<string, string>;
const desc = {
group: `/ship/${ship}/${name}`,
kind: 'graph' as const
};
return (
<Skeleton
{...props}
mobileHide={shouldHideSidebar}
mobileHide
recentGroups={recentGroups}
{...props}
baseUrl={baseUrl}
>
{ workspace.type === 'group' ? (
<GroupHome
baseUrl={baseUrl}
groupPath={groupPath}
/>
) : (
<EmptyGroupHome
associations={associations}
/>
)}
<Box overflow="auto">
<Join desc={desc} />
</Box>
</Skeleton>
);
}}
/>
<Route
path={relativePath('/new')}
render={(routeProps) => {
return (
<Skeleton mobileHide recentGroups={recentGroups} {...props} baseUrl={baseUrl}>
<NewChannel
{...routeProps}
baseUrl={baseUrl}
group={groupPath}
workspace={workspace}
/>
{popovers(routeProps, baseUrl)}
</Skeleton>
</>
);
}}
/>
</Switch>
);
}}
/>
<Route
path={[relativePath('/'), relativePath('/feed+')]}
render={(routeProps) => {
const shouldHideSidebar =
routeProps.location.pathname.includes('/feed');
const title = groupAssociation?.metadata?.title ?? 'Groups';
return (
<>
<Helmet defer={false}>
<title>
{notificationsCount ? `(${String(notificationsCount)}) ` : ''}
{ title }
</title>
</Helmet>
<Skeleton
{...props}
mobileHide={shouldHideSidebar}
recentGroups={recentGroups}
baseUrl={baseUrl}
>
{ workspace.type === 'group' ? (
<GroupHome
baseUrl={baseUrl}
groupPath={groupPath}
/>
) : (
<EmptyGroupHome
associations={associations}
/>
)}
{popovers(routeProps, baseUrl)}
</Skeleton>
</>
);
}}
/>
</Switch>
);
}

View File

@ -70,7 +70,7 @@ export function NewChannel(props: NewChannelProps): ReactElement {
const onSubmit = async (values: FormSchema, actions) => {
const name = channelName(values);
const resId: string =
stringToSymbol(values.name) +
await stringToSymbol(values.name) +
(workspace?.type !== 'messages'
? `-${Math.floor(Math.random() * 10000)}`
: '');

View File

@ -45,7 +45,7 @@ export function NewGroup(): ReactElement {
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
try {
const { title, description, isPrivate } = values;
const name = stringToSymbol(title.trim());
const name = await stringToSymbol(title.trim());
const policy: Enc<GroupPolicy> = isPrivate
? {
invite: {

View File

@ -385,6 +385,14 @@ export const addDmMessage = (our: PatpNoSig, ship: Patp, contents: Content[]): P
const encodeIndex = (idx: string) => idx.split('/').map(decToUd).join('/');
/**
* Fetch all graph keys
*/
export const getKeys = (): Scry => ({
app: 'graph-store',
path: '/keys'
});
/**
* Fetch newest (larger keys) nodes in a graph under some index
*