Merge remote-tracking branch 'origin/master' into philip/tomb

This commit is contained in:
Philip Monk 2022-04-29 16:28:52 -07:00
commit 0e037ece92
145 changed files with 36020 additions and 39112 deletions

3
package-lock.json generated
View File

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

View File

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

View File

@ -2,8 +2,9 @@
/+ *server, agentio, default-agent, multipart, dbug, verb
|%
+$ card card:agent:gall
+$ state-0
$: :: local
+$ app-state
$: %2
:: local
charges=(map desk charge)
==
:: $cache: impermanent state
@ -11,7 +12,7 @@
by-base=(map term desk)
::
+$ inflated-state
[state-0 cache]
[app-state cache]
:: +lac: toggle verbosity
++ lac &
::
@ -50,12 +51,23 @@
++ on-load
|= =vase
^- (quip card _this)
=+ !<(old=state-0 vase)
=* cha ~(. ch q.byk.bowl)
|^
=+ !<(old=app-states vase)
=? old ?=(?(~ ^) -.old) [%1 old]
=^ cards old
?. ?=(%1 -.old) `old
=/ rein=cage kiln-rein+!>([%base %.y ~ ~])
=/ nuke=cage kiln-uninstall+!>(%hodl)
:_ old(- %2)
:~ [%pass /rein %agent [our.bowl %hood] %poke rein]
[%pass /nuke %agent [our.bowl %hood] %poke nuke]
==
?> ?=(%2 -.old)
=. -.state old
:: inflate-cache needs to be called after the state is set
::
=. +.state inflate-cache
`this
[cards this]
::
++ inflate-cache
^- cache
@ -64,6 +76,22 @@
|= [=desk =charge]
?. ?=(%glob -.href.docket.charge) ~
`:_(desk base.href.docket.charge)
::
+$ app-states
$^ state-0-ket
$% state-0-sig
state-1
app-state
==
::
+$ state-1 [%1 (map desk charge)]
+$ state-0-sig
$: ~
==
::
+$ state-0-ket
$: (map desk charge)
==
--
::
++ on-save !>(-.state)
@ -182,7 +210,9 @@
=^ cards state
?+ wire ~&(bad-docket-take+wire `state)
~ `state
[%kiln ~] take-kiln
[%rein ~] ~&(%reined `state)
[%nuke ~] ~&(%nuked `state)
[%kiln ~] take-kiln
[%charge @ *] (take-charge i.t.wire t.t.wire)
==
[cards this]
@ -390,7 +420,7 @@
|_ =bowl:gall
++ io ~(. agentio bowl)
++ pass pass:io
++ def ~(. (default-agent state %|) bowl)
++ def ~(. (default-agent state %|) bowl)
::
++ hash-glob sham
++ cg

View File

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

View File

@ -1,10 +1,10 @@
:~ title+'System'
info+'An app launcher for Urbit.'
color+0xee.5432
glob-http+['https://bootstrap.urbit.org/glob-0v4.t104r.h4pr1.kc9bu.0f8nq.urrhk.glob' 0v4.t104r.h4pr1.kc9bu.0f8nq.urrhk]
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 1 2]
version+[1 1 3]
website+'https://tlon.io'
license+'MIT'
==

23668
pkg/grid/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@
},
"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",

View File

@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
import Mousetrap from 'mousetrap';
import { BrowserRouter, Switch, Route, useHistory, useLocation } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import FingerprintJS from '@fingerprintjs/fingerprintjs';
import { Grid } from './pages/Grid';
import useDocketState from './state/docket';
import { PermalinkRoutes } from './pages/PermalinkRoutes';
@ -11,22 +12,45 @@ import api from './state/api';
import { useMedia } from './logic/useMedia';
import { useHarkStore } from './state/hark';
import { useSettingsState, useTheme } from './state/settings';
import { useLocalState } from './state/local';
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);

View File

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

View File

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

View File

@ -1,14 +1,7 @@
import React from 'react';
export const Lock = (props: React.SVGProps<SVGSVGElement>) => (
<svg
width="10"
height="12"
viewBox="-11 -8 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<svg viewBox="-8.5 -6.5 26 26" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,12 @@ import {
} from './base';
import api from './api';
interface BrowserSetting {
browserId: string;
browserNotifications: boolean;
protocolHandling: boolean;
}
interface BaseSettingsState {
display: {
theme: 'light' | 'dark' | 'auto';
@ -25,6 +31,9 @@ interface BaseSettingsState {
order: string[];
};
loaded: boolean;
browserSettings: {
settings: Stringified<BrowserSetting[]>;
};
putEntry: (bucket: string, key: string, value: Value) => Promise<void>;
fetchAll: () => Promise<void>;
[ref: string]: unknown;
@ -79,6 +88,9 @@ export const useSettingsState = createState<BaseSettingsState>(
tiles: {
order: []
},
browserSettings: {
settings: '' as Stringified<BrowserSetting[]>
},
loaded: false,
putEntry: async (bucket, key, val) => {
const poke = doPutEntry(window.desk, bucket, key, val);
@ -110,3 +122,52 @@ const selTheme = (s: SettingsState) => s.display.theme;
export function useTheme() {
return useSettingsState(selTheme);
}
export function parseBrowserSettings(settings: Stringified<BrowserSetting[]>): BrowserSetting[] {
return settings !== '' ? JSON.parse<BrowserSetting[]>(settings) : [];
}
export function getBrowserSetting(
settings: BrowserSetting[],
browserId: string
): BrowserSetting | undefined {
return settings.find((el) => el.browserId === browserId);
}
export function setBrowserSetting(
settings: BrowserSetting[],
newSetting: Partial<BrowserSetting>,
browserId: string
): BrowserSetting[] {
const oldSettings = settings.slice(0);
const oldSettingIndex = oldSettings.findIndex((s) => s.browserId === browserId);
const setting = {
...oldSettings[oldSettingIndex],
browserId,
...newSetting
};
if (oldSettingIndex >= 0) {
oldSettings.splice(oldSettingIndex, 1);
}
return [...oldSettings, setting];
}
const selBrowserSettings = (s: SettingsState) => s.browserSettings.settings;
export function useBrowserSettings(): BrowserSetting[] {
const settings = useSettingsState(selBrowserSettings);
return parseBrowserSettings(settings);
}
export function useProtocolHandling(browserId: string): boolean {
const settings = useBrowserSettings();
const browserSetting = getBrowserSetting(settings, browserId);
return browserSetting?.protocolHandling ?? false;
}
export function useBrowserNotifications(browserId: string): boolean {
const settings = useBrowserSettings();
const browserSetting = getBrowserSetting(settings, browserId);
return browserSetting?.browserNotifications ?? false;
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@
},
"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",
@ -74,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",
@ -88,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",
@ -106,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",
@ -123,6 +124,7 @@
"tsc:watch": "tsc --watch",
"build:dev": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
"build:prod": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
"build:profile": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js --profile --json > stats.json",
"start": "webpack-dev-server --config config/webpack.dev.js",
"test": "tsc && jest",
"jest": "jest",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,7 +35,9 @@ export interface HarkState {
doNotDisturb: boolean;
poke: (poke: Poke<any>) => Promise<void>;
getMore: () => Promise<boolean>;
getUnreads: () => Promise<void>;
opened: () => void;
readCount: (path: string) => Promise<void>;
// getTimeSubset: (start?: Date, end?: Date) => Promise<void>;
unseen: Timebox;
seen: Timebox;
@ -44,7 +46,6 @@ export interface HarkState {
notificationsGroupConfig: string[];
unreads: Unreads;
archiveNote: (bin: HarkBin, lid: HarkLid) => Promise<void>;
readCount: (path: string) => Promise<void>;
readGraph: (graph: string) => Promise<void>;
readGroup: (group: string) => Promise<void>;
}
@ -60,20 +61,20 @@ const useHarkState = createState<HarkState>(
},
readGraph: async (graph: string) => {
const prefix = `/graph/${graph.slice(6)}`;
let counts = [] as string[];
let eaches = [] as [string, string][];
const counts = [] as string[];
const eaches = [] as [string, string][];
Object.entries(get().unreads).forEach(([path, unreads]) => {
if (path.startsWith(prefix)) {
if(unreads.count > 0) {
counts.push(path);
}
unreads.each.forEach(unread => {
unreads.each.forEach((unread) => {
eaches.push([path, unread]);
});
}
});
get().set(draft => {
counts.forEach(path => {
get().set((draft) => {
counts.forEach((path) => {
draft.unreads[path].count = 0;
});
eaches.forEach(([path, each]) => {
@ -86,8 +87,7 @@ const useHarkState = createState<HarkState>(
].map(pok => api.poke(pok)));
},
readGroup: async (group: string) => {
const graphs =
_.pickBy(useMetadataState.getState().associations.graph, a => a.group === group);
const graphs = _.pickBy(useMetadataState.getState().associations.graph, a => a.group === group);
await Promise.all(Object.keys(graphs).map(get().readGraph));
},
readCount: async (path) => {
@ -122,6 +122,13 @@ const useHarkState = createState<HarkState>(
reduceStateN(useHarkState.getState(), update, [reduce]);
return get().archive?.size === oldSize;
},
getUnreads: async (): Promise<void> => {
const update = await api.scry({
app: 'hark-store',
path: '/all-stats'
});
reduceStateN(useHarkState.getState(), update, [reduce]);
},
unseen: {},
seen: {},
notificationsCount: 0,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,125 @@
import * as React from 'react';
import Helmet from 'react-helmet';
import { Router, withRouter } from 'react-router-dom';
import styled, { ThemeProvider } from 'styled-components';
import FingerprintJS from '@fingerprintjs/fingerprintjs';
import { svgDataURL } from '~/logic/lib/util';
import history from '~/logic/lib/history';
import { favicon } from '~/logic/state/contact';
import { SettingsState } from '~/logic/state/settings';
import { ShortcutContextProvider } from '~/logic/lib/shortcutContext';
import ErrorBoundary from '~/views/components/ErrorBoundary';
import './apps/chat/css/custom.css';
import Omnibox from './components/leap/Omnibox';
import StatusBar from './components/StatusBar';
import './css/fonts.css';
import './css/indigo-static.css';
import { Content } from './landscape/components/Content';
import './landscape/css/custom.css';
import { uxToHex } from '@urbit/api';
import { useThemeWatcher } from '~/logic/lib/useThemeWatcher';
import useLocalState from '~/logic/state/local';
function ensureValidHex(color) {
if (!color)
return '#000000';
const isUx = color.startsWith('0x');
const parsedColor = isUx ? uxToHex(color) : color;
return parsedColor.startsWith('#') ? parsedColor : `#${parsedColor}`;
}
const getId = async () => {
const fpPromise = FingerprintJS.load();
const fp = await fpPromise;
const result = await fp.get();
return result.visitorId;
};
interface RootProps {
display: SettingsState['display'];
}
const Root = styled.div<RootProps>`
font-family: ${p => p.theme.fonts.sans};
height: 100%;
width: 100%;
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
margin: 0;
${p => p.display.backgroundType === 'url' ? `
background-image: url('${p.display.background}');
background-size: cover;
` : p.display.backgroundType === 'color' ? `
background-color: ${ensureValidHex(p.display.background)};
` : `background-color: ${p.theme.colors.white};`
}
display: flex;
flex-flow: column nowrap;
touch-action: none;
* {
scrollbar-width: thin;
scrollbar-color: ${ p => p.theme.colors.gray } transparent;
}
/* Works on Chrome/Edge/Safari */
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: ${ p => p.theme.colors.gray };
border-radius: 1rem;
border: 0px solid transparent;
}
`;
const StatusBarWithRouter = withRouter(StatusBar);
const App: React.FunctionComponent = () => {
const { theme, display } = useThemeWatcher();
React.useEffect(() => {
getId().then((value) => {
useLocalState.setState({ browserId: value });
});
}, []);
return (
<ThemeProvider theme={theme}>
<ShortcutContextProvider>
<Helmet>
{window.ship.length < 14
? <link rel="icon" type="image/svg+xml" href={svgDataURL(favicon())} />
: null}
</Helmet>
<Root display={display}>
<Router history={history}>
<ErrorBoundary>
<StatusBarWithRouter />
</ErrorBoundary>
<ErrorBoundary>
<Omnibox />
</ErrorBoundary>
<ErrorBoundary>
<Content />
</ErrorBoundary>
</Router>
</Root>
<div id="portal-root" />
</ShortcutContextProvider>
</ThemeProvider>
);
};
export default App;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,7 @@
import { Action, Box, Col, Icon, Row, Text } from '@tlon/indigo-react';
import { GraphNode, Group, removePosts } from '@urbit/api';
import { GraphNode, Group, removePosts, roleForShip } from '@urbit/api';
import bigInt from 'big-integer';
import React, { useCallback, useEffect, useRef } from 'react';
import { roleForShip } from '~/logic/lib/group';
import { getPermalinkForGraph } from '~/logic/lib/permalinks';
import { getLatestCommentRevision } from '~/logic/lib/publish';
import { useCopy } from '~/logic/lib/useCopy';
@ -129,6 +128,7 @@ return false;
{(window.ship == post?.author && !disabled) ? (
<ActionLink
color="blue"
bg="white"
to={{
pathname: props.baseUrl,
search: `?edit=${commentIndex}`

View File

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

View File

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

View File

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

View File

@ -1,318 +0,0 @@
import { css } from '@styled-system/css';
import { Box, Icon, LoadingSpinner, Row, Text } from '@tlon/indigo-react';
import {
accept,
decline,
Invite,
joinProgress,
joinResult,
JoinRequest,
Metadata,
MetadataUpdatePreview,
resourceFromPath
} from '@urbit/api';
import { GraphConfig } from '@urbit/api';
import _ from 'lodash';
import React, { ReactElement, ReactNode, useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import styled from 'styled-components';
import { useRunIO } from '~/logic/lib/useRunIO';
import { useWaitForProps } from '~/logic/lib/useWaitForProps';
import { cite, isDm } from '~/logic/lib/util';
import useGraphState from '~/logic/state/graph';
import useGroupState from '~/logic/state/group';
import useMetadataState, { useAssocForGraph } from '~/logic/state/metadata';
import { PropFunc } from '~/types';
import { Header } from '~/views/apps/notifications/header';
import { MetadataIcon } from '~/views/landscape/components/MetadataIcon';
import { StatelessAsyncButton } from '../StatelessAsyncButton';
import airlock from '~/logic/api';
interface GroupInviteProps {
preview?: MetadataUpdatePreview;
status?: JoinRequest;
app?: string;
uid?: string;
invite?: Invite;
resource: string;
}
function Elbow(
props: { size?: number; color?: string } & PropFunc<typeof Box>
) {
const { size = 12, color = 'lightGray', ...rest } = props;
return (
<Box
{...rest}
overflow="hidden"
width={size}
height={size}
position="relative"
>
<Box
border="2px solid"
borderRadius={3}
borderColor={color}
position="absolute"
left="0px"
bottom="0px"
width={size * 2}
height={size * 2}
/>
</Box>
);
}
const description: string[] = [
'Contacting host...',
'Retrieving data...',
'Finished join',
'Unable to join, you do not have the correct permissions',
'Internal error, please file an issue'
];
function inviteUrl(hidden: boolean, resource: string, metadata?: Metadata) {
if (!hidden) {
return `/~landscape${resource}`;
}
if ((metadata?.config as GraphConfig).graph === 'chat') {
return `/~landscape/messages/resource/${
(metadata?.config as GraphConfig)?.graph
}${resource}`;
} else {
return `/~landscape/home/resource/${
(metadata?.config as GraphConfig)?.graph
}${resource}`;
}
}
function InviteMetadata(props: {
preview?: MetadataUpdatePreview;
resource: string;
}) {
const { resource, preview } = props;
const { ship, name } = resourceFromPath(resource);
const dm = isDm(resource);
if (dm) {
return null;
}
const container = (children: ReactNode) => (
<Row overflow="hidden" height={4} gapX={2} alignItems="center">
{children}
</Row>
);
if (preview) {
const { title } = preview.metadata;
const { members } = preview;
return container(
<>
<MetadataIcon height={4} width={4} metadata={preview.metadata} />
<Text fontWeight="medium">{title}</Text>
<Text gray fontWeight="medium">
{members} Member{members > 1 ? 's' : ''}
</Text>
</>
);
}
return container(
<>
<Text whiteSpace="nowrap" textOverflow="ellipsis" ml="1px" mb="2px" mono>
{cite(ship)}/{name}
</Text>
</>
);
}
function InviteStatus(props: { status?: JoinRequest }) {
const { status } = props;
if (!status) {
return null;
}
const current = status && joinProgress.indexOf(status.progress);
const desc = _.isNumber(current) && description[current];
return (
<Row gapX={2} alignItems="center" minHeight={4}>
<Row alignItems="center" flexShrink={0}>
{joinResult.includes(status?.progress as any) ? (
<Icon icon={status?.progress === 'done' ? 'Checkmark' : 'X'} />
) : (
<LoadingSpinner dark />
)}
</Row>
<Text gray>{desc}</Text>
</Row>
);
}
export function useInviteAccept(resource: string, app?: string, uid?: string) {
const { ship, name } = resourceFromPath(resource);
const history = useHistory();
const associations = useMetadataState(s => s.associations);
const groups = useGroupState(s => s.groups);
const graphKeys = useGraphState(s => s.graphKeys);
const waiter = useWaitForProps({ associations, graphKeys, groups });
return useRunIO<void, boolean>(
async () => {
if (!(app && uid)) {
return false;
}
if (resource in groups) {
await airlock.poke(decline(app, uid));
return false;
}
await airlock.poke(accept(app, uid));
await waiter((p) => {
return (
(resource in p.groups &&
resource in (p.associations?.graph ?? {}) &&
p.graphKeys.has(resource.slice(7))) ||
resource in (p.associations?.groups ?? {})
);
});
return true;
},
(success: boolean) => {
if (!success) {
return;
}
const redir = inviteUrl(
groups?.[resource]?.hidden,
resource,
associations?.graph?.[resource]?.metadata
);
if (redir) {
// weird race condition
setTimeout(() => {
history.push(redir);
}, 200);
}
},
resource
);
}
function InviteActions(props: {
status?: JoinRequest;
resource: string;
app?: string;
uid?: string;
}) {
const { status, resource, app, uid } = props;
const inviteAccept = useInviteAccept(resource, app, uid);
const inviteDecline = useCallback(async () => {
if (!(app && uid)) {
return;
}
await airlock.poke(decline(app, uid));
}, [app, uid]);
if (status) {
return (
<Row gapX={2} alignItems="center" height={4}>
<StatelessAsyncButton
height={4}
backgroundColor="white"
onClick={async () => {}}
>
{[...joinResult].includes(status?.progress as any)
? 'Dismiss'
: 'Cancel'}
</StatelessAsyncButton>
</Row>
);
}
return (
<Row gapX={2} alignItems="center" height={4}>
<StatelessAsyncButton
color="blue"
height={4}
backgroundColor="white"
onClick={inviteAccept as any}
>
Accept
</StatelessAsyncButton>
<StatelessAsyncButton
height={4}
backgroundColor="white"
onClick={inviteDecline as any}
>
Decline
</StatelessAsyncButton>
</Row>
);
}
const responsiveStyle = ({ gapXY = 0 as number | number[] }) => {
return css({
flexDirection: ['column', 'row'],
'& > *': {
marginTop: _.isArray(gapXY) ? [gapXY[0], 0] : [gapXY, 0],
marginLeft: _.isArray(gapXY) ? [0, ...gapXY.slice(1)] : [0, gapXY]
},
'& > :first-child': {
marginTop: 0,
marginLeft: 0
}
});
};
const ResponsiveRow = styled(Row)(responsiveStyle);
export function GroupInvite(props: GroupInviteProps): ReactElement {
const { resource, preview, invite, status, app, uid } = props;
const dm = isDm(resource);
const history = useHistory();
const invitedTo = dm ? 'DM' : 'group';
const graphAssoc = useAssocForGraph(resource);
const headerProps = status
? { description: `You are joining a ${invitedTo}` }
: { description: `invited you to a ${invitedTo}`, authors: [invite!.ship] };
const onClick = () => {
if (status?.progress === 'done') {
const redir = inviteUrl(app !== 'groups', resource, graphAssoc?.metadata);
if (redir) {
history.push(redir);
}
}
};
return (
<>
<Header content {...headerProps} />
<Row
onClick={onClick}
height={[null, 4]}
alignItems="flex-start"
gridArea="main"
>
<Elbow display={['none', 'block']} mx={2} />
<ResponsiveRow
gapXY={2}
height={[null, 4]}
alignItems={['flex-start', 'center']}
>
<InviteMetadata preview={preview} resource={resource} />
<InviteStatus status={status} />
<InviteActions
resource={resource}
status={status}
app={app}
uid={uid}
/>
</ResponsiveRow>
</Row>
</>
);
}

View File

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

View File

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

View File

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

View File

@ -44,13 +44,14 @@ function onStopProp<T extends HTMLElement>(e: MouseEvent<T>) {
type ImageProps = PropFunc<typeof BaseImage> & {
objectFit?: string;
stretch?: boolean;
};
const Image = styled.img(system({ objectFit: true }), ...allSystemStyle);
export function RemoteContentImageEmbed(
props: ImageProps & RemoteContentEmbedProps
) {
const { url, ...rest } = props;
const { url, stretch, ...rest } = props;
const [noCors, setNoCors] = useState(false);
const { hovering, bind } = useHovering();
// maybe images aren't set up for CORS embeds
@ -59,7 +60,13 @@ export function RemoteContentImageEmbed(
}, []);
return (
<Box height="100%" width="100%" position="relative" {...bind} {...rest}>
<Box
height={stretch ? "100%" : "192px"}
width={stretch ? "100%" : "192px"}
position="relative"
{...bind}
{...rest}
>
<BaseAnchor
position="absolute"
top={2}
@ -84,9 +91,10 @@ export function RemoteContentImageEmbed(
referrerPolicy="no-referrer"
flexShrink={0}
src={url}
height="100%"
height={stretch ? "100%" : "192px"}
maxWidth={stretch ? "100%" : "192px"}
width="100%"
objectFit="contain"
objectFit="cover"
borderRadius={2}
onError={onError}
{...props}

View File

@ -36,7 +36,7 @@ const getNicknameForShips = (groups: Groups, contacts: Rolodex, selected: string
const peerSet = new Set<string>();
const nicknames = new Map<string, string[]>();
_.forEach(groups, (group, path) => {
if (group.members.size > 0) {
if (group.members.length > 0) {
const groupEntries = group.members.values();
for (const member of groupEntries) {
if(!selected.includes(member)) {
@ -204,7 +204,7 @@ export function ShipSearch<I extends string, V extends Value<I>>(
onChange={onChange}
onSelect={onAdd}
/>
<Row minHeight="34px" flexWrap="wrap">
<Row overflowX="auto" maxHeight="192px" minHeight="34px" flexWrap="wrap">
{pills.map((s, i) => (
<Row
fontFamily="mono"

View File

@ -1,7 +1,7 @@
import {
LoadingSpinner, StatelessToggleSwitchField as Toggle,
Text
Box,
Icon,
LoadingSpinner, StatelessToggleSwitchField as Toggle
} from '@tlon/indigo-react';
import React, { ReactElement } from 'react';
import { useStatelessAsyncClickable } from '~/logic/lib/useStatelessAsyncClickable';
@ -22,11 +22,17 @@ export function StatelessAsyncToggle({
} = useStatelessAsyncClickable(onClick, name);
return state === 'error' ? (
<Text>Error</Text>
<Box width={5} textAlign='center' title='Something went wrong...'>
<Icon icon='ExclaimationMarkBold' />
</Box>
) : state === 'loading' ? (
<LoadingSpinner foreground={'white'} background="gray" />
<Box width={5} textAlign='center'>
<LoadingSpinner foreground={'white'} background="gray" />
</Box>
) : state === 'success' ? (
<Text mx={2}>Done</Text>
<Box width={5} textAlign='center' title='Success'>
<Icon icon='CheckmarkBold' />
</Box>
) : (
<Toggle onClick={handleClick} {...rest} />
);

View File

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

View File

@ -7,7 +7,9 @@ import {
Row,
Text
} from '@tlon/indigo-react';
import React from 'react';
import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind';
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Sigil } from '~/logic/lib/sigil';
import { uxToHex } from '~/logic/lib/util';
@ -22,8 +24,8 @@ import useHarkState from '~/logic/state/hark';
const localSel = selectLocalState(['toggleOmnibox']);
const StatusBar = (props) => {
const { ship } = props;
const StatusBar = () => {
const ship = window.ship;
const ourContact = useContactState(state => state.contacts[`~${ship}`]);
const metaKey = window.navigator.platform.includes('Mac') ? '⌘' : 'Ctrl+';
const { toggleOmnibox } = useLocalState(localSel);
@ -46,6 +48,14 @@ const StatusBar = (props) => {
<Sigil ship={ship} size={16} color={color} icon />
);
useEffect(() => {
Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => {
e.preventDefault();
e.stopImmediatePropagation();
toggleOmnibox();
});
}, []);
return (
<Box
display='grid'
@ -64,7 +74,6 @@ const StatusBar = (props) => {
borderColor='lightGray'
mr={2}
px={2}
{...props}
>
<Icon icon='Dashboard' color='black' />
</Button>

View File

@ -2,9 +2,7 @@ import { Text } from '@tlon/indigo-react';
import styled from 'styled-components';
export const TruncatedText = styled(Text)`
white-space: pre;
text-overflow: ellipsis;
overflow: hidden;
min-width: 0;
overflow-wrap: anywhere;
`;

View File

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

View File

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

View File

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

View File

@ -1,22 +1,21 @@
import { Box } from '@tlon/indigo-react';
import React, { useCallback, useEffect } from 'react';
import React, { Suspense, useCallback, useEffect } from 'react';
import { Route, Switch, useHistory, useLocation } from 'react-router-dom';
import styled from 'styled-components';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import useMetadataState from '~/logic/state/metadata';
import LaunchApp from '~/views/apps/launch/App';
import Notifications from '~/views/apps/notifications/notifications';
import { PermalinkRoutes } from '~/views/apps/permalinks/app';
import Profile from '~/views/apps/profile/profile';
import Settings from '~/views/apps/settings/settings';
import ErrorComponent from '~/views/components/Error';
import { useShortcut } from '~/logic/state/settings';
import { Loading } from '~/views/components/Loading';
import LaunchApp from '~/views/apps/launch/App';
import Landscape from '~/views/landscape';
import Settings from '~/views/apps/settings/settings';
import Profile from '~/views/apps/profile/profile';
import Notifications from '~/views/apps/notifications/notifications';
import ErrorComponent from '~/views/components/Error';
import Landscape from '~/views/landscape/index';
import GraphApp from '../../apps/graph/App';
import { getNotificationRedirect } from '~/logic/lib/notificationRedirects';
import {JoinRoute} from './Join/Join';
import { getNotificationRedirectFromLink } from '~/logic/lib/notificationRedirects';
import { JoinRoute } from './Join/Join';
import useInviteState from '~/logic/state/invite';
import useMetadataState from '~/logic/state/metadata';
export const Container = styled(Box)`
flex-grow: 1;
@ -25,7 +24,7 @@ export const Container = styled(Box)`
height: calc(100% - 62px);
`;
export const Content = (props) => {
export const Content = () => {
const history = useHistory();
const location = useLocation();
const mdLoaded = useMetadataState(s => s.loaded);
@ -37,7 +36,7 @@ export const Content = (props) => {
return;
}
if(query.has('grid-note')) {
history.push(getNotificationRedirect(query.get('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}`);
@ -56,72 +55,44 @@ export const Content = (props) => {
history.goBack();
}, [history.goBack]));
const [hasProtocol, setHasProtocol] = useLocalStorageState(
'registeredProtocol', false
);
useEffect(() => {
if(!hasProtocol && window?.navigator?.registerProtocolHandler) {
try {
window.navigator.registerProtocolHandler('web+urbitgraph', '/perma?ext=%s', 'Urbit Links');
console.log('registered protocol');
setHasProtocol(true);
} catch (e) {
console.log(e);
}
}
}, [hasProtocol]);
return (
<Container>
<JoinRoute />
<Switch>
<Route
exact
path="/" render={p => (
<LaunchApp
location={p.location}
match={p.match}
{...props}
/>
)}
/>
<Route path='/~landscape'>
<Landscape />
</Route>
<Route
path="/~profile"
render={ p => (
<Profile
{...props}
/>
)}
/>
<Route
path="/~settings"
render={ p => (
<Settings {...props} />
)}
/>
<Route
path="/~notifications"
render={ p => (
<Notifications {...props} />
)}
/>
<GraphApp path="/~graph" {...props} />
<PermalinkRoutes {...props} />
<Suspense fallback={Loading}>
<JoinRoute />
<Switch>
<Route
exact
path="/"
component={LaunchApp}
/>
<Route path='/~landscape'>
<Landscape />
</Route>
<Route
path="/~profile"
component={Profile}
/>
<Route
path="/~settings"
component={Settings}
/>
<Route
path="/~notifications"
component={Notifications}
/>
<PermalinkRoutes />
<Route
render={p => (
<ErrorComponent
code={404}
description="Not Found"
{...p}
/>
)}
/>
</Switch>
<Route
render={p => (
<ErrorComponent
code={404}
description="Not Found"
{...p}
/>
)}
/>
</Switch>
</Suspense>
</Container>
);
};

View File

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

View File

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

View File

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

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