Merge branch 'master' into next/npm

This commit is contained in:
Patrick O'Sullivan 2022-04-27 14:44:27 -05:00
commit 67aa4565f1
160 changed files with 48192 additions and 29512 deletions

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
16.14.0

View File

@ -175,6 +175,7 @@ command):
> |mount %bitcoin
> |mount %webterm
% rsync -avL --delete pkg/arvo/ zod/base/
% rm -rf zod/base/tests/
% for desk in garden landscape bitcoin webterm; do \
rsync -avL --delete pkg/$desk/ zod/$desk/ \
done
@ -184,7 +185,7 @@ command):
> |commit %bitcoin
> |commit %webterm
> .multi/pill +solid %base %garden %landscape %bitcoin %webterm
> .brass-multi/pill +brass %base %garden %landscape %bitcoin %webterm
> .multi-brass/pill +brass %base %garden %landscape %bitcoin %webterm
```
And then of course:

3
package-lock.json generated
View File

@ -11,6 +11,9 @@
"lerna": "^4.0.0",
"lint-staged": "^11.1.2",
"prettier": "^2.3.2"
},
"engines": {
"node": "16.14.0"
}
},
"node_modules/@babel/code-frame": {

View File

@ -1,6 +1,9 @@
{
"name": "root",
"private": true,
"engines": {
"node": "16.14.0"
},
"devDependencies": {
"eslint": "^7.29.0",
"husky": "^6.0.0",

View File

@ -171,6 +171,7 @@
++ dejs
=, dejs:format
|%
++ ship (su ;~(pfix sig fed:ag))
:: TODO: fix +stab
::
++ pa
@ -218,6 +219,26 @@
:~ lid+lid
bin+bin
==
++ content
%- of
:~ text+so
ship+ship
==
::
++ body
%- ot
:~ title+(ar content)
content+(ar content)
time+di
binned+pa
link+pa
==
::
++ add-note
%- ot
:~ bin+bin
body+body
==
::
++ action
^- $-(json ^action)
@ -228,6 +249,7 @@
read-count+place
read-each+read-each
read-note+bin
add-note+add-note
==
--
--

View File

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

View File

@ -1,10 +1,10 @@
:~ title+'System'
info+'An app launcher for Urbit.'
color+0xee.5432
glob-http+['https://bootstrap.urbit.org/glob-0v5.1o2c9.g1btf.nandl.703oh.40up1.glob' 0v5.1o2c9.g1btf.nandl.703oh.40up1]
glob-http+['https://bootstrap.urbit.org/glob-0v2.p3f6i.19q8d.lsgcb.mckg7.dtu8f.glob' 0v2.p3f6i.19q8d.lsgcb.mckg7.dtu8f]
::glob-ames+~zod^0v0
base+'grid'
version+[1 0 3]
version+[1 1 3]
website+'https://tlon.io'
license+'MIT'
==

25679
pkg/grid/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,8 +15,11 @@
"tsc": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-checkbox": "^0.1.5",
"@fingerprintjs/fingerprintjs": "^3.3.3",
"@radix-ui/react-dialog": "^0.0.20",
"@radix-ui/react-dropdown-menu": "^0.0.23",
"@radix-ui/react-icons": "^1.1.0",
"@radix-ui/react-polymorphic": "^0.0.13",
"@radix-ui/react-portal": "^0.0.15",
"@radix-ui/react-toggle": "^0.0.10",
@ -36,6 +39,9 @@
"postcss-import": "^14.0.2",
"query-string": "^7.0.1",
"react": "^17.0.2",
"react-dnd": "^15.1.1",
"react-dnd-html5-backend": "^15.1.2",
"react-dnd-touch-backend": "^15.1.1",
"react-dom": "^17.0.2",
"react-error-boundary": "^3.1.3",
"react-router-dom": "^5.2.0",

View File

@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import Mousetrap from 'mousetrap';
import { BrowserRouter, Switch, Route, useHistory, useLocation } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import FingerprintJS from '@fingerprintjs/fingerprintjs';
import { Grid } from './pages/Grid';
import useDocketState from './state/docket';
import { PermalinkRoutes } from './pages/PermalinkRoutes';
@ -10,23 +11,46 @@ import useContactState from './state/contact';
import api from './state/api';
import { useMedia } from './logic/useMedia';
import { useHarkStore } from './state/hark';
import { useTheme } from './state/settings';
import { useLocalState } from './state/local';
import { useSettingsState, useTheme } from './state/settings';
import { useBrowserId, useLocalState } from './state/local';
import { ErrorAlert } from './components/ErrorAlert';
import { useErrorHandler } from './logic/useErrorHandler';
const getNoteRedirect = (path: string) => {
if (path.startsWith('/desk/')) {
const [, , desk] = path.split('/');
return `/app/${desk}`;
return `/apps/${desk}`;
}
if (path.startsWith('/grid/')) {
// Handle links to grid features (preferences, etc)
const route = path
.split('/')
.filter((el) => el !== 'grid')
.join('/');
return route;
}
return '';
};
const getId = async () => {
const fpPromise = FingerprintJS.load();
const fp = await fpPromise;
const result = await fp.get();
return result.visitorId;
};
const AppRoutes = () => {
const { push } = useHistory();
const { search } = useLocation();
const handleError = useErrorHandler();
const browserId = useBrowserId();
useEffect(() => {
getId().then((value) => {
useLocalState.setState({ browserId: value });
});
}, [browserId]);
useEffect(() => {
const query = new URLSearchParams(search);
@ -53,6 +77,10 @@ const AppRoutes = () => {
handleError(() => {
window.name = 'grid';
const { initialize: settingsInitialize, fetchAll } = useSettingsState.getState();
settingsInitialize(api);
fetchAll();
const { fetchDefaultAlly, fetchAllies, fetchCharges } = useDocketState.getState();
fetchDefaultAlly();
fetchCharges();

View File

@ -0,0 +1,35 @@
import React, { useState } from 'react';
import classNames from 'classnames';
import * as RadixCheckbox from '@radix-ui/react-checkbox';
import { CheckIcon } from '@radix-ui/react-icons';
export const Checkbox: React.FC<RadixCheckbox.CheckboxProps> = ({
defaultChecked,
checked,
onCheckedChange,
disabled,
className,
children
}) => {
const [on, setOn] = useState(defaultChecked);
const isControlled = !!onCheckedChange;
const proxyChecked = isControlled ? checked : on;
const proxyOnCheckedChange = isControlled ? onCheckedChange : setOn;
return (
<div className="flex content-center space-x-2">
<RadixCheckbox.Root
className={classNames('default-ring rounded-lg bg-white h-7 w-7', className)}
checked={proxyChecked}
onCheckedChange={proxyOnCheckedChange}
disabled={disabled}
id="checkbox"
>
<RadixCheckbox.Indicator className="flex justify-center">
<CheckIcon className="text-black" />
</RadixCheckbox.Indicator>
</RadixCheckbox.Root>
<label htmlFor="checkbox">{children}</label>
</div>
);
};

View File

@ -6,7 +6,7 @@ import classNames from 'classnames';
export const Dialog: FC<DialogPrimitive.DialogOwnProps> = ({ children, ...props }) => {
return (
<DialogPrimitive.Root {...props}>
<DialogPrimitive.Overlay className="fixed top-0 bottom-0 left-0 right-0 z-30 bg-black opacity-30" />
<DialogPrimitive.Overlay className="fixed top-0 bottom-0 left-0 right-0 z-30 bg-black opacity-30 transform transform-gpu" />
{children}
</DialogPrimitive.Root>
);

View File

@ -34,7 +34,10 @@ export const ProviderLink = ({
>
<Avatar size={size} {...provider} />
<div className="flex-1 text-black">
<p className="font-mono">{provider.nickname || <ShipName name={provider.shipName} />}</p>
<div className="flex font-mono space-x-4">
<ShipName name={provider.shipName} />
<span className="text-gray-500">{provider.nickname}</span>
</div>
{provider.status && size === 'default' && <p className="font-normal">{provider.status}</p>}
</div>
</Link>

View File

@ -0,0 +1,13 @@
import React from 'react';
export const Lock = (props: React.SVGProps<SVGSVGElement>) => (
<svg viewBox="-8.5 -6.5 26 26" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 5H9C9.55228 5 10 5.44772 10 6V11C10 11.5523 9.55229 12 9 12H1C0.447716 12 0 11.5523 0 11V6C0 5.44772 0.447715 5 1 5H2V3C2 1.34315 3.34315 0 5 0C6.65685 0 8 1.34315 8 3V5ZM7 5V3C7 1.89543 6.10457 1 5 1C3.89543 1 3 1.89543 3 3V5H7ZM3 6H9V11H1V6H2H3Z"
className="fill-current"
strokeMiterlimit="10"
/>
</svg>
);

View File

@ -1,3 +1,19 @@
declare module 'urbit-ob' {
export function isValidPatp(patp: string): boolean;
}
type Stringified<T> = string &
{
[P in keyof T]: { '_ value': T[P] };
};
interface JSON {
// stringify(value: any, replacer?: (key: string, value: any) => any, space?: string | number): string;
stringify<T>(
value: T,
replacer?: (key: string, value: any) => any,
space?: string | number
): string & Stringified<T>;
// parse(text: string, reviver?: (key: any, value: any) => any): any;
parse<T>(text: Stringified<T>, reviver?: (key: any, value: any) => any): T;
}

View File

@ -103,10 +103,8 @@ export const Leap = React.forwardRef(
(value: string) => {
const onlySymbols = !value.match(/[\w]/g);
const normValue = normalizeMatchString(value, onlySymbols);
return matches.find(
(m) =>
(m.display && normalizeMatchString(m.display, onlySymbols).startsWith(normValue)) ||
normalizeMatchString(m.value, onlySymbols).startsWith(normValue)
return matches.find((m) =>
normalizeMatchString(m.value, onlySymbols).startsWith(normValue)
);
},
[matches]
@ -141,7 +139,7 @@ export const Leap = React.forwardRef(
const value = input.value.trim();
const isDeletion = (e.nativeEvent as InputEvent).inputType === 'deleteContentBackward';
const inputMatch = getMatch(value);
const matchValue = inputMatch?.display || inputMatch?.value;
const matchValue = inputMatch?.value;
if (matchValue && inputRef.current && !isDeletion) {
inputRef.current.value = matchValue;
@ -207,8 +205,8 @@ export const Leap = React.forwardRef(
const currentIndex = selectedMatch
? matches.findIndex((m) => {
const matchValue = m.display || m.value;
const searchValue = selectedMatch.display || selectedMatch.value;
const matchValue = m.value;
const searchValue = selectedMatch.value;
return matchValue === searchValue;
})
: 0;
@ -216,9 +214,8 @@ export const Leap = React.forwardRef(
const index = (unsafeIndex + matches.length) % matches.length;
const newMatch = matches[index];
const matchValue = newMatch.display || newMatch.value;
useLeapStore.setState({
rawInput: matchValue,
rawInput: newMatch.value,
// searchInput: matchValue,
selectedMatch: newMatch
});
@ -227,7 +224,6 @@ export const Leap = React.forwardRef(
[selection, rawInput, match, matches, selectedMatch]
);
return (
<div className="relative z-50 w-full">
<form
@ -257,7 +253,7 @@ export const Leap = React.forwardRef(
type="text"
ref={inputRef}
placeholder={selection ? '' : 'Search'}
className="flex-1 w-full h-full px-2 h4 text-base rounded-full bg-transparent outline-none"
className="flex-1 w-full h-full px-2 text-base bg-transparent rounded-full outline-none h4"
value={rawInput}
onClick={toggleSearch}
onFocus={onFocus}
@ -266,14 +262,14 @@ export const Leap = React.forwardRef(
autoComplete="off"
aria-autocomplete="both"
aria-controls={dropdown}
aria-activedescendant={selectedMatch?.display || selectedMatch?.value}
aria-activedescendant={selectedMatch?.value}
/>
) : null}
</form>
{menu === 'search' && (
<Link
to="/"
className="absolute top-1/2 right-2 flex-none circle-button w-8 h-8 text-gray-400 bg-gray-50 default-ring -translate-y-1/2"
className="absolute flex-none w-8 h-8 text-gray-400 top-1/2 right-2 circle-button bg-gray-50 default-ring -translate-y-1/2"
onClick={() => select(null)}
>
<Cross className="w-3 h-3 fill-current" />

View File

@ -6,11 +6,20 @@ import { Bullet } from '../components/icons/Bullet';
import { Cross } from '../components/icons/Cross';
import { useHarkStore } from '../state/hark';
import { useLeapStore } from './Nav';
import { SettingsState, useSettingsState } from '../state/settings';
type NotificationsState = 'empty' | 'unread' | 'attention-needed' | 'open';
function getNotificationsState(isOpen: boolean, box: Timebox): NotificationsState {
function getNotificationsState(isOpen: boolean, box: Timebox, dnd: boolean): NotificationsState {
const notifications = Object.values(box);
if (isOpen) {
return 'open';
}
if (dnd) {
return 'empty';
}
if (
notifications.filter(
({ bin }) => bin.place.desk === window.desk && ['/lag', 'blocked'].includes(bin.place.path)
@ -18,9 +27,6 @@ function getNotificationsState(isOpen: boolean, box: Timebox): NotificationsStat
) {
return 'attention-needed';
}
if (isOpen) {
return 'open';
}
// TODO: when real structure, this should be actually be unread not just existence
if (notifications.length > 0) {
@ -36,13 +42,16 @@ type NotificationsLinkProps = Omit<LinkProps<HTMLAnchorElement>, 'to'> & {
shouldDim: boolean;
};
const selDnd = (s: SettingsState) => s.display.doNotDisturb;
export const NotificationsLink = ({
navOpen,
notificationsOpen,
shouldDim
}: NotificationsLinkProps) => {
const unseen = useHarkStore((s) => s.unseen);
const state = getNotificationsState(notificationsOpen, unseen);
const dnd = useSettingsState(selDnd);
const state = getNotificationsState(notificationsOpen, unseen, dnd);
const select = useLeapStore((s) => s.select);
const clearSelection = useCallback(() => select(null), [select]);

View File

@ -5,6 +5,7 @@ import classNames from 'classnames';
import { NotificationPrefs } from './preferences/NotificationPrefs';
import { SystemUpdatePrefs } from './preferences/SystemUpdatePrefs';
import { InterfacePrefs } from './preferences/InterfacePrefs';
import { SecurityPrefs } from './preferences/SecurityPrefs';
import { useCharges } from '../state/docket';
import { AppPrefs } from './preferences/AppPrefs';
import { DocketImage } from '../components/DocketImage';
@ -14,6 +15,7 @@ import { LeftArrow } from '../components/icons/LeftArrow';
import { System } from '../components/icons/System';
import { Interface } from '../components/icons/Interface';
import { Notifications } from '../components/icons/Notifications';
import { Lock } from '../components/icons/Lock';
import { getAppName } from '../state/util';
interface SystemPreferencesSectionProps {
@ -77,11 +79,11 @@ export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }
FallbackComponent={ErrorAlert}
onReset={() => history.push('/leap/system-preferences')}
>
<div className="sm:flex h-full overflow-y-auto">
<div className="h-full overflow-y-auto sm:flex">
<Route exact={isMobile} path={match.url}>
<aside className="flex-none self-start w-full sm:w-auto min-w-60 py-4 sm:py-8 font-semibold text-black sm:text-gray-600 border-r-2 border-gray-50">
<aside className="self-start flex-none w-full py-4 font-semibold text-black border-r-2 sm:w-auto min-w-60 sm:py-8 sm:text-gray-600 border-gray-50">
<nav className="px-2 sm:px-6">
<h2 className="sm:hidden h3 mb-4 px-2">System Preferences</h2>
<h2 className="px-2 mb-4 sm:hidden h3">System Preferences</h2>
<ul className="space-y-1">
<SystemPreferencesSection
url={subUrl('notifications')}
@ -101,6 +103,10 @@ export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }
<Interface className="w-8 h-8 mr-3 bg-gray-100 rounded-md" />
Interface Settings
</SystemPreferencesSection>
<SystemPreferencesSection url={subUrl('security')} active={matchSub('security')}>
<Lock className="w-8 h-8 mr-3 bg-gray-100 rounded-md" />
Security
</SystemPreferencesSection>
</ul>
</nav>
<hr className="my-4 border-t-2 border-gray-50" />
@ -126,6 +132,7 @@ export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }
<Route path={`${match.url}/apps/:desk`} component={AppPrefs} />
<Route path={`${match.url}/system-updates`} component={SystemUpdatePrefs} />
<Route path={`${match.url}/interface`} component={InterfacePrefs} />
<Route path={`${match.url}/security`} component={SecurityPrefs} />
<Route
path={[`${match.url}/notifications`, match.url]}
component={NotificationPrefs}
@ -133,7 +140,7 @@ export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }
</Switch>
<Link
to={match.url}
className="inline-flex sm:hidden items-center sm:none mt-auto pt-4 h4 text-gray-400"
className="inline-flex items-center pt-4 mt-auto text-gray-400 sm:hidden sm:none h4"
>
<LeftArrow className="w-3 h-3 mr-2" /> Back
</Link>

View File

@ -40,7 +40,8 @@ export const BasicNotification = ({ notification, lid }: BasicNotificationProps)
if (!first || !charge) {
return null;
}
const contents = map(notification.body, 'content').filter((c) => c.length > 0);
const orderedByTime = notification.body.sort((a, b) => a.time - b.time);
const contents = map(orderedByTime, 'content').filter((c) => c.length > 0);
const large = contents.length === 0;
const archive = () => {
useHarkStore.getState().archiveNote(notification.bin, lid);
@ -66,19 +67,19 @@ export const BasicNotification = ({ notification, lid }: BasicNotificationProps)
>
<header id={id} className="contents">
<DocketImage {...charge} size={!large ? 'xs' : 'default'} className="note-grid-icon" />
<div className="note-grid-title font-semibold">{charge?.title || desk}</div>
{!large ? <Elbow className="note-grid-arrow w-6 h-6 text-gray-300" /> : null}
<div className="font-semibold note-grid-title">{charge?.title || desk}</div>
{!large ? <Elbow className="w-6 h-6 text-gray-300 note-grid-arrow" /> : null}
<h2
id={`${id}-title`}
className="note-grid-head leading-tight sm:leading-normal font-semibold text-gray-600"
className="font-semibold leading-tight text-gray-600 note-grid-head sm:leading-normal"
>
<NotificationText contents={first.title} />
</h2>
{!('time' in lid) ? (
<div className="note-grid-actions flex sm:hidden hover-none:flex pointer-coarse:flex justify-center self-center group-hover:flex">
<div className="flex self-center justify-center note-grid-actions sm:hidden hover-none:flex pointer-coarse:flex group-hover:flex">
<Button
onClick={archiveNoFollow}
className="px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-base leading-none sm:leading-normal"
className="px-2 py-1 text-xs leading-none sm:px-4 sm:py-2 sm:text-base sm:leading-normal"
>
Archive
</Button>
@ -86,7 +87,7 @@ export const BasicNotification = ({ notification, lid }: BasicNotificationProps)
) : null}
</header>
{contents.length > 0 ? (
<div className="note-grid-body leading-tight sm:leading-normal space-y-2">
<div className="leading-tight note-grid-body sm:leading-normal space-y-2">
{take(contents, MAX_CONTENTS).map((content) => (
<p className="">
<NotificationText contents={content} />

View File

@ -3,10 +3,11 @@ import cn from 'classnames';
import { Link } from 'react-router-dom';
import { HarkLid, Vats, getVatPublisher } from '@urbit/api';
import { Button } from '../../components/Button';
import { useCurrentTheme, useProtocolHandling } from '../../state/local';
import { useBrowserId, useCurrentTheme } from '../../state/local';
import { getDarkColor } from '../../state/util';
import useKilnState from '../../state/kiln';
import {useHarkStore} from '../../state/hark';
import { useHarkStore } from '../../state/hark';
import { useProtocolHandling } from '../../state/settings';
const getCards = (vats: Vats, protocol: boolean): OnboardingCardProps[] => {
const cards = [
@ -52,7 +53,7 @@ const getCards = (vats: Vats, protocol: boolean): OnboardingCardProps[] => {
// color: '#82A6CA'
// }
];
if('registerProtocolHandler' in window.navigator && !protocol) {
if ('registerProtocolHandler' in window.navigator && !protocol) {
cards.push({
title: 'Open Urbit-Native Links',
body: 'Enable your Urbit to open links you find in the wild',
@ -64,9 +65,10 @@ const getCards = (vats: Vats, protocol: boolean): OnboardingCardProps[] => {
});
}
return cards.filter(card => {
return !Object.values(vats).find(vat => getVatPublisher(vat) == card.ship && vat?.arak?.rail?.desk === card.desk);
return cards.filter((card) => {
return !Object.values(vats).find(
(vat) => getVatPublisher(vat) == card.ship && vat?.arak?.rail?.desk === card.desk
);
});
};
@ -85,7 +87,7 @@ interface OnboardingCardProps {
const OnboardingCard = ({ title, button, href, body, color }: OnboardingCardProps) => (
<div
className="p-4 flex flex-col space-y-2 text-black bg-gray-100 justify-between rounded-xl"
className="flex flex-col justify-between p-4 text-black bg-gray-100 space-y-2 rounded-xl"
style={color ? { backgroundColor: color } : {}}
>
<div className="space-y-1">
@ -106,19 +108,22 @@ interface OnboardingNotificationProps {
export const OnboardingNotification = ({ unread = false, lid }: OnboardingNotificationProps) => {
const theme = useCurrentTheme();
const vats = useKilnState((s) => s.vats);
const protocolHandling = useProtocolHandling();
const browserId = useBrowserId();
const protocolHandling = useProtocolHandling(browserId);
const cards = getCards(vats, protocolHandling);
if(cards.length === 0 && !('time' in lid)) {
useHarkStore.getState().archiveNote({
path: '/',
place: {
path: '/onboard',
desk: window.desk
}
}, lid);
if (cards.length === 0 && !('time' in lid)) {
useHarkStore.getState().archiveNote(
{
path: '/',
place: {
path: '/onboard',
desk: window.desk
}
},
lid
);
return null;
}
return (

View File

@ -1,11 +1,26 @@
import React from 'react';
import { Setting } from '../../components/Setting';
import { useProtocolHandling, setLocalState } from '../../state/local';
import {
setBrowserSetting,
useBrowserSettings,
useProtocolHandling,
useSettingsState
} from '../../state/settings';
import { useBrowserId } from '../../state/local';
export function InterfacePrefs() {
const protocolHandling = useProtocolHandling();
const settings = useBrowserSettings();
const browserId = useBrowserId();
const protocolHandling = useProtocolHandling(browserId);
const secure = window.location.protocol === 'https:' || window.location.hostname === 'localhost';
const linkHandlingAllowed = secure && 'registerProtocolHandler' in window.navigator;
const setProtocolHandling = (setting: boolean) => {
const newSettings = setBrowserSetting(settings, { protocolHandling: setting }, browserId);
useSettingsState
.getState()
.putEntry('browserSettings', 'settings', JSON.stringify(newSettings));
};
const toggleProtoHandling = async () => {
if (!protocolHandling && window?.navigator?.registerProtocolHandler) {
try {
@ -14,18 +29,14 @@ export function InterfacePrefs() {
'/apps/grid/perma?ext=%s',
'Urbit Links'
);
setLocalState((draft) => {
draft.protocolHandling = true;
});
setProtocolHandling(true);
} catch (e) {
console.error(e);
}
} else if (protocolHandling && window.navigator?.unregisterProtocolHandler) {
try {
window.navigator.unregisterProtocolHandler('web+urbitgraph', '/apps/grid/perma?ext=%s');
setLocalState((draft) => {
draft.protocolHandling = false;
});
setProtocolHandling(false);
} catch (e) {
console.error(e);
}
@ -35,21 +46,25 @@ export function InterfacePrefs() {
return (
<>
<h2 className="h3 mb-7">Interface Settings</h2>
<Setting
on={protocolHandling}
toggle={toggleProtoHandling}
name="Handle Urbit links"
disabled={!linkHandlingAllowed}
>
<p>
Automatically open urbit links with this urbit
{!linkHandlingAllowed && (
<>
, <strong className="text-orange-500">requires HTTPS</strong>
</>
)}
</p>
</Setting>
<div className="space-y-3">
<Setting
on={protocolHandling}
toggle={toggleProtoHandling}
name="Handle Urbit links"
disabled={!linkHandlingAllowed}
>
<p>
Automatically open urbit links when using this browser.
{!linkHandlingAllowed && (
<>
<strong className="text-orange-500">
Unavailable with this browser/connection.
</strong>
</>
)}
</p>
</Setting>
</div>
</>
);
}

View File

@ -3,15 +3,19 @@ import React from 'react';
import { Setting } from '../../components/Setting';
import { pokeOptimisticallyN } from '../../state/base';
import { HarkState, reduceGraph, useHarkStore } from '../../state/hark';
import { useSettingsState, SettingsState } from '../../state/settings';
import { useBrowserId } from '../../state/local';
import {
useSettingsState,
useBrowserNotifications,
useBrowserSettings,
SettingsState,
setBrowserSetting
} from '../../state/settings';
const selDnd = (s: SettingsState) => s.display.doNotDisturb;
async function toggleDnd() {
const state = useSettingsState.getState();
const curr = selDnd(state);
if (curr) {
Notification.requestPermission();
}
await state.putEntry('display', 'doNotDisturb', !curr);
}
@ -24,28 +28,51 @@ async function toggleMentions() {
export const NotificationPrefs = () => {
const doNotDisturb = useSettingsState(selDnd);
const mentions = useHarkStore(selMentions);
const settings = useBrowserSettings();
const browserId = useBrowserId();
const browserNotifications = useBrowserNotifications(browserId);
const secure = window.location.protocol === 'https:' || window.location.hostname === 'localhost';
const notificationsAllowed = secure && 'Notification' in window;
const setBrowserNotifications = (setting: boolean) => {
const newSettings = setBrowserSetting(settings, { browserNotifications: setting }, browserId);
useSettingsState
.getState()
.putEntry('browserSettings', 'settings', JSON.stringify(newSettings));
};
const toggleNotifications = async () => {
if (!browserNotifications) {
Notification.requestPermission();
setBrowserNotifications(true);
} else {
setBrowserNotifications(false);
}
};
return (
<>
<h2 className="h3 mb-7">Notifications</h2>
<div className="space-y-3">
<Setting on={doNotDisturb} toggle={toggleDnd} name="Do Not Disturb">
<p>
Blocks Urbit notifications in Landscape from appearing as badges and prevents browser
notifications if enabled.
</p>
</Setting>
<Setting
on={doNotDisturb}
toggle={toggleDnd}
name="Do Not Disturb"
disabled={doNotDisturb && !secure}
on={browserNotifications}
toggle={toggleNotifications}
name="Show Desktop Notifications"
disabled={!notificationsAllowed}
>
<p>
Block visual desktop notifications whenever Urbit software produces a notification
badge.
</p>
<p>
Turning this &quot;off&quot; will prompt your browser to ask if you&apos;d like to
enable notifications
Show desktop notifications in this browser.
{!secure && (
<>
, <strong className="text-orange-500">requires HTTPS</strong>
<strong className="text-orange-500">
Unavailable with this browser/connection.
</strong>
</>
)}
</p>

View File

@ -0,0 +1,32 @@
import React, { useState } from 'react';
import classNames from 'classnames';
import { Button } from '../../components/Button';
import { Checkbox } from '../../components/Checkbox';
export const SecurityPrefs = () => {
const [allSessions, setAllSessions] = useState(false);
return (
<>
<h2 className="h3 mb-7">Security</h2>
<div className="space-y-3">
<section className={classNames('inner-section')}>
<h3 className="flex items-center mb-2 h4">Logout</h3>
<div className="flex flex-col justify-center flex-1 space-y-6">
<Checkbox
defaultChecked={false}
checked={allSessions}
onCheckedChange={() => setAllSessions((prev) => !prev)}
>
Log out of all sessions.
</Checkbox>
<form method="post" action="/~/logout">
{allSessions && <input type="hidden" name="all" />}
<Button>Logout</Button>
</form>
</div>
</section>
</div>
</>
);
};

View File

@ -3,7 +3,7 @@ import { RouteComponentProps } from 'react-router-dom';
import fuzzy from 'fuzzy';
import { Treaty } from '@urbit/api';
import { ShipName } from '../../components/ShipName';
import useDocketState, { useAllyTreaties, useAllies } from '../../state/docket';
import { useAllyTreaties } from '../../state/docket';
import { useLeapStore } from '../Nav';
import { AppList } from '../../components/AppList';
import { addRecentDev } from './Home';
@ -19,14 +19,12 @@ export const Apps = ({ match }: AppsProps) => {
}));
const provider = match?.params.ship;
const { treaties, status } = useAllyTreaties(provider);
const allies = useAllies();
const isAllied = provider in allies;
useEffect(() => {
if (Object.keys(allies).length > 0 && !isAllied) {
useDocketState.getState().addAlly(provider);
if (provider) {
addRecentDev(provider);
}
}, [allies, isAllied, provider]);
}, [provider]);
const results = useMemo(() => {
if (!treaties) {
@ -74,12 +72,8 @@ export const Apps = ({ match }: AppsProps) => {
}
}, [results]);
useEffect(() => {
if (provider) {
useDocketState.getState().fetchAllyTreaties(provider);
addRecentDev(provider);
}
}, [provider]);
const showNone =
status === 'error' || ((status === 'success' || status === 'initial') && results?.length === 0);
return (
<div className="dialog-inner-container md:px-6 md:py-8 h4 text-gray-400">
@ -107,12 +101,11 @@ export const Apps = ({ match }: AppsProps) => {
<p>That&apos;s it!</p>
</>
)}
{status === 'error' ||
((status === 'success' || status === 'initial') && results?.length === 0 && (
<h2>
Unable to find software developed by <ShipName name={provider} className="font-mono" />
</h2>
))}
{showNone && (
<h2>
Unable to find software developed by <ShipName name={provider} className="font-mono" />
</h2>
)}
</div>
);
};

View File

@ -1,46 +1,43 @@
import { map, omit } from 'lodash';
import React, { FunctionComponent, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Route, RouteComponentProps, useHistory, useParams } from 'react-router-dom';
import { Route, useHistory, useParams } from 'react-router-dom';
import { ErrorAlert } from '../components/ErrorAlert';
import { MenuState, Nav } from '../nav/Nav';
import { useCharges } from '../state/docket';
import useKilnState from '../state/kiln';
import { RemoveApp } from '../tiles/RemoveApp';
import { SuspendApp } from '../tiles/SuspendApp';
import { Tile } from '../tiles/Tile';
import { TileGrid } from '../tiles/TileGrid';
import { TileInfo } from '../tiles/TileInfo';
interface RouteProps {
menu?: MenuState;
}
export const Grid: FunctionComponent<{}> = () => {
const charges = useCharges();
export const Grid: FunctionComponent = () => {
const { push } = useHistory();
const { menu } = useParams<RouteProps>();
const chargesLoaded = Object.keys(charges).length > 0;
useEffect(() => {
// TOOD: rework
// Heuristically detect reload completion and redirect
async function attempt(count = 0) {
if(count > 5) {
if (count > 5) {
window.location.reload();
}
const start = performance.now();
await useKilnState.getState().fetchVats();
await useKilnState.getState().fetchVats();
if((performance.now() - start) > 5000) {
attempt(count+1);
if (performance.now() - start > 5000) {
attempt(count + 1);
} else {
push('/');
}
}
if(menu === 'upgrading') {
if (menu === 'upgrading') {
attempt();
}
}, [menu])
}, [menu]);
return (
<div className="flex flex-col">
@ -49,15 +46,7 @@ export const Grid: FunctionComponent<{}> = () => {
</header>
<main className="h-full w-full flex justify-center pt-4 md:pt-16 pb-32 relative z-0">
{!chargesLoaded && <span>Loading...</span>}
{chargesLoaded && (
<div className="grid justify-center grid-cols-2 sm:grid-cols-[repeat(auto-fit,minmax(auto,250px))] gap-4 px-4 md:px-8 w-full max-w-6xl">
{charges &&
map(omit(charges, window.desk), (charge, desk) => (
<Tile key={desk} charge={charge} desk={desk} disabled={menu === 'upgrading'} />
))}
</div>
)}
<TileGrid menu={menu} />
<ErrorBoundary FallbackComponent={ErrorAlert} onReset={() => push('/')}>
<Route exact path="/app/:desk">
<TileInfo />

View File

@ -1,6 +1,6 @@
import create, { SetState } from 'zustand';
import produce from 'immer';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { omit, pick } from 'lodash';
import {
Allies,
@ -27,7 +27,7 @@ import {
import api from './api';
import { mockAllies, mockCharges, mockTreaties } from './mock-data';
import { fakeRequest, normalizeUrbitColor, useMockData } from './util';
import { useAsyncCall } from '../logic/useAsyncCall';
import { Status } from '../logic/useAsyncCall';
export interface ChargeWithDesk extends Charge {
desk: string;
@ -269,17 +269,38 @@ export function useAllies() {
export function useAllyTreaties(ship: string) {
const allies = useAllies();
const { call: fetchTreaties, status } = useAsyncCall(() =>
useDocketState.getState().fetchAllyTreaties(ship)
);
const isAllied = ship in allies;
const [status, setStatus] = useState<Status>('initial');
const [treaties, setTreaties] = useState<Treaties>();
useEffect(() => {
if (ship in allies) {
fetchTreaties();
if (Object.keys(allies).length > 0 && !isAllied) {
setStatus('loading');
useDocketState.getState().addAlly(ship);
}
}, [ship, allies]);
}, [allies, isAllied, ship]);
const treaties = useDocketState(
useEffect(() => {
async function fetchTreaties() {
if (isAllied) {
setStatus('loading');
try {
const newTreaties = await useDocketState.getState().fetchAllyTreaties(ship);
if (Object.keys(newTreaties).length > 0) {
setTreaties(newTreaties);
setStatus('success');
}
} catch {
setStatus('error');
}
}
}
fetchTreaties();
}, [ship, isAllied]);
const storeTreaties = useDocketState(
useCallback(
(s) => {
const charter = s.allies[ship];
@ -289,7 +310,24 @@ export function useAllyTreaties(ship: string) {
)
);
useEffect(() => {
const timeout = setTimeout(() => {
setStatus('error');
}, 30 * 1000); // wait 30 secs before timing out
if (Object.keys(storeTreaties).length > 0) {
setTreaties(storeTreaties);
setStatus('success');
clearTimeout(timeout);
}
return () => {
clearTimeout(timeout);
};
}, [storeTreaties]);
return {
isAllied,
treaties,
status
};

View File

@ -19,10 +19,11 @@ import { unstable_batchedUpdates } from 'react-dom';
import produce from 'immer';
import _ from 'lodash';
import api from './api';
import { useSettingsState } from './settings';
import { getBrowserSetting, parseBrowserSettings, useSettingsState } from './settings';
import { BaseState, createState, createSubscription, reduceStateN } from './base';
import { mockNotifications } from './mock-data';
import { useMockData } from './util';
import { useLocalState } from './local';
export interface HarkState {
seen: Timebox;
@ -240,7 +241,12 @@ api.subscribe({
path: '/notes',
event: (u: any) => {
if ('add-note' in u) {
if (useSettingsState.getState().display.doNotDisturb) {
const { browserSettings, display } = useSettingsState.getState();
const { browserId } = useLocalState.getState();
const settings = parseBrowserSettings(browserSettings.settings);
const browserNotifications = getBrowserSetting(settings, browserId)?.browserNotifications;
if (!browserNotifications || display.doNotDisturb) {
return;
}
const { bin, body } = u['add-note'];

View File

@ -4,7 +4,7 @@ import produce from 'immer';
import { clearStorageMigration, createStorageKey, storageVersion } from './util';
interface LocalState {
protocolHandling: boolean;
browserId: string;
currentTheme: 'light' | 'dark';
set: (f: (s: LocalState) => void) => void;
}
@ -14,7 +14,7 @@ export const useLocalState = create<LocalState>(
(set, get) => ({
set: (f) => set(produce(get(), f)),
currentTheme: 'light',
protocolHandling: false
browserId: ''
}),
{
name: createStorageKey('local'),
@ -24,9 +24,9 @@ export const useLocalState = create<LocalState>(
)
);
const selProtocolHandling = (s: LocalState) => s.protocolHandling;
export function useProtocolHandling() {
return useLocalState(selProtocolHandling);
const selBrowserId = (s: LocalState) => s.browserId;
export function useBrowserId() {
return useLocalState(selBrowserId);
}
const selCurrentTheme = (s: LocalState) => s.currentTheme;

View File

@ -197,7 +197,7 @@ function text(t: string) {
function createDmNotification(...content: HarkContent[]): HarkBody {
return {
title: [ship('~hastuc-dibtux'), text(' messaged you')],
time: unixToDa(Date.now() - 3_600).toString(),
time: unixToDa(Date.now() - 3_600).toJSNumber(),
content,
binned: '/',
link: '/'
@ -207,7 +207,7 @@ function createDmNotification(...content: HarkContent[]): HarkBody {
function createBitcoinNotif(amount: string) {
return {
title: [ship('~silnem'), text(` sent you ${amount}`)],
time: unixToDa(Date.now() - 3_600).toString(),
time: unixToDa(Date.now() - 3_600).toJSNumber(),
content: [],
binned: '/',
link: '/'
@ -218,7 +218,7 @@ function createGroupNotif(to: string): HarkBody {
return {
title: [ship('~ridlur-figbud'), text(` invited you to ${to}`)],
content: [],
time: unixToDa(Date.now() - 3_600).toString(),
time: unixToDa(Date.now() - 3_600).toJSNumber(),
binned: '/',
link: '/'
};
@ -257,7 +257,7 @@ const onboard = createMockSysNotification('/onboard');
const updateNotification = createMockSysNotification('/desk/bitcoin', [
{
title: [{ text: 'App "Bitcoin" updated to version 1.0.1' }],
time: '',
time: 0,
content: [],
link: '/desk/bitcoin',
binned: '/'

View File

@ -16,12 +16,26 @@ import {
} from './base';
import api from './api';
interface BrowserSetting {
browserId: string;
browserNotifications: boolean;
protocolHandling: boolean;
}
interface BaseSettingsState {
display: {
theme: 'light' | 'dark' | 'auto';
doNotDisturb: boolean;
};
tiles: {
order: string[];
};
loaded: boolean;
browserSettings: {
settings: Stringified<BrowserSetting[]>;
};
putEntry: (bucket: string, key: string, value: Value) => Promise<void>;
fetchAll: () => Promise<void>;
[ref: string]: unknown;
}
@ -71,6 +85,12 @@ export const useSettingsState = createState<BaseSettingsState>(
theme: 'auto',
doNotDisturb: true
},
tiles: {
order: []
},
browserSettings: {
settings: '' as Stringified<BrowserSetting[]>
},
loaded: false,
putEntry: async (bucket, key, val) => {
const poke = doPutEntry(window.desk, bucket, key, val);
@ -79,8 +99,8 @@ export const useSettingsState = createState<BaseSettingsState>(
fetchAll: async () => {
const result = (await api.scry<DeskData>(getDeskSettings(window.desk))).desk;
const newState = {
loaded: true,
..._.mergeWith(get(), result, (obj, src) => (_.isArray(src) ? src : undefined))
..._.mergeWith(get(), result, (obj, src) => (_.isArray(src) ? src : undefined)),
loaded: true
};
set(newState);
}
@ -92,6 +112,7 @@ export const useSettingsState = createState<BaseSettingsState>(
const data = _.get(e, 'settings-event', false);
if (data) {
reduceStateN(get(), data, reduceUpdate);
set({ loaded: true });
}
})
]
@ -101,3 +122,52 @@ const selTheme = (s: SettingsState) => s.display.theme;
export function useTheme() {
return useSettingsState(selTheme);
}
export function parseBrowserSettings(settings: Stringified<BrowserSetting[]>): BrowserSetting[] {
return settings !== '' ? JSON.parse<BrowserSetting[]>(settings) : [];
}
export function getBrowserSetting(
settings: BrowserSetting[],
browserId: string
): BrowserSetting | undefined {
return settings.find((el) => el.browserId === browserId);
}
export function setBrowserSetting(
settings: BrowserSetting[],
newSetting: Partial<BrowserSetting>,
browserId: string
): BrowserSetting[] {
const oldSettings = settings.slice(0);
const oldSettingIndex = oldSettings.findIndex((s) => s.browserId === browserId);
const setting = {
...oldSettings[oldSettingIndex],
browserId,
...newSetting
};
if (oldSettingIndex >= 0) {
oldSettings.splice(oldSettingIndex, 1);
}
return [...oldSettings, setting];
}
const selBrowserSettings = (s: SettingsState) => s.browserSettings.settings;
export function useBrowserSettings(): BrowserSetting[] {
const settings = useSettingsState(selBrowserSettings);
return parseBrowserSettings(settings);
}
export function useProtocolHandling(browserId: string): boolean {
const settings = useBrowserSettings();
const browserSetting = getBrowserSetting(settings, browserId);
return browserSetting?.protocolHandling ?? false;
}
export function useBrowserNotifications(browserId: string): boolean {
const settings = useBrowserSettings();
const browserSetting = getBrowserSetting(settings, browserId);
return browserSetting?.browserNotifications ?? false;
}

View File

@ -1,5 +1,6 @@
import classNames from 'classnames';
import React, { FunctionComponent } from 'react';
import { useDrag } from 'react-dnd';
import { chadIsRunning } from '@urbit/api';
import { TileMenu } from './TileMenu';
import { Spinner } from '../components/Spinner';
@ -9,6 +10,7 @@ import { ChargeWithDesk } from '../state/docket';
import { useTileColor } from './useTileColor';
import { useVat } from '../state/kiln';
import { Bullet } from '../components/icons/Bullet';
import { dragTypes } from './TileGrid';
type TileProps = {
charge: ChargeWithDesk;
@ -28,13 +30,23 @@ export const Tile: FunctionComponent<TileProps> = ({ charge, desk, disabled = fa
const link = getAppHref(href);
const backgroundColor = suspended ? suspendColor : active ? tileColor || 'purple' : suspendColor;
const [{ isDragging }, drag] = useDrag(() => ({
type: dragTypes.TILE,
item: { desk },
collect: (monitor) => ({
isDragging: !!monitor.isDragging()
})
}));
return (
<a
ref={drag}
href={active ? link : undefined}
target="_blank"
rel="noreferrer"
className={classNames(
'group relative font-semibold aspect-w-1 aspect-h-1 rounded-3xl default-ring focus-visible:ring-4 overflow-hidden',
'group absolute font-semibold w-full h-full rounded-3xl default-ring focus-visible:ring-4 overflow-hidden',
isDragging && 'opacity-0',
lightText && active && !loading ? 'text-gray-200' : 'text-gray-800',
!active && 'cursor-default'
)}
@ -48,7 +60,7 @@ export const Tile: FunctionComponent<TileProps> = ({ charge, desk, disabled = fa
<>
{loading && <Spinner className="h-6 w-6 mr-2" />}
<span className="text-gray-500">
{suspended ? 'Suspended' : loading ? 'Installing' : hung ? 'Errored' : null }
{suspended ? 'Suspended' : loading ? 'Installing' : hung ? 'Errored' : null}
</span>
</>
)}

View File

@ -0,0 +1,56 @@
import classNames from 'classnames';
import { uniq, without } from 'lodash';
import React, { FunctionComponent } from 'react';
import { useDrop } from 'react-dnd';
import { useSettingsState } from '../state/settings';
import { dragTypes, selTiles } from './TileGrid';
interface TileContainerProps {
desk: string;
}
export const TileContainer: FunctionComponent<TileContainerProps> = ({ desk, children }) => {
const { order } = useSettingsState(selTiles);
const [{ isOver }, drop] = useDrop<{ desk: string }, undefined, { isOver: boolean }>(
() => ({
accept: dragTypes.TILE,
drop: ({ desk: itemDesk }) => {
if (!itemDesk || itemDesk === desk) {
return undefined;
}
// [1, 2, 3, 4] 1 -> 3
// [2, 3, 4]
const beforeSlot = order.indexOf(itemDesk) < order.indexOf(desk);
const orderWithoutOriginal = without(order, itemDesk);
const slicePoint = orderWithoutOriginal.indexOf(desk);
// [2, 3] [4]
const left = orderWithoutOriginal.slice(0, beforeSlot ? slicePoint + 1 : slicePoint);
const right = orderWithoutOriginal.slice(slicePoint);
// concat([2, 3], [1], [4])
const newOrder = uniq(left.concat([itemDesk], right));
// [2, 3, 1, 4]
console.log({ order, left, right, slicePoint, newOrder });
useSettingsState.getState().putEntry('tiles', 'order', newOrder);
return undefined;
},
collect: (monitor) => ({
isOver: !!monitor.isOver()
})
}),
[desk, order]
);
return (
<div
ref={drop}
className={classNames(
'relative aspect-w-1 aspect-h-1 rounded-3xl ring-4',
isOver && 'ring-blue-500',
!isOver && 'ring-transparent'
)}
>
{children}
</div>
);
};

View File

@ -0,0 +1,91 @@
import React, { useEffect } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { TouchBackend } from 'react-dnd-touch-backend';
import { uniq } from 'lodash';
import { ChargeWithDesk, useCharges } from '../state/docket';
import { Tile } from './Tile';
import { MenuState } from '../nav/Nav';
import { SettingsState, useSettingsState } from '../state/settings';
import { TileContainer } from './TileContainer';
import { useMedia } from '../logic/useMedia';
export interface TileData {
desk: string;
charge: ChargeWithDesk;
position: number;
dragging: boolean;
}
interface TileGridProps {
menu?: MenuState;
}
export const dragTypes = {
TILE: 'tile'
};
export const selTiles = (s: SettingsState) => ({
order: s.tiles.order,
loaded: s.loaded
});
export const TileGrid = ({ menu }: TileGridProps) => {
const charges = useCharges();
const chargesLoaded = Object.keys(charges).length > 0;
const { order, loaded } = useSettingsState(selTiles);
const isMobile = useMedia('(pointer: coarse)');
useEffect(() => {
const hasKeys = order && !!order.length;
const chargeKeys = Object.keys(charges);
const hasChargeKeys = chargeKeys.length > 0;
if (!loaded) {
return;
}
// Correct order state, fill if none, remove duplicates, and remove
// old uninstalled app keys
if (!hasKeys && hasChargeKeys) {
useSettingsState.getState().putEntry('tiles', 'order', chargeKeys);
} else if (order.length < chargeKeys.length) {
useSettingsState.getState().putEntry('tiles', 'order', uniq(order.concat(chargeKeys)));
} else if (order.length > chargeKeys.length && hasChargeKeys) {
useSettingsState
.getState()
.putEntry('tiles', 'order', uniq(order.filter((key) => key in charges).concat(chargeKeys)));
}
}, [charges, order, loaded]);
if (!chargesLoaded) {
return <span>Loading...</span>;
}
return (
<DndProvider
backend={isMobile ? TouchBackend : HTML5Backend}
options={
isMobile
? {
delay: 50,
scrollAngleRanges: [
{ start: 30, end: 150 },
{ start: 210, end: 330 }
]
}
: undefined
}
>
<div className="grid justify-center grid-cols-2 sm:grid-cols-[repeat(auto-fit,minmax(auto,250px))] gap-4 px-4 md:px-8 w-full max-w-6xl">
{order
.filter((d) => d !== window.desk && d in charges)
.map((desk) => (
<TileContainer key={desk} desk={desk}>
<Tile charge={charges[desk]} desk={desk} disabled={menu === 'upgrading'} />
</TileContainer>
))}
</div>
</DndProvider>
);
};

View File

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

1
pkg/interface/.nvmrc Normal file
View File

@ -0,0 +1 @@
16.14.0

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: {

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,12 @@
"description": "",
"main": "index.js",
"private": true,
"engines": {
"node": "16.14.0"
},
"dependencies": {
"@babel/runtime": "^7.12.5",
"@fingerprintjs/fingerprintjs": "^3.3.3",
"@radix-ui/react-dialog": "^0.1.0",
"@reach/disclosure": "^0.10.5",
"@reach/menu-button": "^0.10.5",
@ -71,6 +75,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 +90,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 +107,7 @@
"lint-staged": "^11.0.0",
"loki": "^0.28.1",
"moment-locales-webpack-plugin": "^1.2.0",
"react-hot-loader": "^4.13.0",
"react-refresh": "^0.11.0",
"sass": "^1.32.5",
"sass-loader": "^8.0.2",
"storybook-addon-designs": "^6.0.0",
@ -120,6 +124,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

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

View File

@ -9,11 +9,20 @@ import useLaunchState from '../state/launch';
import useSettingsState from '../state/settings';
import useLocalState from '../state/local';
import useStorageState from '../state/storage';
import gcpManager from '../lib/gcpManager';
export async function bootstrapApi() {
airlock.reset();
airlock.onError = (e) => {
(async () => {
const { reconnect } = useLocalState.getState();
const { reconnect, errorCount, set } = useLocalState.getState();
console.log(errorCount);
if(errorCount > 1) {
set(s => {
s.subscription = 'disconnected';
});
return;
}
try {
await reconnect();
} catch (e) {
@ -31,20 +40,30 @@ export async function bootstrapApi() {
useLocalState.setState({ subscription: 'connected' });
};
[useGraphState].map(s => s.getState()?.clear?.());
useGraphState.getState().getShallowChildren(`~${window.ship}`, 'dm-inbox');
useMetadataState.getState().initialize(airlock);
const promises = [
useHarkState,
useMetadataState,
const subs = [
useGroupState,
useContactState,
useHarkState,
useSettingsState,
useLaunchState,
useInviteState,
useGraphState,
useStorageState
useStorageState,
useLaunchState,
useGraphState
].map(state => state.getState().initialize(airlock));
await Promise.all(promises);
}
await Promise.all(subs);
useSettingsState.getState().getAll();
gcpManager.start();
const {
getKeys,
getShallowChildren
} = useGraphState.getState();
useHarkState.getState().getUnreads();
getKeys();
getShallowChildren(`~${window.ship}`, 'dm-inbox');
}

View File

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

View File

@ -1,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,27 +1,33 @@
import useMetadataState from '../state/metadata';
import ob from 'urbit-ob';
import useInviteState from '../state/invite';
import { deSig, resourceAsPath } from '@urbit/api';
import { deSig, Notification, resourceAsPath } from '@urbit/api';
import { createJoinParams } from '~/views/landscape/components/Join/Join';
function getGroupResourceRedirect(key: string) {
const graphs = useMetadataState.getState().associations.graph;
const association = graphs[`/ship/${key}`];
if(!association || !('graph' in association.metadata.config)) {
if (!association || !('graph' in association.metadata.config)) {
return '';
}
const section = association.group === association.resource ? '/messages' : association.group;
const section =
association.group === association.resource
? '/messages'
: association.group;
return `/~landscape${section}/resource/${association.metadata.config.graph}${association.resource}`;
}
function getPostRedirect(key: string, segs: string[]) {
const association = useMetadataState.getState().associations.graph[`/ship/${key}`];
const association =
useMetadataState.getState().associations.graph[`/ship/${key}`];
const { metadata } = association;
if(!association || !('graph' in metadata.config)) {
if (!association || !('graph' in metadata.config)) {
return '';
}
return `/~landscape${association.group}/feed/thread/${segs.slice(0, -1).join('/')}`;
return `/~landscape${association.group}/feed/thread/${segs
.slice(0, -1)
.join('/')}`;
}
function getChatRedirect(chat: string, segs: string[]) {
@ -31,7 +37,7 @@ function getChatRedirect(chat: string, segs: string[]) {
function getPublishRedirect(graphKey: string, segs: string[]) {
const base = getGroupResourceRedirect(graphKey);
if(segs.length === 3) {
if (segs.length === 3) {
return `${base}/note/${segs[0]}`;
} else if (segs.length === 4) {
return `${base}/note/${segs[0]}?selected=${segs[2]}`;
@ -41,7 +47,7 @@ function getPublishRedirect(graphKey: string, segs: string[]) {
function getLinkRedirect(graphKey: string, segs: string[]) {
const base = getGroupResourceRedirect(graphKey);
if(segs.length === 1) {
if (segs.length === 1) {
return `${base}/index/${segs[0]}`;
} else if (segs.length === 3) {
return `${base}/index/${segs[0]}?selected=${segs[1]}`;
@ -50,9 +56,9 @@ function getLinkRedirect(graphKey: string, segs: string[]) {
}
function getGraphRedirect(link: string) {
const [,mark, ship, name, ...rest] = link.split('/');
const [, mark, ship, name, ...rest] = link.split('/');
const graphKey = `${ship}/${name}`;
switch(mark) {
switch (mark) {
case 'graph-validator-dm':
return `/~landscape/messages/dm/${ob.patp(rest[0])}`;
case 'graph-validator-chat':
@ -60,18 +66,18 @@ function getGraphRedirect(link: string) {
case 'graph-validator-publish':
return getPublishRedirect(graphKey, rest);
case 'graph-validator-link':
return getLinkRedirect(graphKey, rest);
return getLinkRedirect(graphKey, rest);
case 'graph-validator-post':
return getPostRedirect(graphKey, rest);
default:
return'';
return '';
}
}
function getInviteRedirect(link: string) {
const [,,app,uid] = link.split('/');
const [, , app, uid] = link.split('/');
const invite = useInviteState.getState().invites[app][uid];
if(!invite || (app !== 'groups' && app !== 'graph')) {
if (!invite || (app !== 'groups' && app !== 'graph')) {
return '';
}
@ -86,16 +92,16 @@ function getInviteRedirect(link: string) {
}
function getDmRedirect(link: string) {
const [,,ship] = link.split('/');
const [, , ship] = link.split('/');
return `/~landscape/messages/dm/${ship}`;
}
function getGroupRedirect(link: string) {
const [,,ship,name] = link.split('/');
const [, , ship, name] = link.split('/');
return `/~landscape/ship/${ship}/${name}`;
}
export function getNotificationRedirect(link: string) {
if(link.startsWith('/graph-validator')) {
export function getNotificationRedirectFromLink(link: string) {
if (link.startsWith('/graph-validator')) {
return getGraphRedirect(link);
} else if (link.startsWith('/invite')) {
return getInviteRedirect(link);
@ -105,3 +111,17 @@ export function getNotificationRedirect(link: string) {
return getGroupRedirect(link);
}
}
export function getNotificationRedirectFromPlacePath(
notification: Notification
) {
const placePath = notification.bin.place.path;
switch (notification.bin.path) {
case '/add-members':
return `/~landscape/ship${placePath}`;
case '/remove-members':
return `/~landscape/ship${placePath}`;
default:
return undefined;
}
}

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

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

View File

@ -0,0 +1,21 @@
import { useCallback, useEffect, useState } from 'react';
export const useMedia = (mediaQuery: string) => {
const [match, setMatch] = useState(false);
const update = useCallback((e: MediaQueryListEvent) => {
setMatch(e.matches);
}, []);
useEffect(() => {
const query = window.matchMedia(mediaQuery);
query.addEventListener('change', update);
update({ matches: query.matches } as MediaQueryListEvent);
return () => {
query.removeEventListener('change', update);
};
}, [update]);
return match;
};

View File

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

View File

@ -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,9 @@ 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';
@ -229,6 +227,18 @@ export function deSig(ship: string): string {
return ship.replace('~', '');
}
export function preSig(ship: string): string {
if (!ship) {
return '';
}
if (ship.trim().startsWith('~')) {
return ship.trim();
}
return '~'.concat(ship.trim());
}
export function uxToHex(ux: string) {
if (ux.length > 2 && ux.substr(0, 2) === '0x') {
const value = ux.substr(2).replace('.', '').padStart(6, '0');
@ -399,7 +409,8 @@ export function getContactDetails(contact: any) {
return { nickname, color, member, avatar };
}
export function stringToSymbol(str: string) {
export async function stringToSymbol(str: string) {
const anyAscii = (await import('any-ascii')).default;
const ascii = anyAscii(str);
let result = '';
for (let i = 0; i < ascii.length; i++) {
@ -503,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

@ -0,0 +1,173 @@
import f from 'lodash/fp';
import _ from 'lodash';
import {
BaseState,
createState,
createSubscription,
reduceStateN,
optReduceState
} from '~/logic/state/base';
import airlock from '~/logic/api';
import {
getDeskSettings,
SettingsUpdate,
Value,
Poke,
putEntry as doPutEntry
} from '@urbit/api';
import { UseStore } from 'zustand';
export interface ShortcutMapping {
cycleForward: string;
cycleBack: string;
navForward: string;
navBack: string;
hideSidebar: string;
readGroup: string;
}
export interface GardenSettingsState {
browserSettings: {
settings: string;
};
loaded: boolean;
getAll: () => Promise<void>;
putEntry: (bucket: string, key: string, value: Value) => Promise<void>;
}
function putBucket(
json: SettingsUpdate,
state: GardenSettingsState
): GardenSettingsState {
const data = _.get(json, 'put-bucket', false);
if (data) {
state[data['bucket-key']] = data.bucket;
}
return state;
}
function delBucket(
json: SettingsUpdate,
state: GardenSettingsState
): GardenSettingsState {
const data = _.get(json, 'del-bucket', false);
if (data) {
delete state[data['bucket-key']];
}
return state;
}
function putEntry(json: SettingsUpdate, state: any): GardenSettingsState {
const data: Record<string, string> = _.get(json, 'put-entry', false);
if (data) {
if (!state[data['bucket-key']]) {
state[data['bucket-key']] = {};
}
state[data['bucket-key']][data['entry-key']] = data.value;
}
return state;
}
function delEntry(json: SettingsUpdate, state: any): GardenSettingsState {
const data = _.get(json, 'del-entry', false);
if (data) {
delete state[data['bucket-key']][data['entry-key']];
}
return state;
}
export async function pokeOptimisticallyN<A, S extends {}>(
state: UseStore<S & BaseState<S>>,
poke: Poke<any>,
reduce: ((a: A, fn: S & BaseState<S>) => S & BaseState<S>)[]
) {
let num: string | undefined = undefined;
try {
num = optReduceState(state, poke.json, reduce);
await airlock.poke(poke);
state.getState().removePatch(num);
} catch (e) {
console.error(e);
if (num) {
state.getState().rollback(num);
}
}
}
const reduceUpdate = [putBucket, delBucket, putEntry, delEntry];
export const selectSettingsState = <
K extends keyof (GardenSettingsState & BaseState<GardenSettingsState>)
>(
keys: K[]
) => f.pick<BaseState<GardenSettingsState> & GardenSettingsState, K>(keys);
// @ts-ignore investigate zustand types
const useGardenSettingsState = createState<SettingsState>(
'Settings',
(set, get) => ({
browserSettings: {
settings: ''
},
loaded: false,
getAll: async () => {
const result = (await airlock.scry(getDeskSettings('garden'))).desk;
const newState = {
..._.mergeWith(get(), result, (obj, src) =>
_.isArray(src) ? src : undefined
),
loaded: true
};
set(newState);
},
// getAll: async () => {
// const { desk } = await airlock.scry(getDeskSettings('garden'));
// get().set((s) => {
// for (const bucket in desk) {
// s[bucket] = { ...(s[bucket] || {}), ...desk[bucket] };
// }
// get().set(s)
// });
// },
putEntry: async (bucket: string, entry: string, value: Value) => {
const poke = doPutEntry('garden', bucket, entry, value);
pokeOptimisticallyN(useGardenSettingsState, poke, reduceUpdate);
}
}),
[],
[
(set, get) =>
createSubscription('settings-store', '/desk/garden', (e) => {
const data = _.get(e, 'settings-event', false);
if (data) {
reduceStateN(get(), data, reduceUpdate);
set({ loaded: true });
}
})
]
);
const selBrowserSettings = (s: GardenSettingsState) =>
s.browserSettings.settings;
export function useBrowserSettings() {
const settings = useGardenSettingsState(selBrowserSettings);
console.log({ settings });
return settings !== '' ? JSON.parse(settings) : [];
}
export function useProtocolHandling(browserId: string) {
const settings = useBrowserSettings();
const { protocolHandling = false } =
settings.filter((el: any) => el.browserId === browserId)[0] ?? false;
return protocolHandling;
}
export function useBrowserNotifications(browserId: string) {
const settings = useBrowserSettings();
const { browserNotifications = false } =
settings.filter((el: any) => el.browserId === browserId)[0] ?? false;
return browserNotifications;
}
export default useGardenSettingsState;

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import { clearStorageMigration, createStorageKey, storageVersion, wait } from '~
export type SubscriptionStatus = 'connected' | 'disconnected' | 'reconnecting';
export interface LocalState {
browserId: string;
theme: 'light' | 'dark' | 'auto';
hideAvatars: boolean;
hideNicknames: boolean;
@ -42,6 +43,7 @@ export const selectLocalState =
<K extends keyof LocalState>(keys: K[]) => f.pick<LocalState, K>(keys);
const useLocalState = create<LocalStateZus>(persist((set, get) => ({
browserId: '',
dark: false,
mobile: false,
breaks: {
@ -80,19 +82,17 @@ const useLocalState = create<LocalStateZus>(persist((set, get) => ({
// resume doesn't work properly
reconnect: async () => {
const { errorCount } = get();
set(s => ({ errorCount: s.errorCount+1, subscription: 'reconnecting' }));
if(errorCount > 5) {
set({ subscription: 'disconnected' });
if(errorCount > 1) {
return;
}
await wait(Math.pow(2, errorCount) * 750);
set(s => ({ subscription: 'reconnecting', errorCount: s.errorCount + 1 }));
console.log(get().errorCount);
try {
airlock.reset();
await bootstrapApi();
} catch (e) {
console.error(`Retrying connection, attempt #${errorCount}`);
console.error(e);
set({ subscription: 'disconnected' });
}
},
bootstrap: async () => {
@ -131,4 +131,9 @@ export function useOsDark() {
return useLocalState(selOsDark);
}
const selBrowserId = (s: LocalState) => s.browserId;
export function useBrowserId() {
return useLocalState(selBrowserId);
}
export { useLocalState as default, withLocalState };

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

View File

@ -290,18 +290,18 @@ const MessageActions = ({ onReply, onDelete, msg, isAdmin, permalink }) => {
return (
<Box
borderRadius={1}
borderRadius={2}
backgroundColor='white'
border='1px solid'
borderColor='lightGray'
position='absolute'
top='-12px'
top='-16px'
right={2}
>
<Row>
<Box
padding={1}
size={'24px'}
padding={2}
size={5}
cursor='pointer'
onClick={() => onReply(msg)}
>
@ -342,7 +342,7 @@ const MessageActions = ({ onReply, onDelete, msg, isAdmin, permalink }) => {
</Col>
}
>
<Box padding={1} size={'24px'} cursor='pointer'>
<Box padding={2} size={5} cursor='pointer'>
<Icon icon='Menu' size={3} />
</Box>
</Dropdown>

View File

@ -8,7 +8,6 @@ import { useFileUpload } from '~/logic/lib/useFileUpload';
import { createStorageKey, storageVersion, clearStorageMigration } from '~/logic/lib/util';
import { useOurContact } from '~/logic/state/contact';
import { useGraphTimesent } from '~/logic/state/graph';
import ShareProfile from '~/views/apps/chat/components/ShareProfile';
import { Loading } from '~/views/components/Loading';
import SubmitDragger from '~/views/components/SubmitDragger';
import ChatInput from './ChatInput';
@ -114,8 +113,18 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
const graphTimesentMap = useGraphTimesent(id);
const ourContact = useOurContact();
const { restore, setMessage } = useChatStore(s => ({ setMessage: s.setMessage, restore: s.restore }));
const [uploadError, setUploadError] = useState<string>('');
const handleUploadError = useCallback((err: Error) => {
setUploadError(err.message);
}, []);
const { canUpload, drag } = useFileUpload({
onSuccess: url => onSubmit([{ url }])
onSuccess: (url) => {
onSubmit([{ url }]);
setUploadError('');
},
onError: handleUploadError
});
useEffect(() => {
@ -145,11 +154,6 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
return (
// @ts-ignore bind typings
<Col {...drag.bind} height="100%" overflow="hidden" position="relative">
<ShareProfile
our={ourContact}
recipients={showBanner ? promptShare : []}
onShare={() => setShowBanner(false)}
/>
{canUpload && drag.dragging && <SubmitDragger />}
<ChatWindow
key={id}
@ -171,6 +175,9 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
onSubmit={onSubmit}
ourContact={(promptShare.length === 0 && ourContact) || undefined}
placeholder="Message..."
uploadError={uploadError}
setUploadError={setUploadError}
handleUploadError={handleUploadError}
/>
)}
</Col>

View File

@ -1,77 +0,0 @@
import { BaseImage, Row, Text, Button } from '@tlon/indigo-react';
import { allowGroup, allowShips, Contact, share } from '@urbit/api';
import React, { ReactElement } from 'react';
import { Sigil } from '~/logic/lib/sigil';
import { uxToHex } from '~/logic/lib/util';
import airlock from '~/logic/api';
interface ShareProfileProps {
our?: Contact;
recipients: string | string[];
onShare: () => void;
}
const ShareProfile = (props: ShareProfileProps): ReactElement | null => {
const { recipients } = props;
const image = (props?.our?.avatar)
? (
<BaseImage
src={props.our.avatar}
width='24px'
height='24px'
borderRadius={2}
style={{ objectFit: 'cover' }}
/>
) : (
<Row
p={1}
alignItems="center"
borderRadius={2}
backgroundColor={props.our ? `#${uxToHex(props.our.color)}` : '#000000'}
>
<Sigil
ship={window.ship}
size={16}
color={props.our ? `#${uxToHex(props.our.color)}` : '#000000'}
icon
/>
</Row>
);
const onClick = async () => {
if(typeof recipients === 'string') {
const [,,ship,name] = recipients.split('/');
await airlock.poke(allowGroup(ship, name));
if(ship !== `~${window.ship}`) {
await airlock.poke(share(ship));
}
} else if(recipients.length > 0) {
await airlock.poke(allowShips(recipients));
await Promise.all(recipients.map(r => airlock.poke(share(r))));
}
props.onShare();
};
return props.recipients?.length > 0 ? (
<Row
height="48px"
alignItems="center"
justifyContent="space-between"
borderBottom={1}
borderColor="lightGray"
flexShrink={0}
px="3"
>
<Row alignItems="center">
{image}
<Text verticalAlign="middle" pl={2}>Share private profile?</Text>
</Row>
<Button primary onClick={onClick}>
Share
</Button>
</Row>
) : null;
};
export default ShareProfile;

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

@ -12,7 +12,7 @@ import React from "react";
import { useHistory } from "react-router-dom";
import { getNotificationCount } from "~/logic/lib/hark";
import { alphabeticalOrder } from "~/logic/lib/util";
import useGroupState from "~/logic/state/group";
import useGroupState, {useGroup} from "~/logic/state/group";
import useHarkState, { selHarkGraph } from "~/logic/state/hark";
import useInviteState from "~/logic/state/invite";
import useMetadataState, { usePreview } from "~/logic/state/metadata";
@ -128,6 +128,11 @@ function PendingGroup(props: PendingGroupProps) {
};
const joining = useGroupState((s) => s.pendingJoin[path]?.progress);
const group = useGroup(path);
if(joining?.progress === 'done' && !group) {
return null;
}
return (
<Tile gridColumnStart={first ? 1 : undefined}>

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

View File

@ -34,11 +34,12 @@ export interface LinkBlockItemProps {
node: GraphNode;
size?: CenterProps['height'];
border?: CenterProps['border'];
objectFit?: string;
summary?: boolean;
}
export function LinkBlockItem(props: LinkBlockItemProps & CenterProps) {
const { node, summary, size, m, border = 1, ...rest } = props;
const { node, summary, size, m, border = 1, objectFit, ...rest } = props;
const { post, children } = node;
const { contents, index, author } = post;
@ -90,7 +91,12 @@ export function LinkBlockItem(props: LinkBlockItemProps & CenterProps) {
/>
)
) : isImage ? (
<RemoteContentImageEmbed url={url} />
<RemoteContentImageEmbed
url={url}
tall
stretch
objectFit={objectFit ? objectFit : "cover"}
/>
) : isAudio ? (
<AudioPlayer title={title} url={url} />
) : isOembed ? (

View File

@ -27,7 +27,16 @@ export function LinkDetail(props: LinkDetailProps) {
return (
/* @ts-ignore indio props?? */
<Row height="100%" width="100%" flexDirection={['column', 'column', 'row']} {...rest}>
<LinkBlockItem minWidth="0" minHeight="0" height={['50%', '50%', '100%']} width={['100%', '100%', 'calc(100% - 350px)']} flexGrow={0} border={0} node={node} />
<LinkBlockItem
minWidth="0"
minHeight="0"
height={["50%", "50%", "100%"]}
width={["100%", "100%", "calc(100% - 350px)"]}
flexGrow={0}
border={0}
node={node}
objectFit="contain"
/>
<Col
minHeight="0"
flexShrink={1}

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

View File

@ -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

@ -17,7 +17,10 @@ import { map, take, uniqBy } from 'lodash';
import { Mention } from '~/views/components/MentionText';
import { PropFunc } from '~/types';
import { useHistory } from 'react-router-dom';
import { getNotificationRedirect } from '~/logic/lib/notificationRedirects';
import {
getNotificationRedirectFromLink,
getNotificationRedirectFromPlacePath
} from '~/logic/lib/notificationRedirects';
export interface NotificationProps {
notification: INotification;
@ -44,7 +47,11 @@ const NotificationText = ({ contents, ...rest }: NotificationTextProps) => {
/>
);
}
return <Text key={idx} {...rest}>{content.text}</Text>;
return (
<Text key={idx} {...rest}>
{content.text}
</Text>
);
})}
</>
);
@ -74,9 +81,8 @@ export function Notification(props: {
const { hovering, bind } = useHovering();
const dedupedBody = uniqBy(notification.body, item => item.link);
const contents = map(dedupedBody, 'content').filter(
c => c.length > 0
);
const orderedByTime = dedupedBody.sort((a, b) => a.time - b.time);
const contents = map(orderedByTime, 'content').filter(c => c.length > 0);
const first = notification.body[0];
if (!first) {
// should be unreachable
@ -84,9 +90,13 @@ export function Notification(props: {
}
const onClick = (e: any) => {
const redirect = getNotificationRedirect(first.link);
if(redirect) {
history.push(redirect);
const redirectFromLink = getNotificationRedirectFromLink(first.link);
const redirectFromPlacePath =
getNotificationRedirectFromPlacePath(notification);
if (redirectFromLink) {
history.push(redirectFromLink);
} else if (redirectFromPlacePath) {
history.push(redirectFromPlacePath);
} else {
console.log('no redirect');
}

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

@ -1,15 +1,40 @@
import { BaseAnchor, Box, BoxProps, Button, Center, Col, H3, Icon, Image, Row, Text } from '@tlon/indigo-react';
import { Association, GraphNode, resourceFromPath, GraphConfig, Treaty, deSig } from '@urbit/api';
import {
BaseAnchor,
Box,
BoxProps,
Button,
Center,
Col,
H3,
Icon,
Image,
Row,
Text
} from '@tlon/indigo-react';
import {
Association,
GraphNode,
resourceFromPath,
GraphConfig,
Treaty,
deSig
} from '@urbit/api';
import React, { useCallback, useEffect, useState } from 'react';
import _ from 'lodash';
import { Link, useLocation } from 'react-router-dom';
import api from '~/logic/api';
import {
getPermalinkForGraph, GraphPermalink as IGraphPermalink, parsePermalink,
getPermalinkForGraph,
GraphPermalink as IGraphPermalink,
parsePermalink,
AppPermalink as IAppPermalink
} from '~/logic/lib/permalinks';
import useGardenSettingsState, {
useProtocolHandling
} from '~/logic/state/gardenSettings';
import { getModuleIcon, GraphModule } from '~/logic/lib/util';
import { useVirtualResizeProp } from '~/logic/lib/virtualContext';
import useGraphState from '~/logic/state/graph';
import useGraphState from '~/logic/state/graph';
import useMetadataState from '~/logic/state/metadata';
import { GroupLink } from '~/views/components/GroupLink';
import { TranscludedNode } from './TranscludedNode';
@ -17,6 +42,7 @@ import styled from 'styled-components';
import Author from '~/views/components/Author';
import useDocketState, { useTreaty } from '~/logic/state/docket';
import { createJoinParams } from '~/views/landscape/components/Join/Join';
import { useBrowserId } from '~/logic/state/local';
function Placeholder(type) {
const lines = (type) => {
@ -30,8 +56,8 @@ function Placeholder(type) {
}
};
return (
<Box p='12px 12px 6px'>
<Row mb='6px' height="4">
<Box p="12px 12px 6px">
<Row mb="6px" height="4">
<Box
backgroundColor="washedGray"
size="4"
@ -46,7 +72,7 @@ function Placeholder(type) {
/>
</Row>
{_.times(lines(type), i => (
<Row margin="6px" ml='32px' height="4">
<Row margin="6px" ml="32px" height="4">
<Box
backgroundColor="washedGray"
height="4"
@ -59,7 +85,7 @@ function Placeholder(type) {
);
}
function GroupPermalink(props: { group: string; }) {
function GroupPermalink(props: { group: string }) {
const { group } = props;
return (
<GroupLink
@ -80,22 +106,28 @@ function GraphPermalink(
full?: boolean;
}
) {
const { full = false, showOurContact, pending, graph, group, index, transcluded } = props;
const {
full = false,
showOurContact,
pending,
graph,
group,
index,
transcluded
} = props;
const location = useLocation();
const { ship, name } = resourceFromPath(graph);
const node = useGraphState(
useCallback(s => s.looseNodes?.[`${deSig(ship)}/${name}`]?.[index] as GraphNode, [
graph,
index
])
useCallback(
s => s.looseNodes?.[`${deSig(ship)}/${name}`]?.[index] as GraphNode,
[graph, index]
)
);
const [errored, setErrored] = useState(false);
const [loading, setLoading] = useState(false);
const getNode = useGraphState(s => s.getNode);
const association = useMetadataState(
useCallback(s => s.associations.graph[graph] as Association | null, [
graph
])
useCallback(s => s.associations.graph[graph] as Association | null, [graph])
);
useVirtualResizeProp(Boolean(node));
@ -118,23 +150,19 @@ function GraphPermalink(
const showTransclusion = Boolean(association && node && transcluded < 1);
const permalink = (() => {
const link = `/perma${getPermalinkForGraph(group, graph, index).slice(16)}`;
return (!association && !loading)
? { search: createJoinParams('groups', group, link) } : link;
return !association && !loading
? { search: createJoinParams('groups', group, link) }
: link;
})();
const [nodeGroupHost, nodeGroupName] = association?.group.split('/').slice(-2) ?? ['Unknown', 'Unknown'];
const [nodeGroupHost, nodeGroupName] = association?.group
.split('/')
.slice(-2) ?? ['Unknown', 'Unknown'];
const [nodeChannelHost, nodeChannelName] = association?.resource
.split('/')
.slice(-2) ?? ['Unknown', 'Unknown'];
const [
locChannelName,
locChannelHost,
,
,
,
locGroupName,
locGroupHost
] = location.pathname.split('/').reverse();
const [locChannelName, locChannelHost, , , , locGroupName, locGroupHost] =
location.pathname.split('/').reverse();
const isInSameResource =
locChannelHost === nodeChannelHost &&
@ -156,7 +184,10 @@ function GraphPermalink(
e.stopPropagation();
}}
>
{loading && association && !errored && Placeholder((association.metadata.config as GraphConfig).graph)}
{loading &&
association &&
!errored &&
Placeholder((association.metadata.config as GraphConfig).graph)}
{showTransclusion && index && !loading && (
<TranscludedNode
transcluded={transcluded + 1}
@ -169,7 +200,9 @@ function GraphPermalink(
<PermalinkDetails
known
showTransclusion={showTransclusion}
icon={getModuleIcon((association.metadata.config as GraphConfig).graph as GraphModule)}
icon={getModuleIcon(
(association.metadata.config as GraphConfig).graph as GraphModule
)}
title={association.metadata.title}
/>
)}
@ -177,11 +210,13 @@ function GraphPermalink(
<PermalinkDetails
known
showTransclusion={showTransclusion}
icon={getModuleIcon((association.metadata.config as GraphConfig).graph as GraphModule)}
icon={getModuleIcon(
(association.metadata.config as GraphConfig).graph as GraphModule
)}
title={association.metadata.title}
/>
)}
{isInSameResource && transcluded !== 2 && !loading && <Row height='2' />}
{isInSameResource && transcluded !== 2 && !loading && <Row height="2" />}
{!association && !loading && (
<PermalinkDetails
icon="Groups"
@ -245,8 +280,9 @@ const AppSkeleton = props => (
function AppPermalink({ link, ship, desk }: Omit<IAppPermalink, 'type'>) {
const treaty = useTreaty(ship, desk);
const hasProtocolHandling = Boolean(window?.navigator?.registerProtocolHandler);
const href = hasProtocolHandling ? link : `/apps/grid/perma?ext=${link}`;
const browserId = useBrowserId();
const protocolHandling = useProtocolHandling(browserId);
const href = protocolHandling ? link : `/apps/grid/perma?ext=${link}`;
useEffect(() => {
if (!treaty) {
@ -254,6 +290,12 @@ function AppPermalink({ link, ship, desk }: Omit<IAppPermalink, 'type'>) {
}
}, [treaty, ship, desk]);
useEffect(() => {
const { initialize, getAll } = useGardenSettingsState.getState();
initialize(api);
getAll();
}, []);
return (
<Row
display="inline-flex"
@ -265,14 +307,22 @@ function AppPermalink({ link, ship, desk }: Omit<IAppPermalink, 'type'>) {
>
<AppTile display={['none', 'block']} {...treaty} />
<Col flex="1">
<Row flexDirection={['row', 'column']} alignItems={['center', 'start']} marginBottom={2}>
<Row
flexDirection={['row', 'column']}
alignItems={['center', 'start']}
marginBottom={2}
>
<AppTile display={['block', 'none']} {...treaty} />
<Col>
<H3 color="black">{ treaty?.title || '%' + desk }</H3>
<H3 color="black">{treaty?.title || '%' + desk}</H3>
<Author ship={treaty?.ship || ship} showImage dontShowTime={true} />
</Col>
</Row>
{treaty && <ClampedText marginBottom={2} color="gray">{treaty.info}</ClampedText>}
{treaty && (
<ClampedText marginBottom={2} color="gray">
{treaty.info}
</ClampedText>
)}
{!treaty && (
<>
<AppSkeleton />
@ -318,7 +368,7 @@ function PermalinkDetails(props: {
<Row gapX="2" alignItems="center">
<Box width={4} height={4}>
<Center width={4} height={4}>
<Icon icon={icon} color='gray' />
<Icon icon={icon} color="gray" />
</Center>
</Box>
<Text gray mono={!known}>
@ -356,8 +406,6 @@ export function PermalinkEmbed(props: {
/>
);
case 'app':
return (
<AppPermalink {...permalink} />
);
return <AppPermalink {...permalink} />;
}
}

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

@ -28,7 +28,7 @@ export function BackgroundPicker(): ReactElement {
<ImageInput
ml={5}
id="bgUrl"
placeholder="Drop or upload a file, or paste a link here"
placeholder="Drop or upload a file, or paste an image URL here"
name="bgUrl"
/>
</Col>

View File

@ -1,11 +1,10 @@
import {
Button,
Col,
ManagedToggleSwitchField as Toggle, Text
} from '@tlon/indigo-react';
import { Form, FormikHelpers } from 'formik';
import _ from 'lodash';
import React, { useCallback, useState } from 'react';
import React, { useCallback } from 'react';
import { isWatching } from '~/logic/lib/hark';
import useHarkState from '~/logic/state/hark';
import { FormikOnBlur } from '~/views/components/FormikOnBlur';
@ -72,8 +71,6 @@ export function NotificationPreferences() {
}
}, [graphConfig, dnd]);
const [notificationsAllowed, setNotificationsAllowed] = useState('Notification' in window && Notification.permission !== 'default');
return (
<>
<BackButton />
@ -90,14 +87,6 @@ export function NotificationPreferences() {
<FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
<Form>
<Col gapY="4">
{notificationsAllowed || !('Notification' in window)
? null
: <Button alignSelf='flex-start' onClick={() => {
Notification.requestPermission().then(() => {
setNotificationsAllowed(Notification.permission !== 'default');
});
}}>Allow Browser Notifications</Button>
}
<Toggle
label="Do not disturb"
id="dnd"

View File

@ -1,54 +0,0 @@
import {
Button,
Col,
StatelessCheckboxField, Text
} from '@tlon/indigo-react';
import React, { useState } from 'react';
import { BackButton } from './BackButton';
export default function SecuritySettings() {
const [allSessions, setAllSessions] = useState(false);
return (
<>
<BackButton />
<Col gapY={5} p={5} pt={4}>
<Col gapY={1} mt={0}>
<Text fontSize={2} fontWeight="medium">
Security Preferences
</Text>
<Text gray>
Manage sessions, login credentials and web access
</Text>
</Col>
<Col gapY={1}>
<Text color="black">
Log out of this session
</Text>
<Text mb={3} gray>
{allSessions
? 'You will be logged out of all browsers that have currently logged into your Urbit.'
: 'You will be logged out of your Urbit on this browser.'}
</Text>
<StatelessCheckboxField
mb={3}
selected={allSessions}
onChange={() => setAllSessions(s => !s)}
>
<Text>Log out of all sessions</Text>
</StatelessCheckboxField>
<form method="post" action="/~/logout">
{allSessions && <input type="hidden" name="all" />}
<Button
primary
destructive
border={1}
style={{ cursor: 'pointer' }}
>
Logout
</Button>
</form>
</Col>
</Col>
</>
);
}

View File

@ -11,7 +11,6 @@ import DisplayForm from './components/lib/DisplayForm';
import { LeapSettings } from './components/lib/LeapSettings';
import { NotificationPreferences } from './components/lib/NotificationPref';
import S3Form from './components/lib/S3Form';
import SecuritySettings from './components/lib/Security';
import { DmSettings } from './components/lib/DmSettings';
import ShortcutSettings from './components/lib/ShortcutSettings';
@ -68,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);
@ -117,28 +116,17 @@ return;
<SidebarItem icon='Messages' text='Direct Messages' hash='dm' />
<SidebarItem icon='Node' text='CalmEngine' hash='calm' />
<SidebarItem icon='EastCarat' text='Shortcuts' hash='shortcuts' />
<SidebarItem
icon='Locked'
text='Devices + Security'
hash='security'
/>
</Col>
</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 />}
{hash === 's3' && <S3Form />}
{hash === 'leap' && <LeapSettings />}
{hash === 'calm' && <CalmPrefs />}
{hash === 'security' && <SecuritySettings />}
{hash === 'debug' && <DebugPane />}
</SettingsItem>
</Col>

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';
@ -129,6 +128,7 @@ return false;
{(window.ship == post?.author && !disabled) ? (
<ActionLink
color="blue"
bg="white"
to={{
pathname: props.baseUrl,
search: `?edit=${commentIndex}`

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

@ -42,7 +42,7 @@ const prompt = (
style={{ pointerEvents: 'none' }}
onSelect={e => e.preventDefault}
>
Paste a link here
Paste an image URL here
{canUpload ? (
<>
, or

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;

Some files were not shown because too many files have changed in this diff Show More