Merge pull request #5014 from urbit/lf/perf-sweep

interface: general performance sweep
This commit is contained in:
L 2021-06-15 13:09:54 -05:00 committed by GitHub
commit df45d21128
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 253 additions and 229 deletions

View File

@ -1,7 +1,7 @@
import Urbit from '@urbit/http-api';
const api = new Urbit('', '');
api.ship = window.ship;
api.verbose = true;
// api.verbose = true;
// @ts-ignore TODO window typings
window.api = api;

View File

@ -1,4 +1,4 @@
import { DragEvent, useCallback, useEffect, useState } from 'react';
import { DragEvent, useCallback, useEffect, useState, useMemo } from 'react';
function validateDragEvent(e: DragEvent): FileList | File[] | true | null {
const files: File[] = [];
@ -43,7 +43,7 @@ export function useFileDrag(dragged: (f: FileList | File[], e: DragEvent) => voi
}
setDragging(true);
},
[setDragging]
[]
);
const onDrop = useCallback(
@ -56,7 +56,7 @@ export function useFileDrag(dragged: (f: FileList | File[], e: DragEvent) => voi
e.preventDefault();
dragged(files, e);
},
[setDragging, dragged]
[dragged]
);
const onDragOver = useCallback(
@ -77,7 +77,7 @@ export function useFileDrag(dragged: (f: FileList | File[], e: DragEvent) => voi
setDragging(false);
}
},
[setDragging]
[]
);
useEffect(() => {
@ -92,12 +92,12 @@ export function useFileDrag(dragged: (f: FileList | File[], e: DragEvent) => voi
};
}, []);
const bind = {
const bind = useMemo(() => ({
onDragLeave,
onDragOver,
onDrop,
onDragEnter
};
}), [onDragEnter, onDragOver, onDrop, onDragEnter]);
return { bind, dragging };
return useMemo(() => ({ bind, dragging }), [bind, dragging]);
}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useMemo, useEffect, useState } from 'react';
function retrieve<T>(key: string, initial: T): T {
const s = localStorage.getItem(key);
@ -12,26 +12,16 @@ function retrieve<T>(key: string, initial: T): T {
return initial;
}
interface SetStateFunc<T> {
(t: T): T;
}
// See microsoft/typescript#37663 for filed bug
type SetState<T> = T extends any ? SetStateFunc<T> : never;
export function useLocalStorageState<T>(key: string, initial: T): any {
const [state, _setState] = useState(() => retrieve(key, initial));
const [state, setState] = useState(() => retrieve(key, initial));
useEffect(() => {
_setState(retrieve(key, initial));
setState(retrieve(key, initial));
}, [key]);
const setState = useCallback(
(s: SetState<T>) => {
const updated = typeof s === 'function' ? s(state) : s;
_setState(updated);
localStorage.setItem(key, JSON.stringify(updated));
},
[_setState, key, state]
);
useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [state]);
return [state, setState] as const;
return useMemo(() => [state, setState] as const, [state, setState]);
}

View File

@ -523,3 +523,19 @@ export const favicon = () => {
});
return svg;
};
export function binaryIndexOf(arr: BigInteger[], target: BigInteger): number | undefined {
let leftBound = 0;
let rightBound = arr.length - 1;
while(leftBound <= rightBound) {
const halfway = Math.floor((leftBound + rightBound) / 2);
if(arr[halfway].greater(target)) {
leftBound = halfway + 1;
} else if (arr[halfway].lesser(target)) {
rightBound = halfway - 1;
} else {
return halfway;
}
}
return undefined;
}

View File

@ -1,5 +1,6 @@
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import { patp2dec } from 'urbit-ob';
import shallow from 'zustand/shallow';
import { Association, deSig, GraphNode, Graphs, FlatGraphs, resourceFromPath, ThreadGraphs, getGraph, getShallowChildren } from '@urbit/api';
import { useCallback } from 'react';
@ -275,7 +276,11 @@ export function useGraphTimesentMap(ship: string, name: string) {
useCallback(s => s.graphTimesentMap[`${deSig(ship)}/${name}`], [ship, name])
);
}
const emptyObject = {};
export function useGraphTimesent(key: string) {
return useGraphState(useCallback(s => s.graphTimesentMap[key] || emptyObject, [key]), shallow);
}
export function useGraphForAssoc(association: Association) {
const { resource } = association;
const { ship, name } = resourceFromPath(resource);

View File

@ -0,0 +1,8 @@
import { useOsDark } from './local';
import { useTheme } from './settings';
export function useDark() {
const osDark = useOsDark();
const theme = useTheme();
return theme === 'dark' || (osDark && theme === 'auto');
}

View File

@ -130,4 +130,9 @@ function withLocalState<P, S extends keyof LocalState, C extends React.Component
});
}
const selOsDark = (s: LocalState) => s.dark;
export function useOsDark() {
return useLocalState(selOsDark);
}
export { useLocalState as default, withLocalState };

View File

@ -39,9 +39,9 @@ const useMetadataState = createState<MetadataState>(
if('metadata-hook-update' in preview) {
const newState = get();
newState.set((s) => {
s.previews[group] = preview['metadata-hook-update'];
s.previews[group] = preview['metadata-hook-update'].preview;
});
return preview['metadata-hook-update'];
return preview['metadata-hook-update'].preview;
} else {
throw 'no-permissions';
}
@ -82,9 +82,11 @@ export function useAssocForGroup(group: string) {
);
}
const selPreview = (s: MetadataState) => [s.previews, s.getPreview] as const;
export function usePreview(group: string) {
const [error, setError] = useState(null);
const [previews, getPreview] = useMetadataState(s => [s.previews, s.getPreview]);
const [previews, getPreview] = useMetadataState(selPreview);
useEffect(() => {
let mounted = true;
(async () => {
@ -100,7 +102,7 @@ export function usePreview(group: string) {
return () => {
mounted = false;
};
});
}, [group]);
const preview = previews[group];

View File

@ -124,4 +124,10 @@ export function useShortcut<T extends keyof ShortcutMapping>(
return usePlainShortcut(key, cb);
}
const selTheme = (s: SettingsState) => s.display.theme;
export function useTheme() {
return useSettingsState(selTheme);
}
export default useSettingsState;

View File

@ -1,6 +1,7 @@
import dark from '@tlon/indigo-dark';
import light from '@tlon/indigo-light';
import Mousetrap from 'mousetrap';
import shallow from 'zustand/shallow';
import 'mousetrap-global-bind';
import * as React from 'react';
import Helmet from 'react-helmet';
@ -12,7 +13,6 @@ import gcpManager from '~/logic/lib/gcpManager';
import { favicon, svgDataURL } from '~/logic/lib/util';
import withState from '~/logic/lib/withState';
import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group';
import useLocalState from '~/logic/state/local';
import useSettingsState from '~/logic/state/settings';
import useGraphState from '~/logic/state/graph';
@ -131,7 +131,7 @@ class App extends React.Component {
render() {
const theme = this.getTheme();
const ourContact = this.props.contacts[`~${this.ship}`] || null;
const { ourContact } = this.props;
return (
<ThemeProvider theme={theme}>
<ShortcutContextProvider>
@ -173,12 +173,38 @@ class App extends React.Component {
);
}
}
const WarmApp = process.env.NODE_ENV === 'production' ? App : hot(App);
const selContacts = s => s.contacts[`~${window.ship}`];
const selLocal = s => [s.set, s.omniboxShown, s.toggleOmnibox];
const selSettings = s => [s.display, s.getAll];
const selGraph = s => s.getShallowChildren;
const selLaunch = s => [s.getRuntimeLag, s.getBaseHash];
const WithApp = React.forwardRef((props, ref) => {
const ourContact = useContactState(selContacts);
const [display, getAll] = useSettingsState(selSettings, shallow);
const [setLocal, omniboxShown, toggleOmnibox] = useLocalState(selLocal);
const getShallowChildren = useGraphState(selGraph);
const [getRuntimeLag, getBaseHash] = useLaunchState(selLaunch, shallow);
return (
<WarmApp
ref={ref}
ourContact={ourContact}
display={display}
getAll={getAll}
set={setLocal}
getShallowChildren={getShallowChildren}
getRuntimeLag={getRuntimeLag}
getBaseHash={getBaseHash}
toggleOmnibox={toggleOmnibox}
omniboxShown={omniboxShown}
/>
);
});
WarmApp.whyDidYouRender = true;
export default WithApp;
export default withState(process.env.NODE_ENV === 'production' ? App : hot(App), [
[useGroupState],
[useContactState],
[useSettingsState, ['display', 'getAll']],
[useLocalState],
[useGraphState, ['getShallowChildren']],
[useLaunchState, ['getRuntimeLag', 'getBaseHash']]
]);

View File

@ -86,7 +86,7 @@ const ChatResource = (props: ChatResourceProps): ReactElement => {
);
return `${url}\n~${msg.author}: `;
},
[association]
[association.resource]
);
const isAdmin = useMemo(
@ -107,7 +107,7 @@ const ChatResource = (props: ChatResourceProps): ReactElement => {
if (newer) {
const index = graph.peekLargest()?.[0];
if (!index) {
return true;
return false;
}
await getYoungerSiblings(
ship,
@ -119,10 +119,12 @@ const ChatResource = (props: ChatResourceProps): ReactElement => {
} else {
const index = graph.peekSmallest()?.[0];
if (!index) {
return true;
return false;
}
await getOlderSiblings(ship, name, pageSize, `/${index.toString()}`);
const done = expectedSize !== getCurrGraphSize(ship.slice(1), name);
const currSize = getCurrGraphSize(ship.slice(1), name);
console.log(currSize);
const done = expectedSize !== currSize;
return done;
}
}, [graph, resource]);
@ -144,7 +146,7 @@ const ChatResource = (props: ChatResourceProps): ReactElement => {
const getPermalink = useCallback(
(index: BigInteger) =>
getPermalinkForGraph(association.group, resource, `/${index.toString()}`),
[association]
[association.resource]
);
if (!graph) {

View File

@ -16,7 +16,7 @@ import {
cite, daToUnix, useHovering, useShowNickname, uxToHex
} from '~/logic/lib/util';
import { useContact } from '~/logic/state/contact';
import useLocalState from '~/logic/state/local';
import { useDark } from '~/logic/state/join';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
import { Dropdown } from '~/views/components/Dropdown';
import ProfileOverlay from '~/views/components/ProfileOverlay';
@ -53,16 +53,13 @@ export const DayBreak = ({ when, shimTop = false }: DayBreakProps) => (
</Row>
);
export const MessageAuthor = ({
export const MessageAuthor = React.memo<any>(({
timestamp,
msg,
showOurContact,
...props
}) => {
const osDark = useLocalState(state => state.dark);
const theme = useSettingsState(s => s.display.theme);
const dark = theme === 'dark' || (theme === 'auto' && osDark);
const dark = useDark();
let contact: Contact | null = useContact(`~${msg.author}`);
const date = daToUnix(bigInt(msg.index.split('/').reverse()[0]));
@ -177,7 +174,8 @@ export const MessageAuthor = ({
</Box>
</Box>
);
};
});
MessageAuthor.displayName = 'MessageAuthor';
type MessageProps = { timestamp: string; timestampHover: boolean; }
& Pick<ChatMessageProps, 'msg' | 'transcluded' | 'showOurContact'>
@ -392,6 +390,7 @@ interface ChatMessageProps {
showOurContact: boolean;
onDelete?: () => void;
}
const emptyCallback = () => {};
function ChatMessage(props: ChatMessageProps) {
let { highlighted } = props;
@ -416,10 +415,10 @@ function ChatMessage(props: ChatMessageProps) {
);
}
const onReply = props?.onReply ?? (() => {});
const onDelete = props?.onDelete ?? (() => {});
const transcluded = props?.transcluded ?? 0;
const renderSigil = props.renderSigil ?? (Boolean(nextMsg && msg.author !== nextMsg.author) ||
const onReply = props?.onReply || emptyCallback;
const onDelete = props?.onDelete || emptyCallback;
const transcluded = props?.transcluded || 0;
const renderSigil = props.renderSigil || (Boolean(nextMsg && msg.author !== nextMsg.author) ||
!nextMsg
);
@ -509,9 +508,9 @@ function ChatMessage(props: ChatMessageProps) {
);
}
export default React.forwardRef((props: Omit<ChatMessageProps, 'innerRef'>, ref: any) => (
export default React.memo(React.forwardRef((props: Omit<ChatMessageProps, 'innerRef'>, ref: any) => (
<ChatMessage {...props} innerRef={ref} />
));
)));
export const MessagePlaceholder = ({
height,

View File

@ -6,7 +6,7 @@ import React, { ReactElement, useCallback, useEffect, useRef, useState } from 'r
import { useFileDrag } from '~/logic/lib/useDrag';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import { useOurContact } from '~/logic/state/contact';
import useGraphState from '~/logic/state/graph';
import { useGraphTimesent } from '~/logic/state/graph';
import ShareProfile from '~/views/apps/chat/components/ShareProfile';
import { Loading } from '~/views/components/Loading';
import SubmitDragger from '~/views/components/SubmitDragger';
@ -77,7 +77,7 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
promptShare = [],
fetchMessages
} = props;
const graphTimesentMap = useGraphState(state => state.graphTimesentMap);
const graphTimesentMap = useGraphTimesent(id);
const ourContact = useOurContact();
const chatInput = useRef<NakedChatInput>();
@ -88,7 +88,7 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
}
(chatInput.current as NakedChatInput)?.uploadFiles(files);
},
[chatInput.current]
[chatInput]
);
const { bind, dragging } = useFileDrag(onFileDrag);
@ -147,7 +147,7 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
graphSize={graph.size}
unreadCount={unreadCount}
showOurContact={promptShare.length === 0 && !showBanner}
pendingSize={Object.keys(graphTimesentMap[id] || {}).length}
pendingSize={Object.keys(graphTimesentMap).length}
onReply={onReply}
onDelete={onDelete}
dismissUnread={dismissUnread}
@ -170,3 +170,5 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
</Col>
);
}
ChatPane.whyDidYouRender = true;

View File

@ -6,7 +6,7 @@ import {
import bigInt, { BigInteger } from 'big-integer';
import React, { Component } from 'react';
import VirtualScroller from '~/views/components/VirtualScroller';
import ChatMessage, { MessagePlaceholder } from './ChatMessage';
import ChatMessage from './ChatMessage';
import UnreadNotice from './UnreadNotice';
const IDLE_THRESHOLD = 64;
@ -57,7 +57,7 @@ class ChatWindow extends Component<
this.state = {
fetchPending: false,
idle: true,
initialized: false,
initialized: true,
unreadIndex: bigInt.zero
};
@ -72,14 +72,10 @@ class ChatWindow extends Component<
componentDidMount() {
this.calculateUnreadIndex();
setTimeout(() => {
this.setState({ initialized: true }, () => {
if(this.props.scrollTo) {
this.virtualList!.scrollLocked = false;
this.virtualList!.scrollToIndex(this.props.scrollTo);
}
});
}, this.INITIALIZATION_MAX_TIME);
if(this.props.scrollTo) {
this.virtualList!.scrollLocked = false;
this.virtualList!.scrollToIndex(this.props.scrollTo);
}
}
calculateUnreadIndex() {
@ -205,15 +201,6 @@ class ChatWindow extends Component<
</Text>
);
}
if (!this.state.initialized) {
return (
<MessagePlaceholder
key={index.toString()}
height='64px'
index={index}
/>
);
}
const isPending: boolean = 'pending' in msg && Boolean(msg.pending);
const isLastMessage = index.eq(
graph.peekLargest()?.[0] ?? bigInt.zero

View File

@ -1,5 +1,5 @@
import { Box, Col, Icon, Row, Text } from '@tlon/indigo-react';
import React, { ReactElement } from 'react';
import React, { ReactElement, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { useModal } from '~/logic/lib/useModal';
import useMetadataState, { usePreview } from '~/logic/state/metadata';
@ -15,9 +15,10 @@ export function GroupLink(
): ReactElement {
const { resource, ...rest } = props;
const name = resource.slice(6);
const associations = useMetadataState(state => state.associations);
const joined = useMetadataState(
useCallback(s => resource in s.associations.groups, [resource])
);
const history = useHistory();
const joined = resource in associations.groups;
const { modal, showModal } = useModal({
modal: <JoinGroup autojoin={name} />

View File

@ -9,6 +9,7 @@ import {
Text
} from '@tlon/indigo-react';
import { cite, uxToHex } from '@urbit/api';
import shallow from 'zustand/shallow';
import _ from 'lodash';
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';
@ -20,7 +21,7 @@ import { useCopy } from '~/logic/lib/useCopy';
import { useOutsideClick } from '~/logic/lib/useOutsideClick';
import { useShowNickname } from '~/logic/lib/util';
import { useContact } from '~/logic/state/contact';
import useSettingsState from '~/logic/state/settings';
import useSettingsState, { SettingsState } from '~/logic/state/settings';
import { Portal } from './Portal';
import { ProfileStatus } from './ProfileStatus';
import RichText from './RichText';
@ -40,6 +41,8 @@ type ProfileOverlayProps = BoxProps & {
color?: string;
};
const selSettings = (s: SettingsState) => [s.calm.hideAvatars, s.calm.hideNicknames];
const ProfileOverlay = (props: ProfileOverlayProps) => {
const {
ship,
@ -53,8 +56,7 @@ const ProfileOverlay = (props: ProfileOverlayProps) => {
const history = useHistory();
const outerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const hideAvatars = useSettingsState(state => state.calm.hideAvatars);
const hideNicknames = useSettingsState(state => state.calm.hideNicknames);
const [hideAvatars, hideNicknames] = useSettingsState(selSettings, shallow);
const isOwn = useMemo(() => window.ship === ship, [ship]);
const { copyDisplay, doCopy, didCopy } = useCopy(`~${ship}`);
@ -128,7 +130,7 @@ const ProfileOverlay = (props: ProfileOverlayProps) => {
return (
<Box ref={outerRef} {...rest} onClick={setOpen} cursor="pointer">
<VisibilitySensor onChange={setVisible}>
<VisibilitySensor active={open} onChange={setVisible}>
{children}
</VisibilitySensor>
{ open && (

View File

@ -47,6 +47,7 @@ class RemoteContent extends Component<RemoteContentProps, RemoteContentState> {
private fetchController: AbortController | undefined;
containerRef: HTMLDivElement | null = null;
private saving = false;
private isOembed = false;
constructor(props) {
super(props);
this.state = {
@ -60,6 +61,7 @@ class RemoteContent extends Component<RemoteContentProps, RemoteContentState> {
this.wrapInLink = this.wrapInLink.bind(this);
this.onError = this.onError.bind(this);
this.toggleArrow = this.toggleArrow.bind(this);
this.isOembed = hasProvider(props.url);
}
save = () => {
@ -204,7 +206,6 @@ return;
const isImage = IMAGE_REGEX.test(url);
const isAudio = AUDIO_REGEX.test(url);
const isVideo = VIDEO_REGEX.test(url);
const isOembed = hasProvider(url);
const isTranscluded = () => {
return transcluded;
@ -315,7 +316,7 @@ return;
: null}
</>
);
} else if (isOembed && remoteContentPolicy.oembedShown) {
} else if (this.isOembed && remoteContentPolicy.oembedShown) {
if (!this.state.embed || this.state.embed?.html === '') {
this.loadOembed();
}

View File

@ -37,7 +37,6 @@ export const Content = (props) => {
history.goBack();
}, [history.goBack]));
const [hasProtocol, setHasProtocol] = useLocalStorageState(
'registeredProtocol', false
);
@ -78,16 +77,9 @@ export const Content = (props) => {
/>
)}
/>
<Route
path='/~landscape'
render={p => (
<Landscape
location={p.location}
match={p.match}
{...props}
/>
)}
/>
<Route path='/~landscape'>
<Landscape />
</Route>
<Route
path="/~profile"
render={ p => (

View File

@ -121,7 +121,6 @@ function stitchInline(a: any, b: any) {
}
const lastParaIdx = a.children.length - 1;
const last = a.children[lastParaIdx];
console.log(last);
if (last?.children) {
const ros = {
...a,

View File

@ -1,4 +1,4 @@
import { AppName, readGroup } from '@urbit/api';
import { readGroup } from '@urbit/api';
import _ from 'lodash';
import React, { useCallback, useEffect } from 'react';
import Helmet from 'react-helmet';
@ -27,10 +27,10 @@ import { Resource } from './Resource';
import { Skeleton } from './Skeleton';
import airlock from '~/logic/api';
type GroupsPaneProps = {
interface GroupsPaneProps {
baseUrl: string;
workspace: Workspace;
};
}
export function GroupsPane(props: GroupsPaneProps) {
const { baseUrl, workspace } = props;
@ -114,8 +114,6 @@ export function GroupsPane(props: GroupsPaneProps) {
string
>;
const appName = app as AppName;
const resource = `/ship/${host}/${name}`;
const association = associations.graph[resource];
const resourceUrl = `${baseUrl}/resource/${app}${resource}`;
@ -129,12 +127,11 @@ export function GroupsPane(props: GroupsPaneProps) {
mobileHide
recentGroups={recentGroups}
selected={resource}
selectedApp={appName}
{...props}
baseUrl={resourceUrl}
>
<Resource
{...props}
workspace={props.workspace}
association={association}
baseUrl={baseUrl}
/>

View File

@ -13,11 +13,11 @@ import { PublishResource } from '~/views/apps/publish/PublishResource';
import { ChannelPopoverRoutes } from './ChannelPopoverRoutes';
import { ResourceSkeleton } from './ResourceSkeleton';
type ResourceProps = {
interface ResourceProps {
association: Association;
baseUrl: string;
workspace: Workspace;
};
}
export function Resource(props: ResourceProps): ReactElement {
const { association } = props;

View File

@ -106,7 +106,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
canWrite = isOwn;
}
const BackLink = () => (
const backLink = (
<Box
borderRight={1}
borderRightColor='gray'
@ -123,7 +123,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
</Box>
);
const Title = () => (
const titleText = (
<Text
mono={urbitOb.isValidPatp(title)}
fontSize={2}
@ -143,7 +143,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
</Text>
);
const Description = () => (
const description = (
<TruncatedText
display={['none','inline']}
mono={workspace === '/messages' && !association?.metadata?.description}
@ -160,9 +160,8 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
</TruncatedText>
);
const ExtraControls = () => {
if (workspace === '/messages' && isOwn && !resource.startsWith('dm-')) {
return (
const extraControls =
(workspace === '/messages' && isOwn && !resource.startsWith('dm-')) ? (
<Dropdown
flexShrink={0}
dropWidth='300px'
@ -186,21 +185,15 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
+ Add Ship
</Text>
</Dropdown>
);
}
if (canWrite) {
return (
) : canWrite ? (
<Link to={resourcePath('/new')}>
<Text bold pr='3' color='blue'>
+ New Post
</Text>
</Link>
);
}
return null;
};
) : null;
const MenuControl = () => (
const menuControl = (
<Link to={`${baseUrl}/settings`}>
<Icon icon='Menu' color='gray' pr={2} />
</Link>
@ -229,9 +222,9 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
width={`calc(100% - ${actionsWidth}px - 16px)`}
flexShrink={0}
>
<BackLink />
<Title />
<Description />
{backLink}
{titleText}
{description}
</Box>
<Box
ml={3}
@ -240,8 +233,8 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
flexShrink={0}
ref={actionsRef}
>
{ExtraControls()}
<MenuControl />
{extraControls}
{menuControl}
</Box>
</Box>
{children}

View File

@ -12,7 +12,7 @@ import { useTutorialModal } from '~/views/components/useTutorialModal';
import { GroupSwitcher } from '../GroupSwitcher';
import { SidebarList } from './SidebarList';
import { SidebarListHeader } from './SidebarListHeader';
import { SidebarAppConfigs, SidebarListConfig } from './types';
import { SidebarListConfig } from './types';
const ScrollbarLessCol = styled(Col)`
scrollbar-width: none !important;
@ -25,8 +25,6 @@ const ScrollbarLessCol = styled(Col)`
interface SidebarProps {
recentGroups: string[];
selected?: string;
selectedGroup?: string;
apps: SidebarAppConfigs;
baseUrl: string;
mobileHide?: boolean;
workspace: Workspace;
@ -84,7 +82,6 @@ export function Sidebar(props: SidebarProps): ReactElement | null {
config={config}
selected={selected}
group={groupPath}
apps={props.apps}
baseUrl={props.baseUrl}
workspace={workspace}
/>

View File

@ -12,9 +12,30 @@ import useContactState, { useContact } from '~/logic/state/contact';
import { getItemTitle, getModuleIcon, uxToHex } from '~/logic/lib/util';
import useGroupState from '~/logic/state/group';
import Dot from '~/views/components/Dot';
import { SidebarAppConfigs } from './types';
import { useHarkDm } from '~/logic/state/hark';
import useHarkState, { useHarkDm } from '~/logic/state/hark';
import useSettingsState from '~/logic/state/settings';
import useGraphState from '~/logic/state/graph';
function useAssociationStatus(resource: string) {
const [, , ship, name] = resource.split('/');
const graphKey = `${ship.slice(1)}/${name}`;
const isSubscribed = useGraphState(s => s.graphKeys.has(graphKey));
const { unreads, notifications } = useHarkState(
s => s.unreads.graph?.[resource]?.['/'] || { unreads: 0, notifications: 0, last: 0 }
);
const hasNotifications =
(typeof notifications === 'number' && notifications > 0) ||
(typeof notifications === 'object' && notifications.length);
const hasUnread =
typeof unreads === 'number' ? unreads > 0 : unreads?.size ?? 0 > 0;
return hasNotifications
? 'notification'
: hasUnread
? 'unread'
: isSubscribed
? undefined
: 'unsubscribed';
}
function SidebarItemBase(props: {
to: string;
@ -36,7 +57,11 @@ function SidebarItemBase(props: {
isSynced = false,
mono = false
} = props;
const color = isSynced ? (hasUnread || hasNotification) ? 'black' : 'gray' : 'lightGray';
const color = isSynced
? hasUnread || hasNotification
? 'black'
: 'gray'
: 'lightGray';
const fontWeight = hasUnread || hasNotification ? '500' : 'normal';
@ -95,17 +120,18 @@ function SidebarItemBase(props: {
);
}
export function SidebarDmItem(props: {
export const SidebarDmItem = React.memo((props: {
ship: string;
selected?: boolean;
workspace: Workspace;
}) {
}) => {
const { ship, selected = false } = props;
const contact = useContact(ship);
const { hideAvatars, hideNicknames } = useSettingsState(s => s.calm);
const title = (!hideNicknames && contact?.nickname)
? contact?.nickname
: (cite(ship) ?? ship);
const { hideAvatars, hideNicknames } = useSettingsState(s => s.calm);
const title =
!hideNicknames && contact?.nickname
? contact?.nickname
: cite(ship) ?? ship;
const { unreads } = useHarkDm(ship) || { unreads: 0 };
const img =
contact?.avatar && !hideAvatars ? (
@ -139,17 +165,15 @@ export function SidebarDmItem(props: {
{img}
</SidebarItemBase>
);
}
});
// eslint-disable-next-line max-lines-per-function
export function SidebarAssociationItem(props: {
export const SidebarAssociationItem = React.memo((props: {
hideUnjoined: boolean;
association: Association;
path: string;
selected: boolean;
apps: SidebarAppConfigs;
workspace: Workspace;
}) {
const { association, path, selected, apps } = props;
}) => {
const { association, selected } = props;
const title = getItemTitle(association) || '';
const appName = association?.['app-name'];
let mod = appName;
@ -158,7 +182,7 @@ export function SidebarAssociationItem(props: {
}
const rid = association?.resource;
const groupPath = association?.group;
const groups = useGroupState(state => state.groups);
const group = useGroupState(state => state.groups[groupPath]);
const { hideNicknames } = useSettingsState(s => s.calm);
const contacts = useContactState(s => s.contacts);
const anchorRef = useRef<HTMLAnchorElement>(null);
@ -167,13 +191,9 @@ export function SidebarAssociationItem(props: {
groupPath === `/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`,
anchorRef
);
const app = apps[appName];
const isUnmanaged = groups?.[groupPath]?.hidden || false;
if (!app) {
return null;
}
const isUnmanaged = group?.hidden || false;
const DM = isUnmanaged && props.workspace?.type === 'messages';
const itemStatus = app.getStatus(path);
const itemStatus = useAssociationStatus(rid);
const hasNotification = itemStatus === 'notification';
const hasUnread = itemStatus === 'unread';
const isSynced = itemStatus !== 'unsubscribed';
@ -194,7 +214,11 @@ export function SidebarAssociationItem(props: {
}
const participantNames = (str: string) => {
const color = isSynced ? (hasUnread || hasNotification) ? 'black' : 'gray' : 'lightGray';
const color = isSynced
? hasUnread || hasNotification
? 'black'
: 'gray'
: 'lightGray';
if (_.includes(str, ',') && _.startsWith(str, '~')) {
const names = _.split(str, ', ');
return names.map((name, idx) => {
@ -209,9 +233,7 @@ export function SidebarAssociationItem(props: {
return (
<Text key={name} mono bold={hasUnread} color={color}>
{name}
<Text color={color}>
{idx + 1 != names.length ? ', ' : null}
</Text>
<Text color={color}>{idx + 1 != names.length ? ', ' : null}</Text>
</Text>
);
} else {
@ -230,9 +252,7 @@ export function SidebarAssociationItem(props: {
hasUnread={hasUnread}
isSynced={isSynced}
title={
DM && !urbitOb.isValidPatp(title)
? participantNames(title)
: title
DM && !urbitOb.isValidPatp(title) ? participantNames(title) : title
}
hasNotification={hasNotification}
>
@ -255,4 +275,4 @@ export function SidebarAssociationItem(props: {
)}
</SidebarItemBase>
);
}
});

View File

@ -6,7 +6,7 @@ import { SidebarAssociationItem, SidebarDmItem } from './SidebarItem';
import useGraphState, { useInbox } from '~/logic/state/graph';
import useHarkState from '~/logic/state/hark';
import { alphabeticalOrder, getResourcePath, modulo } from '~/logic/lib/util';
import { SidebarAppConfigs, SidebarListConfig, SidebarSort } from './types';
import { SidebarListConfig, SidebarSort } from './types';
import { Workspace } from '~/types/workspace';
import useMetadataState from '~/logic/state/metadata';
import { useHistory } from 'react-router';
@ -14,8 +14,7 @@ import { useShortcut } from '~/logic/state/settings';
function sidebarSort(
associations: AppAssociations,
apps: SidebarAppConfigs,
inboxUnreads: Record<string, UnreadStats>
unreads: Record<string, Record<string, UnreadStats>>
): Record<SidebarSort, (a: string, b: string) => number> {
const alphabetical = (a: string, b: string) => {
const aAssoc = associations[a];
@ -29,16 +28,16 @@ function sidebarSort(
const lastUpdated = (a: string, b: string) => {
const aAssoc = associations[a];
const bAssoc = associations[b];
const aAppName = aAssoc?.['app-name'];
const bAppName = bAssoc?.['app-name'];
const aResource = aAssoc.resource;
const bResource = bAssoc.resource;
const aUpdated = a.startsWith('~')
? (inboxUnreads?.[`/${patp2dec(a)}`]?.last || 0)
: (apps[aAppName]?.lastUpdated(a) || 0);
? (unreads?.[`/ship/~${window.ship}/dm-inbox`]?.[`/${patp2dec(a)}`]?.last || 0)
: ((unreads?.[aResource]?.['/']?.last) || 0);
const bUpdated = b.startsWith('~')
? (inboxUnreads?.[`/${patp2dec(b)}`]?.last || 0)
: (apps[bAppName]?.lastUpdated(b) || 0);
? (unreads?.[`/ship/~${window.ship}/dm-inbox`]?.[`/${patp2dec(b)}`]?.last || 0)
: ((unreads?.[bResource]?.['/']?.last) || 0);
return bUpdated - aUpdated || alphabetical(a, b);
};
@ -86,7 +85,6 @@ function getItems(associations: Associations, workspace: Workspace, inbox: Graph
}
export function SidebarList(props: {
apps: SidebarAppConfigs;
config: SidebarListConfig;
baseUrl: string;
group?: string;
@ -96,11 +94,11 @@ export function SidebarList(props: {
const { selected, config, workspace } = props;
const associations = useMetadataState(state => state.associations);
const inbox = useInbox();
const unreads = useHarkState(s => s.unreads.graph?.[`/ship/~${window.ship}/dm-inbox`]);
const unreads = useHarkState(s => s.unreads.graph);
const graphKeys = useGraphState(s => s.graphKeys);
const ordered = getItems(associations, workspace, inbox)
.sort(sidebarSort(associations.graph, props.apps, unreads)[config.sortBy]);
.sort(sidebarSort(associations.graph, unreads)[config.sortBy]);
const history = useHistory();
@ -139,10 +137,8 @@ export function SidebarList(props: {
return pathOrShip.startsWith('/') ? (
<SidebarAssociationItem
key={pathOrShip}
path={pathOrShip}
selected={pathOrShip === selected}
association={associations.graph[pathOrShip]}
apps={props.apps}
hideUnjoined={config.hideUnjoined}
workspace={workspace}
/>

View File

@ -1,39 +1,24 @@
import React, { ReactElement, ReactNode, useCallback, useMemo, useState } from 'react';
import React, { ReactElement, ReactNode, useCallback, useState } from 'react';
import { Sidebar } from './Sidebar/Sidebar';
import { AppName } from '@urbit/api';
import useGraphState from '~/logic/state/graph';
import useHarkState from '~/logic/state/hark';
import { Workspace } from '~/types/workspace';
import { Body } from '~/views/components/Body';
import ErrorBoundary from '~/views/components/ErrorBoundary';
import { useShortcut } from '~/logic/state/settings';
import { useGraphModule } from './Sidebar/Apps';
interface SkeletonProps {
children: ReactNode;
recentGroups: string[];
selected?: string;
selectedApp?: AppName;
baseUrl: string;
mobileHide?: boolean;
workspace: Workspace;
}
export function Skeleton(props: SkeletonProps): ReactElement {
export const Skeleton = React.memo((props: SkeletonProps): ReactElement => {
const [sidebar, setSidebar] = useState(true);
useShortcut('hideSidebar', useCallback(() => {
setSidebar(s => !s);
}, []));
const graphs = useGraphState(state => state.graphs);
const graphKeys = useGraphState(state => state.graphKeys);
const unreads = useHarkState(state => state.unreads);
const graphConfig = useGraphModule(graphKeys, graphs, unreads.graph);
const config = useMemo(
() => ({
graph: graphConfig
}),
[graphConfig]
);
return !sidebar ? (<Body> {props.children} </Body>) : (
<Body
@ -47,7 +32,6 @@ export function Skeleton(props: SkeletonProps): ReactElement {
<Sidebar
recentGroups={props.recentGroups}
selected={props.selected}
apps={config}
baseUrl={props.baseUrl}
mobileHide={props.mobileHide}
workspace={props.workspace}
@ -56,4 +40,4 @@ export function Skeleton(props: SkeletonProps): ReactElement {
{props.children}
</Body>
);
}
});

View File

@ -10,6 +10,7 @@ import { GroupsPane } from './components/GroupsPane';
import { JoinGroup } from './components/JoinGroup';
import { NewGroup } from './components/NewGroup';
import './css/custom.css';
import _ from 'lodash';
moment.updateLocale('en', {
relativeTime : {
@ -32,7 +33,12 @@ moment.updateLocale('en', {
}
});
export default function Landscape(props) {
const makeGroupWorkspace = _.memoize((group: string): Workspace => ({ type: 'group', group }));
const homeWorkspace: Workspace = { type: 'home' };
const messagesWorkspace: Workspace = { type: 'messages' };
export default function Landscape() {
const notificationsCount = useHarkState(s => s.notificationsCount);
return (
@ -49,40 +55,26 @@ export default function Landscape(props) {
} = routeProps.match.params as Record<string, string>;
const groupPath = `/ship/${host}/${name}`;
const baseUrl = `/~landscape${groupPath}`;
const ws: Workspace = { type: 'group', group: groupPath };
const ws: Workspace = makeGroupWorkspace(groupPath);
return (
<GroupsPane workspace={ws} baseUrl={baseUrl} {...props} />
);
}}
/>
<Route path="/~landscape/home"
render={() => {
const ws: Workspace = { type: 'home' };
return (
<GroupsPane workspace={ws} baseUrl="/~landscape/home" {...props} />
);
}}
/>
<Route path="/~landscape/messages"
render={() => {
const ws: Workspace = { type: 'messages' };
return (
<GroupsPane workspace={ws} baseUrl="/~landscape/messages" {...props} />
);
}}
/>
<Route path="/~landscape/new"
render={() => {
return (
<Body>
<Box maxWidth="300px">
<NewGroup />
</Box>
</Body>
<GroupsPane workspace={ws} baseUrl={baseUrl} />
);
}}
/>
<Route path="/~landscape/home">
<GroupsPane workspace={homeWorkspace} baseUrl="/~landscape/home" />
</Route>
<Route path="/~landscape/messages">
<GroupsPane workspace={messagesWorkspace} baseUrl="/~landscape/messages" />
</Route>
<Route path="/~landscape/new">
<Body>
<Box maxWidth="300px">
<NewGroup />
</Box>
</Body>
</Route>
<Route path="/~landscape/join/:ship?/:name?"
render={(routeProps) => {
const { ship, name } = routeProps.match.params;
@ -103,4 +95,3 @@ export default function Landscape(props) {
</>
);
}

View File

@ -26,7 +26,10 @@ export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
}
get size() {
return Object.keys(this.root).length;
if(this.cachedIter) {
return this.cachedIter.length;
}
return this.generateCachedIter().length;
}