mirror of
https://github.com/urbit/shrub.git
synced 2024-12-11 11:02:25 +03:00
Merge branch 'master' into next/npm
This commit is contained in:
commit
67aa4565f1
@ -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
3
package-lock.json
generated
@ -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": {
|
||||
|
@ -1,6 +1,9 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": "16.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^7.29.0",
|
||||
"husky": "^6.0.0",
|
||||
|
@ -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
|
||||
==
|
||||
--
|
||||
--
|
||||
|
@ -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
|
||||
|
@ -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
25679
pkg/grid/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
@ -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();
|
||||
|
35
pkg/grid/src/components/Checkbox.tsx
Normal file
35
pkg/grid/src/components/Checkbox.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
13
pkg/grid/src/components/icons/Lock.tsx
Normal file
13
pkg/grid/src/components/icons/Lock.tsx
Normal 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>
|
||||
);
|
16
pkg/grid/src/global.d.ts
vendored
16
pkg/grid/src/global.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
@ -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" />
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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} />
|
||||
|
@ -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 (
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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 "off" will prompt your browser to ask if you'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>
|
||||
|
32
pkg/grid/src/nav/preferences/SecurityPrefs.tsx
Normal file
32
pkg/grid/src/nav/preferences/SecurityPrefs.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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'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>
|
||||
);
|
||||
};
|
||||
|
@ -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 />
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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'];
|
||||
|
@ -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;
|
||||
|
@ -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: '/'
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
56
pkg/grid/src/tiles/TileContainer.tsx
Normal file
56
pkg/grid/src/tiles/TileContainer.tsx
Normal 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>
|
||||
);
|
||||
};
|
91
pkg/grid/src/tiles/TileGrid.tsx
Normal file
91
pkg/grid/src/tiles/TileGrid.tsx
Normal 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>
|
||||
);
|
||||
};
|
1
pkg/interface/.gitignore
vendored
1
pkg/interface/.gitignore
vendored
@ -6,6 +6,7 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
*.swp
|
||||
.DS_Store
|
||||
stats.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
|
1
pkg/interface/.nvmrc
Normal file
1
pkg/interface/.nvmrc
Normal file
@ -0,0 +1 @@
|
||||
16.14.0
|
@ -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'
|
||||
]
|
||||
};
|
||||
|
@ -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: {
|
||||
|
@ -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: {
|
||||
|
39650
pkg/interface/package-lock.json
generated
39650
pkg/interface/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
@ -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'));
|
@ -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');
|
||||
}
|
||||
|
35
pkg/interface/src/logic/lib/S3Client.ts
Normal file
35
pkg/interface/src/logic/lib/S3Client.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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(
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
7
pkg/interface/src/logic/lib/history.ts
Normal file
7
pkg/interface/src/logic/lib/history.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
|
||||
const history = createBrowserHistory({
|
||||
basename: '/apps/landscape'
|
||||
});
|
||||
|
||||
export default history;
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
|
@ -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 {
|
||||
|
21
pkg/interface/src/logic/lib/useMedia.ts
Normal file
21
pkg/interface/src/logic/lib/useMedia.ts
Normal 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;
|
||||
};
|
@ -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);
|
||||
|
52
pkg/interface/src/logic/lib/useThemeWatcher.ts
Normal file
52
pkg/interface/src/logic/lib/useThemeWatcher.ts
Normal 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
|
||||
};
|
||||
}
|
@ -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;
|
||||
|
@ -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');
|
||||
|
@ -39,6 +39,7 @@ const associations = (json: MetadataUpdate, state: MetadataState): MetadataState
|
||||
if (data) {
|
||||
state.associations = normalizeAssociations(data);
|
||||
state.loaded = true;
|
||||
state.onLoad();
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
@ -69,5 +69,4 @@ export const favicon = () => {
|
||||
return svg;
|
||||
};
|
||||
|
||||
|
||||
export default useContactState;
|
||||
|
173
pkg/interface/src/logic/state/gardenSettings.ts
Normal file
173
pkg/interface/src/logic/state/gardenSettings.ts
Normal 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;
|
@ -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({
|
||||
|
@ -41,7 +41,7 @@ const useGroupState = createState<GroupState>(
|
||||
},
|
||||
|
||||
}),
|
||||
['groups'],
|
||||
[],
|
||||
[
|
||||
(set, get) =>
|
||||
createSubscription('group-store', '/groups', (e) => {
|
||||
|
@ -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,
|
||||
|
@ -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 };
|
||||
|
@ -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);
|
||||
|
@ -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'
|
||||
};
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
125
pkg/interface/src/views/App.tsx
Normal file
125
pkg/interface/src/views/App.tsx
Normal 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;
|
||||
|
@ -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]
|
||||
);
|
||||
|
||||
|
@ -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']);
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
</>
|
||||
|
@ -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}>
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
|
@ -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 ? (
|
||||
|
@ -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}
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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');
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -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 (
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
@ -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}`
|
||||
|
@ -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}>
|
||||
|
@ -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';
|
||||
|
@ -42,7 +42,7 @@ const prompt = (
|
||||
style={{ pointerEvents: 'none' }}
|
||||
onSelect={e => e.preventDefault}
|
||||
>
|
||||
Paste a link here
|
||||
Paste an image URL here
|
||||
{canUpload ? (
|
||||
<>
|
||||
, or
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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" />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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
Loading…
Reference in New Issue
Block a user