Merge pull request #5563 from urbit/hm/landscape-perf-optimization

groups: perf optimization
This commit is contained in:
Hunter Miller 2022-03-15 11:26:19 -05:00 committed by GitHub
commit c22bac9273
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 75412 additions and 72756 deletions

27990
pkg/grid/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ yarn-debug.log*
yarn-error.log*
*.swp
.DS_Store
stats.json
# Runtime data
pids

View File

@ -4,7 +4,6 @@ module.exports = {
'@babel/transform-runtime',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-proposal-optional-chaining',
'@babel/plugin-proposal-class-properties',
'react-hot-loader/babel'
'@babel/plugin-proposal-class-properties'
]
};

View File

@ -1,7 +1,7 @@
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
// const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const urbitrc = require('./urbitrc');
const _ = require('lodash');
const { execSync } = require('child_process');
@ -47,7 +47,7 @@ if(urbitrc.URL) {
module.exports = {
mode: 'development',
entry: {
app: './src/index.js'
app: './src/index.tsx'
// serviceworker: './src/serviceworker.js'
},
module: {
@ -59,15 +59,14 @@ module.exports = {
options: {
presets: ['@babel/preset-env', '@babel/typescript', ['@babel/preset-react', {
runtime: 'automatic',
development: true,
importSource: '@welldone-software/why-did-you-render'
development: true
}]],
plugins: [
'@babel/transform-runtime',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-proposal-optional-chaining',
'@babel/plugin-proposal-class-properties',
'react-hot-loader/babel'
process.env.NODE_ENV !== 'production' && 'react-refresh/babel'
]
}
},
@ -108,14 +107,15 @@ module.exports = {
new webpack.DefinePlugin({
'process.env.LANDSCAPE_SHORTHASH': JSON.stringify(GIT_DESC),
'process.env.LANDSCAPE_STORAGE_VERSION': JSON.stringify(Date.now()),
'process.env.LANDSCAPE_LAST_WIPE': JSON.stringify('2021-10-20'),
'process.env.LANDSCAPE_LAST_WIPE': JSON.stringify('2021-10-20')
}),
// new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Groups',
template: './public/index.html'
})
}),
process.env.NODE_ENV !== 'production' && new ReactRefreshWebpackPlugin()
],
watch: true,
output: {

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,7 @@
"@babel/preset-env": "^7.12.11",
"@babel/preset-react": "^7.12.10",
"@babel/preset-typescript": "^7.12.7",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
"@storybook/addon-actions": "^6.2.9",
"@storybook/addon-essentials": "^6.2.9",
"@storybook/addon-links": "^6.2.9",
@ -85,7 +86,6 @@
"@typescript-eslint/eslint-plugin": "^4.15.0",
"@typescript-eslint/parser": "^4.24.0",
"@urbit/eslint-config": "^1.0.0",
"@welldone-software/why-did-you-render": "^6.1.0",
"babel-eslint": "^10.1.0",
"babel-jest": "^26.6.3",
"babel-loader": "^8.2.2",
@ -103,7 +103,6 @@
"lint-staged": "^11.0.0",
"loki": "^0.28.1",
"moment-locales-webpack-plugin": "^1.2.0",
"react-hot-loader": "^4.13.0",
"sass": "^1.32.5",
"sass-loader": "^8.0.2",
"storybook-addon-designs": "^6.0.0",
@ -120,6 +119,7 @@
"tsc:watch": "tsc --watch",
"build:dev": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
"build:prod": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
"build:profile": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --json > stats.json",
"start": "webpack-dev-server --config config/webpack.dev.js",
"test": "tsc && jest",
"jest": "jest",

View File

@ -3,6 +3,5 @@ import * as ReactDOM from 'react-dom';
import './register-sw';
import './storage-wipe';
import App from './views/App';
import './wdyr';
ReactDOM.render(<App />, document.getElementById('root'));

View File

@ -1,51 +0,0 @@
import { deSig, Path, PatpNoSig, Group, Resource, roleTags, RoleTags } from '@urbit/api';
import _ from 'lodash';
export function roleForShip(
group: Group,
ship: PatpNoSig
): RoleTags | undefined {
return roleTags.reduce((currRole, role) => {
const roleShips = group?.tags?.role?.[role];
return roleShips && roleShips.has(ship) ? role : currRole;
}, undefined as RoleTags | undefined);
}
export function resourceFromPath(path: Path): Resource {
const [, , ship, name] = path.split('/');
return { ship, name };
}
export function makeResource(ship: string, name: string) {
return { ship, name };
}
export function isWriter(group: Group, resource: string) {
const writers: Set<string> | undefined = _.get(
group,
['tags', 'graph', resource, 'writers'],
undefined
);
const admins = group?.tags?.role?.admin ?? new Set();
if (_.isUndefined(writers)) {
return true;
} else {
return writers.has(window.ship) || admins.has(window.ship);
}
}
export function isChannelAdmin(group: Group, resource: string, ship = `~${window.ship}`) {
const role = roleForShip(group, deSig(ship));
return (
isHost(resource, ship) ||
role === 'admin' ||
role === 'moderator'
);
}
export function isHost(resource: string, ship = `~${window.ship}`) {
const [, , host] = resource.split('/');
return ship === host;
}

View File

@ -1,28 +1,11 @@
import {
cite,
NotificationGraphConfig,
Post,
Unreads
} from '@urbit/api';
import bigInt, { BigInteger } from 'big-integer';
import _ from 'lodash';
import f from 'lodash/fp';
import { emptyHarkStats } from '../state/hark';
export function getLastSeen(
unreads: Unreads,
path: string,
index: string
): BigInteger | undefined {
const lastSeenIdx = unreads.graph?.[path]?.[index]?.unreads;
if (!(typeof lastSeenIdx === 'string')) {
return bigInt.zero;
}
return f.flow(f.split('/'), f.last, x => (x ? bigInt(x) : undefined))(
lastSeenIdx
);
}
export function getHarkStats(unreads: Unreads, path: string) {
return unreads?.[path] ?? emptyHarkStats();
}
@ -53,4 +36,3 @@ export function isWatching(
)
);
}

View File

@ -0,0 +1,7 @@
import { createBrowserHistory } from 'history';
const history = createBrowserHistory({
basename: '/apps/landscape'
});
export default history;

View File

@ -1,4 +1,4 @@
import { isChannelAdmin } from '~/logic/lib/group';
import { isChannelAdmin } from '@urbit/api';
import { cite } from '~/logic/lib/util';
import { createJoinParams } from '~/views/landscape/components/Join/Join';
@ -49,7 +49,7 @@ const commandIndex = function (currentGroup, groups, associations) {
const association = currentGroup ? associations?.groups?.[currentGroup] : null;
const canAdd =
(group && association)
? (association.metadata.vip === 'member-metadata' || isChannelAdmin(group, currentGroup))
? (association.metadata.vip === 'member-metadata' || isChannelAdmin(group, currentGroup, window.ship))
: !currentGroup; // home workspace or hasn't loaded
const workspace = currentGroup || '/home';
commands.push(result('Groups: Create', '/~landscape/new', 'Groups', null));

View File

@ -0,0 +1,52 @@
import dark from '@tlon/indigo-dark';
import light from '@tlon/indigo-light';
import { useEffect } from 'react';
import useLocalState, { selectLocalState } from '../state/local';
import useSettingsState, { selectDisplayState } from '../state/settings';
const selLocal = selectLocalState(['dark', 'set']);
export function useThemeWatcher() {
const { set, dark: isDark } = useLocalState(selLocal);
const display = useSettingsState(selectDisplayState);
const theme = ((isDark && display.theme == 'auto') || display.theme == 'dark') ? dark : light;
useEffect(() => {
const updateTheme = (e: MediaQueryListEvent) => set(s => ({ dark: e.matches }));
const updateMobile = (e: MediaQueryListEvent) => set(s => ({ mobile: e.matches }));
const updateSmall = (e: MediaQueryListEvent) => set(s => ({ breaks: { sm: e.matches } }));
const updateMedium = (e: MediaQueryListEvent) => set(s => ({ breaks: { md: e.matches } }));
const updateLarge = (e: MediaQueryListEvent) => set(s => ({ breaks: { lg: e.matches } }));
const themeWatcher = window.matchMedia('(prefers-color-scheme: dark)');
const mobileWatcher = window.matchMedia(`(max-width: ${theme.breakpoints[0]})`);
const smallWatcher = window.matchMedia(`(min-width: ${theme.breakpoints[0]})`);
const mediumWatcher = window.matchMedia(`(min-width: ${theme.breakpoints[1]})`);
const largeWatcher = window.matchMedia(`(min-width: ${theme.breakpoints[2]})`);
themeWatcher.addEventListener('change', updateTheme);
mobileWatcher.addEventListener('change', updateMobile);
smallWatcher.addEventListener('change', updateSmall);
mediumWatcher.addEventListener('change', updateMedium);
largeWatcher.addEventListener('change', updateLarge);
updateTheme({ matches: themeWatcher.matches } as MediaQueryListEvent);
updateMobile({ matches: mobileWatcher.matches } as MediaQueryListEvent);
updateSmall({ matches: smallWatcher.matches } as MediaQueryListEvent);
updateMedium({ matches: mediumWatcher.matches } as MediaQueryListEvent);
updateLarge({ matches: largeWatcher.matches } as MediaQueryListEvent);
return () => {
themeWatcher.removeEventListener('change', updateTheme);
mobileWatcher.removeEventListener('change', updateMobile);
smallWatcher.removeEventListener('change', updateSmall);
mediumWatcher.removeEventListener('change', updateMedium);
largeWatcher.removeEventListener('change', updateLarge);
};
}, []);
return {
display,
theme
};
}

View File

@ -2,11 +2,10 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import _ from 'lodash';
import { patp2dec } from 'urbit-ob';
import f from 'lodash/fp';
import { Association, Contact, Patp } from '@urbit/api';
import { Association, Patp } from '@urbit/api';
import { enableMapSet } from 'immer';
/* eslint-disable max-lines */
import anyAscii from 'any-ascii';
import { sigil as sigiljs, stringRenderer } from '@tlon/sigil-js';
import bigInt, { BigInteger } from 'big-integer';
import { IconRef, Workspace } from '~/types';
@ -515,7 +514,6 @@ export const svgDataURL = svg => 'data:image/svg+xml;base64,' + btoa(svg);
export const svgBlobURL = svg => URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
export function binaryIndexOf(arr: BigInteger[], target: BigInteger): number | undefined {
let leftBound = 0;
let rightBound = arr.length - 1;

View File

@ -1,7 +1,7 @@
import {
Enc,
Group,
GroupPolicy, GroupUpdate,
GroupUpdate,
InvitePolicy, InvitePolicyDiff, OpenPolicy, OpenPolicyDiff, Tags
} from '@urbit/api';
import _ from 'lodash';
@ -13,26 +13,10 @@ import { GroupState as State } from '../state/group';
type GroupState = BaseState<State> & State;
function decodeGroup(group: Enc<Group>): Group {
const members = new Set(group.members);
const res = {
return {
...group,
members,
tags: decodeTags(group.tags),
policy: decodePolicy(group.policy)
tags: decodeTags(group.tags)
};
return res;
}
function decodePolicy(policy: Enc<GroupPolicy>): GroupPolicy {
if ('invite' in policy) {
const { invite } = policy;
return { invite: { pending: new Set(invite.pending) } };
} else {
const { open } = policy;
return {
open: { banned: new Set(open.banned), banRanks: new Set(open.banRanks) }
};
}
}
function decodeTags(tags: Enc<Tags>): Tags {
@ -40,11 +24,11 @@ function decodeTags(tags: Enc<Tags>): Tags {
tags,
(acc, ships: any, key): Tags => {
if (key.search(/\\/) === -1) {
acc.role[key] = new Set(ships);
acc.role[key] = ships;
return acc;
} else {
const [app, tag, resource] = key.split('\\');
_.set(acc, [app, resource, tag], new Set(ships));
_.set(acc, [app, resource, tag], ships);
return acc;
}
},
@ -79,9 +63,9 @@ const addGroup = (json: GroupUpdate, state: GroupState): GroupState => {
const { resource, policy, hidden } = json.addGroup;
const resourcePath = resourceAsPath(resource);
state.groups[resourcePath] = {
members: new Set(),
tags: { role: { admin: new Set([window.ship]) } },
policy: decodePolicy(policy),
members: [],
tags: { role: { admin: [window.ship] } },
policy,
hidden
};
}
@ -105,13 +89,19 @@ const addMembers = (json: GroupUpdate, state: GroupState): GroupState => {
return;
}
for (const member of ships) {
state.groups[resourcePath].members.add(member);
if (
'invite' in state.groups[resourcePath].policy &&
state.groups[resourcePath].policy['invite'].pending.has(member)
) {
state.groups[resourcePath].policy['invite'].pending.delete(member);
}
const members = state.groups[resourcePath].members;
if (!_.includes(members, member)) {
members.push(member);
}
const policy = state.groups[resourcePath].policy;
if ('invite' in policy) {
const invites = (policy as InvitePolicy).invite;
if (invites && _.includes(invites.pending, member)) {
_.remove(invites.pending, item => item === member);
}
}
}
}
return state;
@ -122,7 +112,7 @@ const removeMembers = (json: GroupUpdate, state: GroupState): GroupState => {
const { resource, ships } = json.removeMembers;
const resourcePath = resourceAsPath(resource);
for (const member of ships) {
state.groups[resourcePath].members.delete(member);
_.remove(state.groups[resourcePath].members, item => item === member);
}
}
return state;
@ -177,12 +167,14 @@ const inviteChangePolicy = (diff: InvitePolicyDiff, policy: InvitePolicy) => {
if ('addInvites' in diff) {
const { addInvites } = diff;
for (const ship of addInvites) {
policy.invite.pending.add(ship);
if (!_.includes(policy.invite.pending, ship)) {
policy.invite.pending.push(ship);
}
}
} else if ('removeInvites' in diff) {
const { removeInvites } = diff;
for (const ship of removeInvites) {
policy.invite.pending.delete(ship);
_.remove(policy.invite.pending, item => item === ship);
}
} else {
console.log('bad policy change');
@ -193,22 +185,24 @@ const openChangePolicy = (diff: OpenPolicyDiff, policy: OpenPolicy) => {
if ('allowRanks' in diff) {
const { allowRanks } = diff;
for (const rank of allowRanks) {
policy.open.banRanks.delete(rank);
_.remove(policy.open.banRanks, item => item === rank);
}
} else if ('banRanks' in diff) {
const { banRanks } = diff;
for (const rank of banRanks) {
policy.open.banRanks.delete(rank);
_.remove(policy.open.banRanks, item => item === rank);
}
} else if ('allowShips' in diff) {
const { allowShips } = diff;
for (const ship of allowShips) {
policy.open.banned.delete(ship);
_.remove(policy.open.banned, item => item === ship);
}
} else if ('banShips' in diff) {
const { banShips } = diff;
for (const ship of banShips) {
policy.open.banned.add(ship);
if (!_.includes(policy.open.banned, ship)) {
policy.open.banned.push(ship);
}
}
} else {
console.log('bad policy change');

View File

@ -39,6 +39,7 @@ const associations = (json: MetadataUpdate, state: MetadataState): MetadataState
if (data) {
state.associations = normalizeAssociations(data);
state.loaded = true;
state.onLoad();
}
return state;
};

View File

@ -69,5 +69,4 @@ export const favicon = () => {
return svg;
};
export default useContactState;

View File

@ -41,7 +41,7 @@ const useGroupState = createState<GroupState>(
},
}),
['groups'],
[],
[
(set, get) =>
createSubscription('group-store', '/groups', (e) => {

View File

@ -36,6 +36,7 @@ export interface HarkState {
poke: (poke: Poke<any>) => Promise<void>;
getMore: () => Promise<boolean>;
opened: () => void;
readCount: (path: string) => Promise<void>;
// getTimeSubset: (start?: Date, end?: Date) => Promise<void>;
unseen: Timebox;
seen: Timebox;
@ -44,7 +45,6 @@ export interface HarkState {
notificationsGroupConfig: string[];
unreads: Unreads;
archiveNote: (bin: HarkBin, lid: HarkLid) => Promise<void>;
readCount: (path: string) => Promise<void>;
readGraph: (graph: string) => Promise<void>;
readGroup: (group: string) => Promise<void>;
}
@ -60,20 +60,20 @@ const useHarkState = createState<HarkState>(
},
readGraph: async (graph: string) => {
const prefix = `/graph/${graph.slice(6)}`;
let counts = [] as string[];
let eaches = [] as [string, string][];
const counts = [] as string[];
const eaches = [] as [string, string][];
Object.entries(get().unreads).forEach(([path, unreads]) => {
if (path.startsWith(prefix)) {
if(unreads.count > 0) {
counts.push(path);
}
unreads.each.forEach(unread => {
unreads.each.forEach((unread) => {
eaches.push([path, unread]);
});
}
});
get().set(draft => {
counts.forEach(path => {
get().set((draft) => {
counts.forEach((path) => {
draft.unreads[path].count = 0;
});
eaches.forEach(([path, each]) => {
@ -86,8 +86,7 @@ const useHarkState = createState<HarkState>(
].map(pok => api.poke(pok)));
},
readGroup: async (group: string) => {
const graphs =
_.pickBy(useMetadataState.getState().associations.graph, a => a.group === group);
const graphs = _.pickBy(useMetadataState.getState().associations.graph, a => a.group === group);
await Promise.all(Object.keys(graphs).map(get().readGraph));
},
readCount: async (path) => {

View File

@ -7,7 +7,9 @@ import {
reduceStateN
} from './base';
import airlock from '~/logic/api';
import history from '~/logic/lib/history';
import { reduce } from '../reducers/metadata-update';
import { getNotificationRedirect } from '../lib/notificationRedirects';
export const METADATA_MAX_PREVIEW_WAIT = 150000;
@ -16,6 +18,7 @@ export interface MetadataState {
loaded: boolean;
getPreview: (group: string) => Promise<MetadataUpdatePreview
>;
onLoad: () => void;
previews: {
[group: string]: MetadataUpdatePreview
}
@ -53,6 +56,9 @@ const useMetadataState = createState<MetadataState>(
}
throw e;
}
},
onLoad: () => {
handleGridRedirect();
}
}),
['loaded'],
@ -67,6 +73,12 @@ const useMetadataState = createState<MetadataState>(
]
);
const { graph, groups } = useMetadataState.getState().associations;
if (Object.keys(graph).length > 0 || Object.keys(groups).length > 0) {
handleGridRedirect();
}
export function useAssocForGraph(graph: string) {
return useMetadataState(
useCallback(s => s.associations.graph[graph] as Association | undefined, [
@ -111,6 +123,17 @@ export function usePreview(group: string) {
return { error, preview };
}
function handleGridRedirect() {
const query = new URLSearchParams(window.location.search);
if(query.has('grid-note')) {
history.push(getNotificationRedirect(query.get('grid-note')));
} else if(query.has('grid-link')) {
const link = decodeURIComponent(query.get('grid-link')!);
history.push(`/perma${link}`);
}
}
export function useGraphsForGroup(group: string) {
const graphs = useMetadataState(s => s.associations.graph);
return _.pickBy(graphs, (a: Association) => a.group === group);

View File

@ -1,42 +0,0 @@
import React from 'react';
import { Meta, Story } from '@storybook/react';
import { Box } from '@tlon/indigo-react';
import { InviteItem, InviteItemProps } from '~/views/components/Invite';
import { JoinProgress } from '@urbit/api';
export default {
title: 'Notifications/Invite',
component: InviteItem
} as Meta;
const Template: Story<InviteItemProps> = args => (
<Box backgroundColor="white" p="0" maxWidth="90%" width="fit-content">
<InviteItem {...args} />
</Box>
);
const pendingJoin = (progress: JoinProgress) => ({
hidden: false,
started: Date.now() - 3600,
ship: '~haddef-sigwen',
progress
});
export const Pending = Template.bind({});
Pending.args = {
pendingJoin: pendingJoin('start'),
resource: '/ship/~bollug-worlus/urbit-index'
};
export const Errored = Template.bind({});
Errored.args = {
pendingJoin: pendingJoin('no-perms'),
resource: '/ship/~bollug-worlus/urbit-index'
};
export const Done = Template.bind({});
Done.args = {
pendingJoin: pendingJoin('done'),
resource: '/ship/~bollug-worlus/urbit-index'
};

View File

@ -1,8 +1,10 @@
import { PatpNoSig } from '@urbit/api';
import useHarkState from '~/logic/state/hark';
declare global {
interface Window {
ship: PatpNoSig;
desk: string;
hark: typeof useHarkState.getState;
}
}

View File

@ -1,249 +0,0 @@
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';
import 'react-hot-loader';
import { hot } from 'react-hot-loader/root';
import { BrowserRouter as Router, withRouter } from 'react-router-dom';
import styled, { ThemeProvider } from 'styled-components';
import gcpManager from '~/logic/lib/gcpManager';
import { svgDataURL } from '~/logic/lib/util';
import withState from '~/logic/lib/withState';
import useContactState, { favicon } from '~/logic/state/contact';
import useLocalState from '~/logic/state/local';
import useSettingsState from '~/logic/state/settings';
import useGraphState from '~/logic/state/graph';
import { ShortcutContextProvider } from '~/logic/lib/shortcutContext';
import ErrorBoundary from '~/views/components/ErrorBoundary';
import './apps/chat/css/custom.css';
import Omnibox from './components/leap/Omnibox';
import StatusBar from './components/StatusBar';
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';
function ensureValidHex(color) {
if (!color)
return '#000000';
const isUx = color.startsWith('0x');
const parsedColor = isUx ? uxToHex(color) : color;
return parsedColor.startsWith('#') ? parsedColor : `#${parsedColor}`;
}
const Root = withState(styled.div`
font-family: ${p => p.theme.fonts.sans};
height: 100%;
width: 100%;
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
margin: 0;
${p => p.display.backgroundType === 'url' ? `
background-image: url('${p.display.background}');
background-size: cover;
` : p.display.backgroundType === 'color' ? `
background-color: ${ensureValidHex(p.display.background)};
` : `background-color: ${p.theme.colors.white};`
}
display: flex;
flex-flow: column nowrap;
touch-action: none;
* {
scrollbar-width: thin;
scrollbar-color: ${ p => p.theme.colors.gray } transparent;
}
/* Works on Chrome/Edge/Safari */
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: ${ p => p.theme.colors.gray };
border-radius: 1rem;
border: 0px solid transparent;
}
`, [
[useSettingsState, ['display']]
]);
const StatusBarWithRouter = withRouter(StatusBar);
class App extends React.Component {
constructor(props) {
super(props);
this.ship = window.ship;
this.updateTheme = this.updateTheme.bind(this);
this.updateMobile = this.updateMobile.bind(this);
}
componentDidMount() {
bootstrapApi();
this.props.getShallowChildren(`~${window.ship}`, 'dm-inbox');
const theme = this.getTheme();
setTimeout(() => {
// Something about how the store works doesn't like changing it
// before the app has actually rendered, hence the timeout.
this.themeWatcher = window.matchMedia('(prefers-color-scheme: dark)');
this.mobileWatcher = window.matchMedia(`(max-width: ${theme.breakpoints[0]})`);
this.smallWatcher = window.matchMedia(`(min-width: ${theme.breakpoints[0]})`);
this.mediumWatcher = window.matchMedia(`(min-width: ${theme.breakpoints[1]})`);
this.largeWatcher = window.matchMedia(`(min-width: ${theme.breakpoints[2]})`);
// TODO: addListener is deprecated, but safari 13 requires it
this.themeWatcher.addListener(this.updateTheme);
this.mobileWatcher.addListener(this.updateMobile);
this.smallWatcher.addListener(this.updateSmall);
this.mediumWatcher.addListener(this.updateMedium);
this.largeWatcher.addListener(this.updateLarge);
this.updateMobile(this.mobileWatcher);
this.updateSmall(this.updateSmall);
this.updateTheme(this.themeWatcher);
this.updateMedium(this.mediumWatcher);
this.updateLarge(this.largeWatcher);
}, 500);
this.props.getAll();
gcpManager.start();
Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => {
e.preventDefault();
e.stopImmediatePropagation();
this.props.toggleOmnibox();
});
}
componentWillUnmount() {
this.themeWatcher.removeListener(this.updateTheme);
this.mobileWatcher.removeListener(this.updateMobile);
this.smallWatcher.removeListener(this.updateSmall);
this.mediumWatcher.removeListener(this.updateMedium);
this.largeWatcher.removeListener(this.updateLarge);
}
updateTheme(e) {
this.props.set((state) => {
state.dark = e.matches;
});
}
updateMobile(e) {
this.props.set((state) => {
state.mobile = e.matches;
});
}
updateSmall = (e) => {
this.props.set((state) => {
state.breaks.sm = e.matches;
});
}
updateMedium = (e) => {
this.props.set((state) => {
state.breaks.md = e.matches;
});
}
updateLarge = (e) => {
this.props.set((state) => {
state.breaks.lg = e.matches;
});
}
getTheme() {
const { props } = this;
return ((props.dark && props?.display?.theme == 'auto') ||
props?.display?.theme == 'dark'
) ? dark : light;
}
render() {
const theme = this.getTheme();
const { ourContact } = this.props;
return (
<ThemeProvider theme={theme}>
<ShortcutContextProvider>
<Helmet>
{window.ship.length < 14
? <link rel="icon" type="image/svg+xml" href={svgDataURL(favicon())} />
: null}
</Helmet>
<Root>
<Router basename="/apps/landscape">
<ErrorBoundary>
<StatusBarWithRouter
props={this.props}
ourContact={ourContact}
connection={'foo'}
subscription={this.subscription}
ship={this.ship}
/>
</ErrorBoundary>
<ErrorBoundary>
<Omnibox
show={this.props.omniboxShown}
toggle={this.props.toggleOmnibox}
/>
</ErrorBoundary>
<ErrorBoundary>
<Content
ship={this.ship}
subscription={this.subscription}
connection={'aa'}
/>
</ErrorBoundary>
</Router>
</Root>
<div id="portal-root" />
</ShortcutContextProvider>
</ThemeProvider>
);
}
}
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, s.dark];
const selSettings = s => [s.display, s.getAll];
const selGraph = s => s.getShallowChildren;
const WithApp = React.forwardRef((props, ref) => {
const ourContact = useContactState(selContacts);
const [display, getAll] = useSettingsState(selSettings, shallow);
const [setLocal, omniboxShown, toggleOmnibox, dark] = useLocalState(selLocal);
const getShallowChildren = useGraphState(selGraph);
return (
<WarmApp
ref={ref}
ourContact={ourContact}
display={display}
getAll={getAll}
set={setLocal}
dark={dark}
getShallowChildren={getShallowChildren}
toggleOmnibox={toggleOmnibox}
omniboxShown={omniboxShown}
/>
);
});
WarmApp.whyDidYouRender = true;
export default WithApp;

View File

@ -0,0 +1,137 @@
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 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 { ShortcutContextProvider } from '~/logic/lib/shortcutContext';
import ErrorBoundary from '~/views/components/ErrorBoundary';
import './apps/chat/css/custom.css';
import Omnibox from './components/leap/Omnibox';
import StatusBar from './components/StatusBar';
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';
function ensureValidHex(color) {
if (!color)
return '#000000';
const isUx = color.startsWith('0x');
const parsedColor = isUx ? uxToHex(color) : color;
return parsedColor.startsWith('#') ? parsedColor : `#${parsedColor}`;
}
interface RootProps {
display: SettingsState['display'];
}
const Root = styled.div<RootProps>`
font-family: ${p => p.theme.fonts.sans};
height: 100%;
width: 100%;
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
margin: 0;
${p => p.display.backgroundType === 'url' ? `
background-image: url('${p.display.background}');
background-size: cover;
` : p.display.backgroundType === 'color' ? `
background-color: ${ensureValidHex(p.display.background)};
` : `background-color: ${p.theme.colors.white};`
}
display: flex;
flex-flow: column nowrap;
touch-action: none;
* {
scrollbar-width: thin;
scrollbar-color: ${ p => p.theme.colors.gray } transparent;
}
/* Works on Chrome/Edge/Safari */
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: ${ p => p.theme.colors.gray };
border-radius: 1rem;
border: 0px solid transparent;
}
`;
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');
getAll();
gcpManager.start();
Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => {
e.preventDefault();
e.stopImmediatePropagation();
toggleOmnibox();
});
}, []);
return (
<ThemeProvider theme={theme}>
<ShortcutContextProvider>
<Helmet>
{window.ship.length < 14
? <link rel="icon" type="image/svg+xml" href={svgDataURL(favicon())} />
: null}
</Helmet>
<Root display={display}>
<Router history={history}>
<ErrorBoundary>
<StatusBarWithRouter />
</ErrorBoundary>
<ErrorBoundary>
<Omnibox />
</ErrorBoundary>
<ErrorBoundary>
<Content />
</ErrorBoundary>
</Router>
</Root>
<div id="portal-root" />
</ShortcutContextProvider>
</ThemeProvider>
);
};
export default App;

View File

@ -1,22 +1,23 @@
import { Association, Content, createPost, fetchIsAllowed, Post, removePosts, deSig } from '@urbit/api';
import { Association, Content, createPost, deSig, fetchIsAllowed, isWriter, Post, removePosts, resourceFromPath } from '@urbit/api';
import { BigInteger } from 'big-integer';
import _ from 'lodash';
import React, {
ReactElement, useCallback,
ReactElement,
useCallback,
useEffect,
useMemo, useState
useMemo,
useState
} from 'react';
import { isWriter, resourceFromPath } from '~/logic/lib/group';
import shallow from 'zustand/shallow';
import airlock from '~/logic/api';
import { disallowedShipsForOurContact } from '~/logic/lib/contact';
import { getPermalinkForGraph } from '~/logic/lib/permalinks';
import { toHarkPath } from '~/logic/lib/util';
import useGraphState, { useGraphForAssoc } from '~/logic/state/graph';
import { useGroupForAssoc } from '~/logic/state/group';
import useHarkState, { useHarkStat } from '~/logic/state/hark';
import { Loading } from '~/views/components/Loading';
import { ChatPane } from './components/ChatPane';
import airlock from '~/logic/api';
import { disallowedShipsForOurContact } from '~/logic/lib/contact';
import shallow from 'zustand/shallow';
import { toHarkPath } from '~/logic/lib/util';
const getCurrGraphSize = (ship: string, name: string) => {
const { graphs } = useGraphState.getState();
@ -37,7 +38,7 @@ const ChatResource = (props: ChatResourceProps): ReactElement => {
const graph = useGraphForAssoc(association);
const stats = useHarkStat(toHarkPath(association.resource));
const unreadCount = stats.count;
const canWrite = group ? isWriter(group, resource) : false;
const canWrite = group ? isWriter(group, resource, window.ship) : false;
const [
getNewest,
getOlderSiblings,
@ -89,7 +90,7 @@ const ChatResource = (props: ChatResourceProps): ReactElement => {
);
const isAdmin = useMemo(
() => group ? group.tags.role.admin.has(deSig(window.ship)) : false,
() => (group ? _.includes(group.tags.role.admin, deSig(window.ship)) : false),
[group]
);

View File

@ -1,52 +0,0 @@
import { Center, Text } from '@tlon/indigo-react';
import { GraphConfig, joinGraph } from '@urbit/api';
import React, { ReactElement } from 'react';
import { Route, Switch, useHistory } from 'react-router-dom';
import { deSig } from '~/logic/lib/util';
import useGraphState from '~/logic/state/graph';
import useMetadataState from '~/logic/state/metadata';
import airlock from '~/logic/api';
const GraphApp = (): ReactElement => {
const associations= useMetadataState(state => state.associations);
const graphKeys = useGraphState(state => state.graphKeys);
const history = useHistory();
return (
<Switch>
<Route exact path="/~graph/join/ship/:ship/:name/:module?"
render={(props) => {
const resource =
`${deSig(props.match.params.ship)}/${props.match.params.name}`;
const { ship, name } = props.match.params;
const path = `/ship/~${deSig(ship)}/${name}`;
const association = associations.graph[path];
const autoJoin = () => {
try {
airlock.thread(joinGraph(
`~${deSig(props.match.params.ship)}`,
props.match.params.name
));
} catch(err) {
setTimeout(autoJoin, 2000);
}
};
if(!graphKeys.has(resource)) {
autoJoin();
} else if(Boolean(association) && 'graph' in association.metadata.config) {
history.push(`/~landscape/home/resource/${(association.metadata.config as GraphConfig).graph}${path}`);
}
return (
<Center width="100%" height="100%">
<Text fontSize={1}>Redirecting...</Text>
</Center>
);
}}
/>
</Switch>
);
};
export default GraphApp;

View File

@ -1,18 +1,18 @@
/* eslint-disable max-lines-per-function */
import { Box, Icon, Row, Text, Button } from "@tlon/indigo-react";
import React, { ReactElement } from "react";
import { Helmet } from "react-helmet";
import { Route, useHistory } from "react-router-dom";
import styled from "styled-components";
import useHarkState from "~/logic/state/hark";
import useSettingsState, { selectCalmState } from "~/logic/state/settings";
import Groups from "./components/Groups";
import { NewGroup } from "~/views/landscape/components/NewGroup";
import ModalButton from "./components/ModalButton";
import Tiles from "./components/tiles";
import Tile from "./components/tiles/tile";
import "./css/custom.css";
import { createJoinParams, Join, JoinRoute } from "~/views/landscape/components/Join/Join";
import { Box, Icon, Row, Text, Button } from '@tlon/indigo-react';
import React, { ReactElement } from 'react';
import { Helmet } from 'react-helmet';
import { Route, useHistory } from 'react-router-dom';
import styled from 'styled-components';
import useHarkState from '~/logic/state/hark';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
import Groups from './components/Groups';
import { NewGroup } from '~/views/landscape/components/NewGroup';
import ModalButton from './components/ModalButton';
import Tiles from './components/tiles';
import Tile from './components/tiles/tile';
import './css/custom.css';
import { createJoinParams, JoinRoute } from '~/views/landscape/components/Join/Join';
const ScrollbarLessBox = styled(Box)`
scrollbar-width: none !important;
@ -22,12 +22,8 @@ const ScrollbarLessBox = styled(Box)`
}
`;
interface LaunchAppProps {
connection: string;
}
export const LaunchApp = (props: LaunchAppProps): ReactElement | null => {
const notificationsCount = useHarkState((state) => state.notificationsCount);
export const LaunchApp = (): ReactElement | null => {
const notificationsCount = useHarkState(state => state.notificationsCount);
const calmState = useSettingsState(selectCalmState);
const { hideUtilities, hideGroups } = calmState;
const history = useHistory();
@ -36,22 +32,22 @@ export const LaunchApp = (props: LaunchAppProps): ReactElement | null => {
<>
<Helmet defer={false}>
<title>
{notificationsCount ? `(${String(notificationsCount)}) ` : ""}Groups
{notificationsCount ? `(${String(notificationsCount)}) ` : ''}Groups
</title>
</Helmet>
<Route path="/join/:ship/:name">
<Route path='/join/:ship/:name'>
<JoinRoute />
</Route>
<ScrollbarLessBox
height="100%"
overflowY="scroll"
display="flex"
flexDirection="column"
height='100%'
overflowY='scroll'
display='flex'
flexDirection='column'
>
<Box
mx={2}
display="grid"
gridTemplateColumns="repeat(auto-fill, minmax(128px, 1fr))"
display='grid'
gridTemplateColumns='repeat(auto-fill, minmax(128px, 1fr))'
gridGap={3}
p={2}
pt={0}
@ -59,22 +55,22 @@ export const LaunchApp = (props: LaunchAppProps): ReactElement | null => {
{!hideUtilities && (
<>
<Tile
bg="white"
color="scales.black20"
to="/~landscape/home"
bg='white'
color='scales.black20'
to='/~landscape/home'
p={0}
>
<Box
p={2}
height="100%"
width="100%"
bg="scales.black20"
height='100%'
width='100%'
bg='scales.black20'
border={1}
borderColor="lightGray"
borderColor='lightGray'
>
<Row alignItems="center">
<Icon color="black" icon="Home" />
<Text ml={2} mt="1px" color="black">
<Row alignItems='center'>
<Icon color='black' icon='Home' />
<Text ml={2} mt='1px' color='black'>
My Channels
</Text>
</Row>
@ -82,10 +78,10 @@ export const LaunchApp = (props: LaunchAppProps): ReactElement | null => {
</Tile>
<Tiles />
<ModalButton
icon="Plus"
bg="white"
color="black"
text="New Group"
icon='Plus'
bg='white'
color='black'
text='New Group'
style={{ gridColumnStart: 1 }}
>
<NewGroup />
@ -96,9 +92,9 @@ export const LaunchApp = (props: LaunchAppProps): ReactElement | null => {
borderRadius={2}
onClick={() => history.push({ search: createJoinParams('groups') })}
>
<Row backgroundColor="white" gapX="2" p={2} height="100%" width="100%" alignItems="center">
<Icon icon="BootNode" />
<Text fontWeight="medium" whiteSpace="nowrap">Join Group</Text>
<Row backgroundColor='white' gapX='2' p={2} height='100%' width='100%' alignItems='center'>
<Icon icon='BootNode' />
<Text fontWeight='medium' whiteSpace='nowrap'>Join Group</Text>
</Row>
</Button>
</>

View File

@ -1,10 +1,9 @@
import { Box, Col, Text } from '@tlon/indigo-react';
import { Association, deSig, Graph, Group } from '@urbit/api';
import { Association, deSig, Graph, Group, isWriter } from '@urbit/api';
import bigInt, { BigInteger } from 'big-integer';
import React, {
Component, ReactNode
} from 'react';
import { isWriter } from '~/logic/lib/group';
import { GraphScroller } from '~/views/components/GraphScroller';
import { LinkItem } from './components/LinkItem';
import LinkSubmit from './components/LinkSubmit';
@ -40,7 +39,7 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
canWrite() {
const { group, association } = this.props;
return isWriter(group, association.resource);
return isWriter(group, association.resource, window.ship);
}
renderItem = React.forwardRef<HTMLDivElement>(({ index }: RendererProps, ref) => {

View File

@ -1,8 +1,17 @@
import { Action, Anchor, Box, Col, Icon, Row, Rule, Text } from '@tlon/indigo-react';
import { Association, GraphNode, Group, markEachAsRead, removePosts, TextContent, UrlContent, ReferenceContent } from '@urbit/api';
import {
Association,
GraphNode,
Group,
markEachAsRead,
removePosts,
TextContent,
UrlContent,
ReferenceContent,
roleForShip
} from '@urbit/api';
import React, { ReactElement, RefObject, useCallback, useEffect, useRef } from 'react';
import { Link, Redirect } from 'react-router-dom';
import { roleForShip } from '~/logic/lib/group';
import { getPermalinkForGraph, referenceToPermalink } from '~/logic/lib/permalinks';
import { useCopy } from '~/logic/lib/useCopy';
import { useHarkStat } from '~/logic/state/hark';

View File

@ -1,85 +0,0 @@
import React, { ReactElement } from 'react';
import _ from 'lodash';
import {
Invite,
AppInvites,
JoinRequest
} from '@urbit/api';
import { alphabeticalOrder, resourceAsPath } from '~/logic/lib/util';
import useInviteState from '~/logic/state/invite';
import useGraphState from '~/logic/state/graph';
import { PendingDm } from './PendingDm';
import InviteItem from '~/views/components/Invite';
interface InvitesProps {
pendingJoin?: any;
}
interface InviteRef {
uid: string;
app: string;
invite: Invite;
}
export function Invites(props: InvitesProps): ReactElement {
const invites = useInviteState(state => state.invites);
const pendingDms = useGraphState(s => s.pendingDms) ?? [];
const inviteArr: InviteRef[] = _.reduce(
invites,
(acc: InviteRef[], val: AppInvites, app: string) => {
const appInvites = _.reduce(
val,
(invs: InviteRef[], invite: Invite, uid: string) => {
return [...invs, { invite, uid, app }];
},
[]
);
return [...acc, ...appInvites];
},
[]
);
const pendingJoin = _.omitBy(props.pendingJoin, 'hidden');
const invitesAndStatus: { [rid: string]: JoinRequest | InviteRef } = {
..._.keyBy(inviteArr, ({ invite }) => resourceAsPath(invite.resource)),
...pendingJoin
};
return (
<>
{[...pendingDms].map(ship => (
<PendingDm key={ship} ship={`~${ship}`} />
))}
{Object.keys(invitesAndStatus)
.sort(alphabeticalOrder)
.map((resource) => {
const inviteOrStatus = invitesAndStatus[resource];
const join = pendingJoin[resource];
if ('progress' in inviteOrStatus) {
return (
<InviteItem
key={resource}
resource={resource}
pendingJoin={join}
/>
);
} else {
const { app, uid, invite } = inviteOrStatus;
return (
<InviteItem
key={resource}
invite={invite}
app={app}
uid={uid}
resource={resource}
/>
);
}
})}
</>
);
}

View File

@ -37,7 +37,7 @@ export function NavLink({
);
}
export default function NotificationsScreen(props: any): ReactElement {
export default function NotificationsScreen(): ReactElement {
const relativePath = (p: string) => baseUrl + p;
const notificationsCount = useHarkState(state => state.notificationsCount);

View File

@ -9,7 +9,6 @@ import _ from 'lodash';
import React, { ReactElement, useState } from 'react';
import { useHistory } from 'react-router-dom';
import * as Yup from 'yup';
import { resourceFromPath } from '~/logic/lib/group';
import { uxToHex } from '~/logic/lib/util';
import useContactState from '~/logic/state/contact';
import { MarkdownField } from '~/views/apps/publish/components/MarkdownField';
@ -22,7 +21,7 @@ import {
ProfileImages, ProfileStatus
} from './Profile';
import airlock from '~/logic/api';
import { editContact, setPublic } from '@urbit/api';
import { editContact, setPublic, resourceFromPath } from '@urbit/api';
const formSchema = Yup.object({
nickname: Yup.string(),

View File

@ -6,7 +6,7 @@ import useContactState from '~/logic/state/contact';
import useHarkState from '~/logic/state/hark';
import { Profile } from './components/Profile';
export default function ProfileScreen(props: any) {
export default function ProfileScreen() {
const contacts = useContactState(state => state.contacts);
const notificationsCount = useHarkState(state => state.notificationsCount);
return (

View File

@ -1,9 +1,8 @@
import { Action, Box, Col, Row, Text } from '@tlon/indigo-react';
import { Association, Graph, GraphNode, Group, markEachAsRead, removePosts } from '@urbit/api';
import { Association, Graph, GraphNode, Group, markEachAsRead, removePosts, roleForShip } from '@urbit/api';
import bigInt from 'big-integer';
import React, { useEffect, useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom';
import { roleForShip } from '~/logic/lib/group';
import { getPermalinkForGraph } from '~/logic/lib/permalinks';
import { getComments, getLatestRevision } from '~/logic/lib/publish';
import { useCopy } from '~/logic/lib/useCopy';

View File

@ -1,8 +1,7 @@
import { Box, Text } from '@tlon/indigo-react';
import { addTag, Association, Group } from '@urbit/api';
import { addTag, Association, Group, resourceFromPath } from '@urbit/api';
import { Form, Formik } from 'formik';
import React, { ReactElement } from 'react';
import { resourceFromPath } from '~/logic/lib/group';
import { AsyncButton } from '~/views/components/AsyncButton';
import { ShipSearch } from '~/views/components/ShipSearch';
import airlock from '~/logic/api';

View File

@ -67,7 +67,7 @@ function SettingsItem(props: { children: ReactNode }) {
);
}
export default function SettingsScreen(props: any) {
export default function SettingsScreen() {
const location = useLocation();
const hash = location.hash.slice(1);
const notificationsCount = useHarkState(state => state.notificationsCount);
@ -120,12 +120,7 @@ return;
</Col>
<Col flexGrow={1} overflowY='auto'>
<SettingsItem>
{hash === 'notifications' && (
<NotificationPreferences
{...props}
graphConfig={props.notificationsGraphConfig}
/>
)}
{hash === 'notifications' && <NotificationPreferences />}
{hash === 'display' && <DisplayForm />}
{hash === 'dm' && <DmSettings />}
{hash === 'shortcuts' && <ShortcutSettings />}

View File

@ -1,8 +1,7 @@
import { Action, Box, Col, Icon, Row, Text } from '@tlon/indigo-react';
import { GraphNode, Group, removePosts } from '@urbit/api';
import { GraphNode, Group, removePosts, roleForShip } from '@urbit/api';
import bigInt from 'big-integer';
import React, { useCallback, useEffect, useRef } from 'react';
import { roleForShip } from '~/logic/lib/group';
import { getPermalinkForGraph } from '~/logic/lib/permalinks';
import { getLatestCommentRevision } from '~/logic/lib/publish';
import { useCopy } from '~/logic/lib/useCopy';

View File

@ -7,12 +7,12 @@ import {
Group,
markCountAsRead,
addPost,
isWriter,
resourceFromPath
} from '@urbit/api';
import bigInt from 'big-integer';
import { FormikHelpers } from 'formik';
import React, { useEffect, useMemo } from 'react';
import { isWriter } from '~/logic/lib/group';
import { getUnreadCount } from '~/logic/lib/hark';
import { referenceToPermalink } from '~/logic/lib/permalinks';
import { getLatestCommentRevision } from '~/logic/lib/publish';
@ -137,7 +137,7 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
const harkPath = toHarkPath(association.resource, parentIndex);
const readCount = children.length - getUnreadCount(unreads, harkPath);
const canComment = isWriter(group, association.resource) || association.metadata.vip === 'reader-comments';
const canComment = isWriter(group, association.resource, window.ship) || association.metadata.vip === 'reader-comments';
return (
<Col {...rest} minWidth={0}>

View File

@ -6,12 +6,11 @@ import {
ErrorLabel, Icon, Label,
Row, Text
} from '@tlon/indigo-react';
import { Association, OpenPolicy } from '@urbit/api';
import { Association, OpenPolicy, roleForShip } from '@urbit/api';
import { FieldArray, useFormikContext } from 'formik';
import _ from 'lodash';
import React, { ReactElement, useMemo, useState } from 'react';
import styled from 'styled-components';
import { roleForShip } from '~/logic/lib/group';
import useGroupState from '~/logic/state/group';
import useMetadataState from '~/logic/state/metadata';
import { DropdownSearch } from './DropdownSearch';

View File

@ -1,318 +0,0 @@
import { css } from '@styled-system/css';
import { Box, Icon, LoadingSpinner, Row, Text } from '@tlon/indigo-react';
import {
accept,
decline,
Invite,
joinProgress,
joinResult,
JoinRequest,
Metadata,
MetadataUpdatePreview,
resourceFromPath
} from '@urbit/api';
import { GraphConfig } from '@urbit/api';
import _ from 'lodash';
import React, { ReactElement, ReactNode, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import styled from 'styled-components';
import { useRunIO } from '~/logic/lib/useRunIO';
import { useWaitForProps } from '~/logic/lib/useWaitForProps';
import { cite, isDm } from '~/logic/lib/util';
import useGraphState from '~/logic/state/graph';
import useGroupState from '~/logic/state/group';
import useMetadataState, { useAssocForGraph } from '~/logic/state/metadata';
import { PropFunc } from '~/types';
import { Header } from '~/views/apps/notifications/header';
import { MetadataIcon } from '~/views/landscape/components/MetadataIcon';
import { StatelessAsyncButton } from '../StatelessAsyncButton';
import airlock from '~/logic/api';
interface GroupInviteProps {
preview?: MetadataUpdatePreview;
status?: JoinRequest;
app?: string;
uid?: string;
invite?: Invite;
resource: string;
}
function Elbow(
props: { size?: number; color?: string } & PropFunc<typeof Box>
) {
const { size = 12, color = 'lightGray', ...rest } = props;
return (
<Box
{...rest}
overflow="hidden"
width={size}
height={size}
position="relative"
>
<Box
border="2px solid"
borderRadius={3}
borderColor={color}
position="absolute"
left="0px"
bottom="0px"
width={size * 2}
height={size * 2}
/>
</Box>
);
}
const description: string[] = [
'Contacting host...',
'Retrieving data...',
'Finished join',
'Unable to join, you do not have the correct permissions',
'Internal error, please file an issue'
];
function inviteUrl(hidden: boolean, resource: string, metadata?: Metadata) {
if (!hidden) {
return `/~landscape${resource}`;
}
if ((metadata?.config as GraphConfig).graph === 'chat') {
return `/~landscape/messages/resource/${
(metadata?.config as GraphConfig)?.graph
}${resource}`;
} else {
return `/~landscape/home/resource/${
(metadata?.config as GraphConfig)?.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(
<>
<MetadataIcon height={4} width={4} metadata={preview.metadata} />
<Text fontWeight="medium">{title}</Text>
<Text gray fontWeight="medium">
{members} Member{members > 1 ? 's' : ''}
</Text>
</>
);
}
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={2} alignItems="center" minHeight={4}>
<Row alignItems="center" flexShrink={0}>
{joinResult.includes(status?.progress as any) ? (
<Icon icon={status?.progress === 'done' ? 'Checkmark' : 'X'} />
) : (
<LoadingSpinner dark />
)}
</Row>
<Text gray>{desc}</Text>
</Row>
);
}
export function useInviteAccept(resource: string, 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 airlock.poke(decline(app, uid));
return false;
}
await airlock.poke(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;
app?: string;
uid?: string;
}) {
const { status, resource, app, uid } = props;
const inviteAccept = useInviteAccept(resource, app, uid);
const inviteDecline = useCallback(async () => {
if (!(app && uid)) {
return;
}
await airlock.poke(decline(app, uid));
}, [app, uid]);
if (status) {
return (
<Row gapX={2} alignItems="center" height={4}>
<StatelessAsyncButton
height={4}
backgroundColor="white"
onClick={async () => {}}
>
{[...joinResult].includes(status?.progress as any)
? 'Dismiss'
: 'Cancel'}
</StatelessAsyncButton>
</Row>
);
}
return (
<Row gapX={2} alignItems="center" height={4}>
<StatelessAsyncButton
color="blue"
height={4}
backgroundColor="white"
onClick={inviteAccept as any}
>
Accept
</StatelessAsyncButton>
<StatelessAsyncButton
height={4}
backgroundColor="white"
onClick={inviteDecline as any}
>
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, 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 (
<>
<Header content {...headerProps} />
<Row
onClick={onClick}
height={[null, 4]}
alignItems="flex-start"
gridArea="main"
>
<Elbow display={['none', 'block']} mx={2} />
<ResponsiveRow
gapXY={2}
height={[null, 4]}
alignItems={['flex-start', 'center']}
>
<InviteMetadata preview={preview} resource={resource} />
<InviteStatus status={status} />
<InviteActions
resource={resource}
status={status}
app={app}
uid={uid}
/>
</ResponsiveRow>
</Row>
</>
);
}

View File

@ -1,52 +0,0 @@
import { Col, Row, Rule } from '@tlon/indigo-react';
import React, { ReactElement, ReactNode } from 'react';
import { PropFunc } from '~/types';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
export interface InviteSkeletonProps {
onAccept: () => Promise<any>;
onDecline: () => Promise<any>;
acceptDesc: string;
declineDesc: string;
children: ReactNode;
}
export function InviteSkeleton(
props: InviteSkeletonProps & PropFunc<typeof Col>
): ReactElement {
const {
children,
acceptDesc,
declineDesc,
onAccept,
onDecline,
...rest
} = props;
return (
<>
<Col width="100%" p={1} {...rest}>
{children}
<Row px={4} gapX={4}>
<StatelessAsyncAction
name="accept"
bg="transparent"
onClick={onAccept}
color="blue"
mr={2}
>
{acceptDesc}
</StatelessAsyncAction>
<StatelessAsyncAction
name="decline"
bg="transparent"
color="red"
onClick={onDecline}
>
{declineDesc}
</StatelessAsyncAction>
</Row>
</Col>
<Rule borderColor="washedGray" />
</>
);
}

View File

@ -1,24 +0,0 @@
import { Col, Rule } from '@tlon/indigo-react';
import { JoinRequest } from '@urbit/api';
import React, { ReactElement, ReactNode } from 'react';
import { PropFunc } from '~/types/util';
import { JoiningStatus } from '~/views/apps/notifications/joining';
type JoinSkeletonProps = {
children: ReactNode;
status: JoinRequest;
resource: string;
} & PropFunc<typeof Col>;
export function JoinSkeleton(props: JoinSkeletonProps): ReactElement {
const { resource, children, status, ...rest } = props;
return (
<>
<Col p={1} {...rest}>
{children}
<JoiningStatus resource={resource} status={status} />
</Col>
<Rule borderColor="washedGray" />
</>
);
}

View File

@ -1,35 +0,0 @@
import { Invite, JoinRequest } from '@urbit/api';
import React from 'react';
import { usePreview } from '~/logic/state/metadata';
import { GroupInvite } from './Group';
export interface InviteItemProps {
invite?: Invite;
resource: string;
pendingJoin?: JoinRequest;
app?: string;
uid?: string;
}
export function InviteItem(props: InviteItemProps) {
const { pendingJoin, invite, resource, uid, app } = props;
const { preview } = usePreview(resource);
if (pendingJoin?.hidden) {
return null;
}
return (
<GroupInvite
resource={resource}
preview={preview}
invite={invite}
status={pendingJoin}
uid={uid}
app={app}
/>
);
}
export default InviteItem;

View File

@ -36,7 +36,7 @@ const getNicknameForShips = (groups: Groups, contacts: Rolodex, selected: string
const peerSet = new Set<string>();
const nicknames = new Map<string, string[]>();
_.forEach(groups, (group, path) => {
if (group.members.size > 0) {
if (group.members.length > 0) {
const groupEntries = group.members.values();
for (const member of groupEntries) {
if(!selected.includes(member)) {

View File

@ -22,8 +22,8 @@ import useHarkState from '~/logic/state/hark';
const localSel = selectLocalState(['toggleOmnibox']);
const StatusBar = (props) => {
const { ship } = props;
const StatusBar = () => {
const ship = window.ship;
const ourContact = useContactState(state => state.contacts[`~${ship}`]);
const metaKey = window.navigator.platform.includes('Mac') ? '⌘' : 'Ctrl+';
const { toggleOmnibox } = useLocalState(localSel);
@ -64,7 +64,6 @@ const StatusBar = (props) => {
borderColor='lightGray'
mr={2}
px={2}
{...props}
>
<Icon icon='Dashboard' color='black' />
</Button>

View File

@ -3,7 +3,6 @@ import { omit } from 'lodash';
import Mousetrap from 'mousetrap';
import fuzzy from 'fuzzy';
import _ from 'lodash';
import f from 'lodash/fp';
import React, {
ReactElement, useCallback,
useEffect, useMemo,
@ -17,30 +16,24 @@ import defaultApps from '~/logic/lib/default-apps';
import makeIndex, { OmniboxItem } from '~/logic/lib/omnibox';
import { useOutsideClick } from '~/logic/lib/useOutsideClick';
import { deSig } from '~/logic/lib/util';
import useLocalState from '~/logic/state/local';
import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group';
import useHarkState from '~/logic/state/hark';
import useInviteState from '~/logic/state/invite';
import useLaunchState from '~/logic/state/launch';
import { withLocalState } from '~/logic/state/local';
import useMetadataState from '~/logic/state/metadata';
import useSettingsState, { SettingsState } from '~/logic/state/settings';
import { Portal } from '../Portal';
import OmniboxInput from './OmniboxInput';
import OmniboxResult from './OmniboxResult';
interface OmniboxProps {
show: boolean;
toggle: () => void;
notifications: number;
}
import { selectLocalState } from '~/logic/state/local';
const SEARCHED_CATEGORIES = [
'commands',
'ships',
'other',
'groups',
'subscriptions',
'subscriptions'
];
const settingsSel = (s: SettingsState) => s.leap;
const CAT_LIMIT = 6;
@ -50,20 +43,22 @@ const CAT_LIMIT = 6;
*/
function flattenCattegoryMap(cats: string[], catMap: Map<string, OmniboxItem[]>) {
let res = [] as OmniboxItem[];
cats.forEach(cat => {
cats.forEach((cat) => {
res = res.concat(_.take(catMap.get(cat), CAT_LIMIT));
});
return res;
}
export function Omnibox(props: OmniboxProps): ReactElement {
const selOmnibox = selectLocalState(['omniboxShown', 'toggleOmnibox']);
export function Omnibox(): ReactElement {
const location = useLocation();
const history = useHistory();
const leapConfig = useSettingsState(settingsSel);
const omniboxRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const { omniboxShown: show, toggleOmnibox: toggle } = useLocalState(selOmnibox);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<[] | [string, string]>([]);
@ -102,17 +97,17 @@ export function Omnibox(props: OmniboxProps): ReactElement {
}, [selectedGroup, leapConfig, contacts, associations, groups]);
const onOutsideClick = useCallback(() => {
props.show && props.toggle();
}, [props.show, props.toggle]);
show && toggle();
}, [show, toggle]);
useOutsideClick(omniboxRef, onOutsideClick);
// handle omnibox show
useEffect(() => {
if (!props.show) {
if (!show) {
return;
}
Mousetrap.bind('escape', props.toggle);
Mousetrap.bind('escape', toggle);
const touchstart = new Event('touchstart');
// @ts-ignore ref typings
inputRef?.current?.input?.dispatchEvent(touchstart);
@ -122,7 +117,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
Mousetrap.unbind('escape');
setQuery('');
};
}, [props.show]);
}, [show]);
const initialResults = useMemo(() => {
return new Map<string, OmniboxItem[]>(
@ -145,7 +140,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
}
const q = query.toLowerCase();
const resultsMap = new Map<string, OmniboxItem[]>();
let categoryMaxes: Record<string, number> = {};
const categoryMaxes: Record<string, number> = {};
SEARCHED_CATEGORIES.map((category) => {
const categoryIndex = index.get(category);
@ -156,7 +151,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
.reduce((a,b) => Math.max(a,b), 0);
resultsMap.set(category, fuzzied.map(a => a.original));
});
let order = Object.entries(categoryMaxes)
const order = Object.entries(categoryMaxes)
.sort(([,a],[,b]) => b - a)
.map(([id]) => id);
return [resultsMap, order];
@ -164,7 +159,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
const navigate = useCallback(
(app: string, link: string, shift: boolean) => {
props.toggle();
toggle();
if (
defaultApps.includes(app.toLowerCase()) ||
app === 'profile' ||
@ -177,20 +172,20 @@ export function Omnibox(props: OmniboxProps): ReactElement {
if(shift && app === 'profile') {
// TODO: hacky, fix
link = link.replace('~profile', '~landscape/messages/dm');
}
}
if(link.startsWith('?')) {
history.push({
search: link
});
} else {
history.push(link);
}
} else {
window.location.href = link;
}
},
[history, props.toggle]
[history, toggle]
);
const setPreviousSelected = useCallback(() => {
@ -249,8 +244,8 @@ export function Omnibox(props: OmniboxProps): ReactElement {
if (query.length > 0) {
setQuery('');
return;
} else if (props.show) {
props.toggle();
} else if (show) {
toggle();
return;
}
}
@ -268,7 +263,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
}
if (evt.key === 'Enter') {
evt.preventDefault();
let values = flattenCattegoryMap(categoryOrder, results);
const values = flattenCattegoryMap(categoryOrder, results);
if (selected.length) {
navigate(selected[0], selected[1], evt.shiftKey);
} else if (values.length === 0) {
@ -283,11 +278,11 @@ export function Omnibox(props: OmniboxProps): ReactElement {
}
},
[
props.toggle,
toggle,
selected,
navigate,
query,
props.show,
show,
results,
categoryOrder,
setPreviousSelected,
@ -296,7 +291,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
);
useEffect(() => {
const flattenedResultLinks: [string, string][] =
const flattenedResultLinks: [string, string][] =
flattenCattegoryMap(categoryOrder, results)
.map(result => [result.app, result.link]);
if (!flattenedResultLinks.includes(selected as [string, string])) {
@ -308,24 +303,6 @@ export function Omnibox(props: OmniboxProps): ReactElement {
setQuery(event.target.value);
}, []);
// Sort Omnibox results alphabetically
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;
}
return 0;
};
const renderResults = useCallback(() => {
return (
<Box
@ -389,7 +366,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
top={0}
right={0}
zIndex={11}
display={props.show ? 'block' : 'none'}
display={show ? 'block' : 'none'}
>
<Row justifyContent='center'>
<Box
@ -419,5 +396,5 @@ export function Omnibox(props: OmniboxProps): ReactElement {
</Portal>
);
}
// @ts-ignore investigate zustand types
export default withLocalState(Omnibox, ['toggleOmnibox', 'omniboxShown']);
export default Omnibox;

View File

@ -5,12 +5,11 @@ import {
Text
} from '@tlon/indigo-react';
import { addTag, Association, Group, PermVariation, removeTag, metadataEdit, deSig } from '@urbit/api';
import { addTag, Association, Group, PermVariation, removeTag, metadataEdit, deSig, resourceFromPath } from '@urbit/api';
import { Form, Formik } from 'formik';
import _ from 'lodash';
import React from 'react';
import * as Yup from 'yup';
import { resourceFromPath } from '~/logic/lib/group';
import { FormGroupChild } from '~/views/components/FormGroup';
import { shipSearchSchemaInGroup } from '~/views/components/ShipSearch';
import { ChannelWritePerms } from '../ChannelWritePerms';

View File

@ -1,16 +1,15 @@
import { Box, Col, Row, Text } from '@tlon/indigo-react';
import {
Association,
deleteGraph,
deleteGraph,
Group,
leaveGraph,
metadataRemove
metadataRemove,
isChannelAdmin,
isHost
} from '@urbit/api';
import React, { useCallback } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { isChannelAdmin, isHost } from '~/logic/lib/group';
import { useHashLink } from '~/logic/lib/useHashLink';
import { FormGroup } from '~/views/components/FormGroup';
import { ModalOverlay } from '~/views/components/ModalOverlay';
@ -53,8 +52,8 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
return history.push(props.rootUrl);
};
const canAdmin = isChannelAdmin(group, association.resource);
const isOwner = isHost(association.resource);
const canAdmin = isChannelAdmin(group, association.resource, window.ship);
const isOwner = isHost(association.resource, window.ship);
return (
<ModalOverlay

View File

@ -1,22 +1,17 @@
import { Box } from '@tlon/indigo-react';
import React, { useCallback, useEffect } from 'react';
import React, { Suspense, useCallback, useEffect } from 'react';
import { Route, Switch, useHistory, useLocation } from 'react-router-dom';
import styled from 'styled-components';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import useMetadataState from '~/logic/state/metadata';
import LaunchApp from '~/views/apps/launch/App';
import Notifications from '~/views/apps/notifications/notifications';
import { PermalinkRoutes } from '~/views/apps/permalinks/app';
import Profile from '~/views/apps/profile/profile';
import Settings from '~/views/apps/settings/settings';
import ErrorComponent from '~/views/components/Error';
import { useShortcut } from '~/logic/state/settings';
import { Loading } from '~/views/components/Loading';
import LaunchApp from '~/views/apps/launch/App';
import Landscape from '~/views/landscape/index';
import GraphApp from '../../apps/graph/App';
import { getNotificationRedirect } from '~/logic/lib/notificationRedirects';
import {JoinRoute} from './Join/Join';
import { JoinRoute } from './Join/Join';
import useInviteState from '~/logic/state/invite';
import useMetadataState from '~/logic/state/metadata';
export const Container = styled(Box)`
flex-grow: 1;
@ -25,7 +20,13 @@ export const Container = styled(Box)`
height: calc(100% - 62px);
`;
export const Content = (props) => {
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();
const mdLoaded = useMetadataState(s => s.loaded);
@ -74,54 +75,42 @@ export const Content = (props) => {
return (
<Container>
<JoinRoute />
<Switch>
<Route
exact
path="/" render={p => (
<LaunchApp
location={p.location}
match={p.match}
{...props}
/>
)}
/>
<Route path='/~landscape'>
<Landscape />
</Route>
<Route
path="/~profile"
render={ p => (
<Profile
{...props}
/>
)}
/>
<Route
path="/~settings"
render={ p => (
<Settings {...props} />
)}
/>
<Route
path="/~notifications"
render={ p => (
<Notifications {...props} />
)}
/>
<GraphApp path="/~graph" {...props} />
<PermalinkRoutes {...props} />
<Suspense fallback={Loading}>
<JoinRoute />
<Switch>
<Route
exact
path="/"
component={LaunchApp}
/>
<Route path='/~landscape'>
<Landscape />
</Route>
<Route
path="/~profile"
component={Profile}
/>
<Route
path="/~settings"
component={Settings}
/>
<Route
path="/~notifications"
component={Notifications}
/>
<PermalinkRoutes />
<Route
render={p => (
<ErrorComponent
code={404}
description="Not Found"
{...p}
/>
)}
/>
</Switch>
<Route
render={p => (
<ErrorComponent
code={404}
description="Not Found"
{...p}
/>
)}
/>
</Switch>
</Suspense>
</Container>
);
};

View File

@ -1,8 +1,7 @@
import { Button, Col, Icon, Label, Row, Text } from '@tlon/indigo-react';
import { Association, deleteGroup, leaveGroup } from '@urbit/api';
import { Association, deleteGroup, leaveGroup, resourceFromPath } from '@urbit/api';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { resourceFromPath } from '~/logic/lib/group';
import { useModal } from '~/logic/lib/useModal';
import { StatelessAsyncButton } from '~/views/components/StatelessAsyncButton';
import airlock from '~/logic/api';

View File

@ -5,11 +5,10 @@ import {
Text
} from '@tlon/indigo-react';
import _ from 'lodash';
import { Association, changePolicy, deSig, Enc, Group, GroupPolicy, metadataEdit, MetadataEditField } from '@urbit/api';
import { Association, changePolicy, deSig, Enc, Group, GroupPolicy, metadataEdit, MetadataEditField, resourceFromPath, roleForShip } from '@urbit/api';
import { Form, Formik, FormikHelpers } from 'formik';
import React from 'react';
import * as Yup from 'yup';
import { resourceFromPath, roleForShip } from '~/logic/lib/group';
import { uxToHex } from '~/logic/lib/util';
import { AsyncButton } from '~/views/components/AsyncButton';
import { ColorInput } from '~/views/components/ColorInput';

View File

@ -1,7 +1,6 @@
import { Col, Icon, Row, Text } from '@tlon/indigo-react';
import { Association, Group, metadataRemove, metadataEdit, deSig } from '@urbit/api';
import { Association, Group, metadataRemove, metadataEdit, deSig, resourceFromPath, roleForShip } from '@urbit/api';
import React, { useCallback } from 'react';
import { resourceFromPath, roleForShip } from '~/logic/lib/group';
import { getModuleIcon, GraphModule } from '~/logic/lib/util';
import useMetadataState from '~/logic/state/metadata';
import { Dropdown } from '~/views/components/Dropdown';

View File

@ -1,8 +1,7 @@
import { Box, Button, Col, Text } from '@tlon/indigo-react';
import { Association, deSig, Group } from '@urbit/api';
import { Association, deSig, Group, resourceFromPath, roleForShip } from '@urbit/api';
import React, { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { resourceFromPath, roleForShip } from '~/logic/lib/group';
import { GroupAdminSettings } from './Admin';
import { GroupChannelSettings } from './Channels';
import { GroupFeedSettings } from './GroupFeed';

View File

@ -1,4 +1,3 @@
import { readGroup } from '@urbit/api';
import _ from 'lodash';
import React, { useCallback, useEffect } from 'react';
import Helmet from 'react-helmet';
@ -13,26 +12,27 @@ import { getGroupFromWorkspace } from '~/logic/lib/workspace';
import useGroupState from '~/logic/state/group';
import useHarkState from '~/logic/state/hark';
import useMetadataState from '~/logic/state/metadata';
import { DmResource } from '~/views/apps/chat/DmResource';
import { Workspace } from '~/types/workspace';
import '~/views/apps/links/css/custom.css';
import '~/views/apps/publish/css/custom.css';
import { Loading } from '~/views/components/Loading';
import { UnjoinedResource } from '~/views/components/UnjoinedResource';
import { EmptyGroupHome } from './Home/EmptyGroupHome';
import { GroupHome } from './Home/GroupHome';
import { InvitePopover } from './InvitePopover';
import { NewChannel } from './NewChannel';
import { PopoverRoutes } from './PopoverRoutes';
import { Resource } from './Resource';
import { Skeleton } from './Skeleton';
import {Join, JoinRoute} from './Join/Join';
import { EmptyGroupHome } from './Home/EmptyGroupHome';
import { Join } from './Join/Join';
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 associations = useMetadataState(state => state.associations);
@ -66,7 +66,6 @@ export function GroupsPane(props: GroupsPaneProps) {
doneJoin(group);
}
return () => {
setRecentGroups(gs => _.uniq([workspace.group, ...gs]));
};
@ -183,31 +182,29 @@ export function GroupsPane(props: GroupsPaneProps) {
</>
);
}}
/>
<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 width="100%">
<Join desc={desc} />
</Box>
</Skeleton>
)
}}
>
</Route>
/>
<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 width="100%">
<Join desc={desc} />
</Box>
</Skeleton>
);
}}
/>
<Route
path={relativePath('/new')}
render={(routeProps) => {
@ -254,7 +251,7 @@ export function GroupsPane(props: GroupsPaneProps) {
associations={associations}
/>
)}
{popovers(routeProps, baseUrl)}
{popovers(routeProps, baseUrl)}
</Skeleton>
</>
);

View File

@ -1,8 +1,7 @@
import { Button, Icon, Row, Text } from '@tlon/indigo-react';
import { disableGroupFeed } from '@urbit/api';
import { disableGroupFeed, resourceFromPath } from '@urbit/api';
import React, { useState } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { resourceFromPath } from '~/logic/lib/group';
import airlock from '~/logic/api';
export const AddFeedBanner = (props) => {

View File

@ -1,10 +1,9 @@
import { Col } from '@tlon/indigo-react';
import { deSig, markCountAsRead } from '@urbit/api';
import { deSig, markCountAsRead, resourceFromPath } from '@urbit/api';
import React, {
useEffect
} from 'react';
import { Route, Switch, useHistory } from 'react-router-dom';
import { resourceFromPath } from '~/logic/lib/group';
import useGraphState from '~/logic/state/graph';
import useGroupState from '~/logic/state/group';
import useMetadataState from '~/logic/state/metadata';

View File

@ -3,7 +3,6 @@ import React, {
useEffect
} from 'react';
import { Route, Switch, useHistory } from 'react-router-dom';
import { resourceFromPath } from '~/logic/lib/group';
import useGraphState, { useGraphTimesentMap } from '~/logic/state/graph';
import { useGroup } from '~/logic/state/group';
import { useAssocForGraph } from '~/logic/state/metadata';
@ -12,7 +11,7 @@ import { GroupFeedHeader } from './GroupFeedHeader';
import { PostThreadRoutes } from './Post/PostThread';
import PostFlatTimeline from './Post/PostFlatTimeline';
import airlock from '~/logic/api';
import { markCountAsRead } from '@urbit/api';
import { markCountAsRead, resourceFromPath } from '@urbit/api';
import { PostRepliesRoutes } from './Post/PostReplies';
import { toHarkPlace } from '~/logic/lib/util';

View File

@ -1,10 +1,9 @@
import { Box, Col } from '@tlon/indigo-react';
import { Association, Graph, GraphNode, Group } from '@urbit/api';
import { Association, Graph, GraphNode, Group, resourceFromPath } from '@urbit/api';
import { History } from 'history';
import bigInt from 'big-integer';
import React from 'react';
import { useHistory } from 'react-router';
import { resourceFromPath } from '~/logic/lib/group';
import PostItem from './PostItem/PostItem';
import PostInput from './PostInput';
import { GraphScroller } from '~/views/components/GraphScroller';

View File

@ -1,9 +1,8 @@
import { Box, Col } from '@tlon/indigo-react';
import { arrToString, Association, FlatGraph, FlatGraphNode, Group } from '@urbit/api';
import { arrToString, Association, FlatGraph, FlatGraphNode, Group, resourceFromPath } from '@urbit/api';
import bigInt from 'big-integer';
import React from 'react';
import { RouteComponentProps, useHistory } from 'react-router';
import { resourceFromPath } from '~/logic/lib/group';
import { keyEq, ThreadScroller } from '~/views/components/ThreadScroller';
import PostItem from './PostItem/PostItem';
import PostInput from './PostInput';

View File

@ -1,9 +1,8 @@
import { Box, Col, Text } from '@tlon/indigo-react';
import { Association, Group } from '@urbit/api';
import { Association, Group, resourceFromPath } from '@urbit/api';
import React, { ReactElement } from 'react';
import { Loading } from '~/views/components/Loading';
import { useFlatGraph } from '~/logic/state/graph';
import { resourceFromPath } from '~/logic/lib/group';
import PostFlatFeed from './PostFlatFeed';
import PostInput from './PostInput';

View File

@ -1,9 +1,19 @@
import { BaseTextArea, Box, Button, Icon, LoadingSpinner, Row } from '@tlon/indigo-react';
import { Association, Content, createPost, evalCord, Group, Path } from '@urbit/api';
import {
Association,
Content,
createPost,
evalCord,
Group,
Path,
isChannelAdmin,
isHost,
isWriter,
resourceFromPath
} from '@urbit/api';
import React, {
ReactElement, useCallback, useState
} from 'react';
import { isChannelAdmin, isHost, isWriter, resourceFromPath } from '~/logic/lib/group';
import tokenizeMessage from '~/logic/lib/tokenizeMessage';
import useStorage from '~/logic/lib/useStorage';
import { useToggleState } from '~/logic/lib/useToggleState';
@ -20,13 +30,13 @@ function canWrite(props) {
}
if(vip === 'admin-feed') {
return isChannelAdmin(group, association.group);
return isChannelAdmin(group, association.group, window.ship);
}
if(vip === 'host-feed') {
return isHost(association.group);
return isHost(association.group, window.ship);
}
return isWriter(group, association.resource);
return isWriter(group, association.resource, window.ship);
}
interface PostInputProps {

View File

@ -1,10 +1,9 @@
import { Action, Col, Icon, Row } from '@tlon/indigo-react';
import { Association, Post, removePosts } from '@urbit/api';
import { Association, Post, removePosts, resourceFromPath } from '@urbit/api';
import React, { ReactElement } from 'react';
import { getPermalinkForGraph } from '~/logic/lib/permalinks';
import { useCopy } from '~/logic/lib/useCopy';
import useContactState from '~/logic/state/contact';
import { resourceFromPath } from '~/logic/lib/group';
import Author from '~/views/components/Author';
import { Dropdown } from '~/views/components/Dropdown';
import airlock from '~/logic/api';

View File

@ -1,11 +1,10 @@
import { Box, Col, Row, Text } from '@tlon/indigo-react';
import { Association, GraphNode, Group, Post } from '@urbit/api';
import { Association, GraphNode, Group, Post, isWriter } from '@urbit/api';
import { BigInteger } from 'big-integer';
import { History } from 'history';
import React, { useCallback, useMemo, useState } from 'react';
import { useHistory } from 'react-router';
import { getPostRoute } from '~/logic/lib/graph';
import { isWriter } from '~/logic/lib/group';
import { useHovering } from '~/logic/lib/util';
import { Mention } from '~/views/components/MentionText';
import PostInput from '../PostInput';
@ -61,7 +60,7 @@ function PostItem(props: PostItemProps) {
if (index && index.length > 0) {
return true;
}
return isWriter(group, association.resource);
return isWriter(group, association.resource, window.ship);
}, [group, association.resource, vip, index]);
const navigateToChildren = useCallback(() => {

View File

@ -1,7 +1,6 @@
import { Box, Col, Text } from '@tlon/indigo-react';
import { Association, PermVariation } from '@urbit/api';
import { Association, PermVariation, resourceFromPath } from '@urbit/api';
import React, { useEffect } from 'react';
import { resourceFromPath } from '~/logic/lib/group';
import useGraphState, { GraphState, useGraph } from '~/logic/state/graph';
import { Loading } from '~/views/components/Loading';
import PostFeed from './PostFeed';

View File

@ -4,12 +4,11 @@ import React, {
useCallback,
useEffect
} from 'react';
import { resourceFromPath } from '~/logic/lib/group';
import { Loading } from '~/views/components/Loading';
import useGraphState from '~/logic/state/graph';
import PostFlatFeed from './PostFlatFeed';
import PostInput from './PostInput';
import { arrToString, Association, deSig, PermVariation } from '@urbit/api';
import { arrToString, Association, deSig, PermVariation, resourceFromPath } from '@urbit/api';
import { useParams, Switch, Route } from 'react-router';
import { useGroupForAssoc } from '~/logic/state/group';

View File

@ -5,13 +5,12 @@ import {
Row, Text
} from '@tlon/indigo-react';
import { Association, invite } from '@urbit/api';
import { Association, invite, resourceFromPath } from '@urbit/api';
import { Form, Formik } from 'formik';
import _ from 'lodash';
import React, { useCallback, useRef } from 'react';
import { Route, Switch, useHistory } from 'react-router-dom';
import * as Yup from 'yup';
import { resourceFromPath } from '~/logic/lib/group';
import { useOutsideClick } from '~/logic/lib/useOutsideClick';
import { deSig } from '~/logic/lib/util';
import { Workspace } from '~/types/workspace';

View File

@ -14,12 +14,10 @@ import { useHistory, useLocation } from 'react-router-dom';
import useGroupState from '~/logic/state/group';
import { useInviteForResource } from '~/logic/state/invite';
import useMetadataState, { usePreview } from '~/logic/state/metadata';
import { decline, Invite } from '@urbit/api';
import { join, JoinRequest } from '@urbit/api/groups';
import { joinError, joinLoad, JoinProgress, join, JoinRequest, decline, Invite } from '@urbit/api';
import airlock from '~/logic/api';
import { joinError, joinLoad, JoinProgress } from '@urbit/api';
import { useQuery } from '~/logic/lib/useQuery';
import { JoinKind, JoinDesc, JoinSkeleton } from './Skeleton';
import { JoinDesc, JoinKind, JoinSkeleton } from './Skeleton';
import { preSig } from '~/logic/lib/util';
interface InviteWithUid extends Invite {
@ -160,7 +158,7 @@ function JoinError(props: {
const explanation =
request.progress === 'no-perms'
? 'You do not have the correct permissions'
: 'An unexpected error occurred';
: 'An unexpected error occurred';
const onRetry = () => {
useGroupState.getState().abortJoin(desc.group);

View File

@ -1,27 +1,23 @@
import React, { useEffect, useState } from "react";
import React from 'react';
import {
Col,
Row,
Text,
Box,
Button,
ManagedTextInputField,
ManagedCheckboxField,
ContinuousProgressBar,
} from "@tlon/indigo-react";
import { ModalOverlay } from "~/views/components/ModalOverlay";
import Author from "~/views/components/Author";
import { GroupSummary } from "../GroupSummary";
Box
} from '@tlon/indigo-react';
import { ModalOverlay } from '~/views/components/ModalOverlay';
import Author from '~/views/components/Author';
import { GroupSummary } from '../GroupSummary';
import { resourceFromPath } from "~/logic/lib/group";
import { resourceFromPath } from '@urbit/api';
import useMetadataState, { usePreview } from "~/logic/state/metadata";
import useInviteState, { useInviteForResource } from "~/logic/state/invite";
import {useHistory} from "react-router-dom";
import { usePreview } from '~/logic/state/metadata';
import { useInviteForResource } from '~/logic/state/invite';
import { useHistory } from 'react-router-dom';
const SUMMARY_HEIGHT = "96px";
const SUMMARY_HEIGHT = '96px';
export type JoinKind = "graph" | "groups";
export type JoinKind = 'graph' | 'groups';
export interface JoinDesc {
group: string;
@ -38,7 +34,7 @@ interface JoinSkeletonProps {
}
export function JoinSkeleton(props: JoinSkeletonProps) {
const { title, body, children, onJoin, desc, modal } = props;
const { title, children, desc, modal } = props;
const history = useHistory();
const dismiss = () => {
history.push({ search: '' });
@ -46,24 +42,24 @@ export function JoinSkeleton(props: JoinSkeletonProps) {
const inner = (
<Col
width={modal ? ["90vw", "384px"] : undefined}
borderRadius="2"
backgroundColor="white"
width={modal ? ['90vw', '384px'] : undefined}
borderRadius='2'
backgroundColor='white'
>
<Col
gapY="4"
p="4"
borderRadius="2"
backgroundColor="washedGray"
justifyContent="space-between"
gapY='4'
p='4'
borderRadius='2'
backgroundColor='washedGray'
justifyContent='space-between'
flexGrow={1}
>
<Box maxWidth="512px">
<Text fontWeight="medium" fontSize="2">
<Box maxWidth='512px'>
<Text fontWeight='medium' fontSize='2'>
{title}
</Text>
</Box>
{!!desc ? <JoinBody desc={desc} /> : null}
{desc ? <JoinBody desc={desc} /> : null}
</Col>
{children}
</Col>
@ -78,30 +74,30 @@ export function JoinSkeleton(props: JoinSkeletonProps) {
export function JoinBody(props: { desc: JoinDesc }) {
const { desc } = props;
const { group, kind } = desc || {};
const { preview, error } = usePreview(group);
const { preview } = usePreview(group);
const { ship, name } = resourceFromPath(group);
const invite = useInviteForResource(kind, ship, name);
return (
<>
{!desc ? "Prompt invite link" : null}
{!desc ? 'Prompt invite link' : null}
{preview ? (
<GroupSummary
memberCount={preview.members}
channelCount={preview["channel-count"]}
channelCount={preview['channel-count']}
metadata={preview.metadata}
height={SUMMARY_HEIGHT}
width="100%"
maxWidth="100%"
overflow="hidden"
width='100%'
maxWidth='100%'
overflow='hidden'
/>
) : (
<FallbackSummary path={group} />
)}
{invite ? (
<Col gapY="2">
<Col gapY='2'>
<Box>
<Text>
<Text mono>{invite.ship}</Text> <Text gray>invited you</Text>
@ -109,7 +105,7 @@ export function JoinBody(props: { desc: JoinDesc }) {
</Box>
{invite.text?.length > 0 ? (
<Box>
<Text>"{invite.text}"</Text>
<Text>&quot;{ invite.text }&quot;</Text>
</Box>
) : null}
</Col>
@ -120,18 +116,18 @@ export function JoinBody(props: { desc: JoinDesc }) {
function FallbackSummary(props: { path: string }) {
const { path } = props;
const [, , ship, name] = path.split("/");
const [, , ship, name] = path.split('/');
return (
<Row
height={SUMMARY_HEIGHT}
width="100%"
overflow="hidden"
alignItems="center"
gapX="0"
width='100%'
overflow='hidden'
alignItems='center'
gapX='0'
>
<Author gray fullNotIcon size={40} showImage ship={ship} dontShowTime />
<Text mono whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">
<Text mono whiteSpace='nowrap' overflow='hidden' textOverflow='ellipsis'>
/{name}
</Text>
</Row>

View File

@ -1,10 +1,9 @@
import { Box, Col, Text } from '@tlon/indigo-react';
import { invite } from '@urbit/api';
import { invite, resourceFromPath } from '@urbit/api';
import { Form, Formik } from 'formik';
import _ from 'lodash';
import React from 'react';
import * as Yup from 'yup';
import { resourceFromPath } from '~/logic/lib/group';
import { deSig } from '~/logic/lib/util';
import { AsyncButton } from '~/views/components/AsyncButton';
import { ShipSearch } from '~/views/components/ShipSearch';

View File

@ -4,13 +4,12 @@ import {
ManagedTextInputField as Input,
Text
} from '@tlon/indigo-react';
import { addTag, createManagedGraph, createUnmanagedGraph } from '@urbit/api';
import { addTag, createManagedGraph, createUnmanagedGraph, resourceFromPath } from '@urbit/api';
import { Form, Formik } from 'formik';
import _ from 'lodash';
import React, { ReactElement } from 'react';
import { useHistory, useRouteMatch } from 'react-router-dom';
import * as Yup from 'yup';
import { resourceFromPath } from '~/logic/lib/group';
import { useWaitForProps } from '~/logic/lib/useWaitForProps';
import { deSig, parentPath, stringToSymbol } from '~/logic/lib/util';
import useGroupState from '~/logic/state/group';

View File

@ -6,7 +6,7 @@ import {
StatelessTextInput as Input, Text
} from '@tlon/indigo-react';
import { addTag, Association, Contact, Contacts, changePolicy, deSig, Group, removeMembers, removeTag, RoleTags } from '@urbit/api';
import { addTag, Association, Contact, Contacts, changePolicy, deSig, Group, removeMembers, removeTag, RoleTags, resourceFromPath, roleForShip } from '@urbit/api';
import _ from 'lodash';
import f from 'lodash/fp';
import React, {
@ -16,7 +16,6 @@ import React, {
import { Link } from 'react-router-dom';
import VisibilitySensor from 'react-visibility-sensor';
import styled from 'styled-components';
import { resourceFromPath, roleForShip } from '~/logic/lib/group';
import { Sigil } from '~/logic/lib/sigil';
import { cite, uxToHex } from '~/logic/lib/util';
import useContactState from '~/logic/state/contact';
@ -60,7 +59,7 @@ const emptyContact = (patp: string, pending: boolean): Participant => ({
function getParticipants(cs: Contacts, group: Group) {
const contacts: Participant[] = _.flow(
f.omitBy<Contacts>((_c, patp) => !group.members.has(deSig(patp))),
f.omitBy<Contacts>((_c, patp) => !_.includes(group.members, deSig(patp))),
f.toPairs,
f.map(([patp, c]: [string, Contact]) => ({
...c,
@ -83,8 +82,8 @@ function getParticipants(cs: Contacts, group: Group) {
const incPending = _.unionBy(allMembers, pending, 'patp');
return [
incPending,
incPending.length - group.members.size,
group.members.size
incPending.length - group.members.length,
group.members.length
] as const;
}
@ -109,11 +108,14 @@ export function Participants(props: {
ParticipantsTabId,
(p: Participant) => boolean
> = useMemo(
() => ({
total: p => !p.pending,
pending: p => p.pending,
admin: p => props.group.tags?.role?.admin?.has(p.patp)
}),
() => {
const admins = props.group.tags?.role?.admin;
return {
total: p => !p.pending,
pending: p => p.pending,
admin: p => _.includes(admins, p.patp)
};
},
[props.group]
);
@ -130,7 +132,7 @@ export function Participants(props: {
[setSearch]
);
const adminCount = props.group.tags?.role?.admin?.size || 0;
const adminCount = props.group.tags?.role?.admin?.length || 0;
const isInvite = 'invite' in props.group.policy;
const contacts = useContactState(state => state.contacts);
@ -362,7 +364,7 @@ function Participant(props: {
</StatelessAsyncAction>
)}
{role === 'admin' ? (
group?.tags?.role?.admin?.size > 1 && (<StatelessAsyncAction onClick={onDemote} bg="transparent">
group?.tags?.role?.admin?.length > 1 && (<StatelessAsyncAction onClick={onDemote} bg="transparent">
Demote from Admin
</StatelessAsyncAction>)
) : (

View File

@ -1,8 +1,8 @@
import _ from 'lodash';
import { Box, Col, Text } from '@tlon/indigo-react';
import { Association, deSig, Group } from '@urbit/api';
import { Association, deSig, Group, resourceFromPath } from '@urbit/api';
import React, { ReactElement, useCallback, useRef } from 'react';
import { Link, Route, RouteComponentProps, Switch } from 'react-router-dom';
import { resourceFromPath } from '~/logic/lib/group';
import { useHashLink } from '~/logic/lib/useHashLink';
import { ModalOverlay } from '~/views/components/ModalOverlay';
import { SidebarItem } from '~/views/landscape/components/SidebarItem';
@ -26,11 +26,12 @@ export function PopoverRoutes(
useHashLink();
const groupSize = props.group.members.size;
const groupSize = props.group.members.length;
const ship = resourceFromPath(props.association?.group ?? '/ship/~zod/group').ship;
const owner = deSig(ship) === window.ship;
const admin = props.group?.tags?.role?.admin.has(window.ship) || false;
const admins = props.group?.tags?.role?.admin;
const admin = (admins && _.includes(admins, window.ship)) || false;
return (
<Switch>

View File

@ -1,11 +1,10 @@
import _ from 'lodash';
import { Box, Col, Icon, Text } from '@tlon/indigo-react';
import { AppName, Association } from '@urbit/api';
import { AppName, Association, isWriter } from '@urbit/api';
import React, { ReactElement, ReactNode, useCallback, useState } from 'react';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import urbitOb from 'urbit-ob';
import { isWriter } from '~/logic/lib/group';
import { useResize } from '~/logic/lib/useResize';
import { getItemTitle } from '~/logic/lib/util';
import useContactState from '~/logic/state/contact';
@ -103,7 +102,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
const isOwn = `~${window.ship}` === ship;
let canWrite = (app === 'publish') ? true : false;
if (!isWriter(group, association.resource)) {
if (!isWriter(group, association.resource, window.ship)) {
canWrite = isOwn;
}

View File

@ -3,7 +3,7 @@ import {
} from '@tlon/indigo-react';
import React, { ReactElement } from 'react';
import styled from 'styled-components';
import { roleForShip } from '~/logic/lib/group';
import { roleForShip } from '@urbit/api';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import { getGroupFromWorkspace } from '~/logic/lib/workspace';
import useGroupState from '~/logic/state/group';

View File

@ -10,7 +10,7 @@ import {
import { FormikHelpers } from 'formik';
import React, { ReactElement, useCallback } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { roleForShip } from '~/logic/lib/group';
import { roleForShip } from '@urbit/api';
import { getGroupFromWorkspace } from '~/logic/lib/workspace';
import useGroupState from '~/logic/state/group';
import useHarkState from '~/logic/state/hark';

View File

@ -1,9 +1,10 @@
import React, { ReactElement, ReactNode, useCallback, useState } from 'react';
import React, { ReactElement, ReactNode, Suspense, useCallback, useState } from 'react';
import { Sidebar } from './Sidebar/Sidebar';
import { Workspace } from '~/types/workspace';
import { Body } from '~/views/components/Body';
import ErrorBoundary from '~/views/components/ErrorBoundary';
import { useShortcut } from '~/logic/state/settings';
import { Loading } from '~/views/components/Loading';
interface SkeletonProps {
children: ReactNode;
@ -41,7 +42,7 @@ export const Skeleton = React.memo((props: SkeletonProps): ReactElement => {
/>
)}
</ErrorBoundary>
{props.children}
<Suspense fallback={Loading}>{props.children}</Suspense>
</Body>
);
});

View File

@ -7,10 +7,11 @@ import useHarkState from '~/logic/state/hark';
import { Workspace } from '~/types/workspace';
import { Body } from '../components/Body';
import { GroupsPane } from './components/GroupsPane';
import { NewGroup } from './components/NewGroup';
import './css/custom.css';
import _ from 'lodash';
const NewGroup = React.lazy(() => import('./components/NewGroup').then(module => ({ default: module.NewGroup })));
moment.updateLocale('en', {
relativeTime : {
future: '%s',

View File

@ -1,8 +0,0 @@
import React from 'react';
if (false && process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, {
trackAllPureComponents: true
});
}

View File

@ -1,3 +1,4 @@
import { deSig } from '../index';
import { Enc, Path, Patp, PatpNoSig, Poke, Thread } from '../lib/types';
import { Group, GroupPolicy, GroupPolicyDiff, GroupUpdateAddMembers, GroupUpdateAddTag, GroupUpdateChangePolicy, GroupUpdateRemoveGroup, GroupUpdateRemoveMembers, GroupUpdateRemoveTag, Resource, RoleTags, Tag } from './types';
import { GroupUpdate } from './update';
@ -95,10 +96,6 @@ export const changePolicy = (
}
});
export const makeResource = (ship: string, name: string) => {
return { ship, name };
};
export const join = (
ship: string,
name: string,
@ -166,8 +163,8 @@ export const roleTags = ['janitor', 'moderator', 'admin'];
// TODO make this type better?
export const groupBunts = {
group: (): Group => ({ members: new Set(), tags: { role: {} }, hidden: false, policy: groupBunts.policy() }),
policy: (): GroupPolicy => ({ open: { banned: new Set(), banRanks: new Set() } })
group: (): Group => ({ members: [], tags: { role: {} }, hidden: false, policy: groupBunts.policy() }),
policy: (): GroupPolicy => ({ open: { banned: [], banRanks: [] } })
};
export const joinError = ['no-perms', 'strange', 'abort'] as const;
@ -175,51 +172,55 @@ export const joinResult = ['done', ...joinError] as const;
export const joinLoad = ['start', 'added', 'metadata'] as const;
export const joinProgress = [...joinLoad, ...joinResult] as const;
export const roleForShip = (
export function roleForShip(
group: Group,
ship: PatpNoSig
): RoleTags | undefined => {
): RoleTags | undefined {
return roleTags.reduce((currRole, role) => {
const roleShips = group?.tags?.role?.[role];
return roleShips && roleShips.has(ship) ? role : currRole;
return roleShips && roleShips.includes(ship) ? role : currRole;
}, undefined as RoleTags | undefined);
};
export const resourceFromPath = (path: Path): Resource => {
export function resourceFromPath(path: Path): Resource {
const [, , ship, name] = path.split('/');
return { ship, name };
};
}
export function makeResource(ship: string, name: string) {
return { ship, name };
}
export const isWriter = (group: Group, resource: string, ship: string) => {
const graph = group.tags?.graph;
const writers: Set<string> | undefined = graph && (graph[resource] as any)?.writers;
const admins = group?.tags?.role?.admin ?? new Set();
const graph = group?.tags?.graph;
const writers: string[] | undefined = graph && (graph[resource] as any)?.writers;
const admins = group?.tags?.role?.admin ?? [];
if (typeof writers === 'undefined') {
return true;
} else {
return writers.has(ship) || admins.has(ship);
return writers.includes(ship) || admins.includes(ship);
}
};
export const isHost = (
resource: string,
ship: string
): boolean => {
const [, , host] = resource.split('/');
return ship === host;
};
export const isChannelAdmin = (
export function isChannelAdmin(
group: Group,
resource: string,
ship: string
): boolean => {
const role = roleForShip(group, ship.slice(1));
): boolean {
const role = roleForShip(group, deSig(ship));
return (
isHost(resource, ship) ||
role === 'admin' ||
role === 'moderator'
);
};
}
export function isHost(
resource: string,
ship: string
): boolean {
const [, , host] = resource.split('/');
return ship === host;
}

View File

@ -16,14 +16,14 @@ export type Tag = AppTag | RoleTag;
export interface InvitePolicy {
invite: {
pending: Set<PatpNoSig>;
pending: PatpNoSig[];
};
}
export interface OpenPolicy {
open: {
banned: Set<PatpNoSig>;
banRanks: Set<ShipRank>;
banned: PatpNoSig[];
banRanks: ShipRank[];
};
}
@ -76,7 +76,7 @@ export type GroupPolicyDiff =
export type GroupPolicy = OpenPolicy | InvitePolicy;
export interface TaggedShips {
[tag: string]: Set<PatpNoSig>;
[tag: string]: PatpNoSig[];
}
export interface Tags {
@ -85,7 +85,7 @@ export interface Tags {
}
export interface Group {
members: Set<PatpNoSig>;
members: PatpNoSig[];
tags: Tags;
policy: GroupPolicy;
hidden: boolean;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff