grid: upgrade for new hark store

This commit is contained in:
Liam Fitzgerald 2021-09-06 13:56:29 +10:00
parent 76e033c649
commit 43093f811f
13 changed files with 452 additions and 65 deletions

View File

@ -46,6 +46,7 @@ module.exports = {
'@typescript-eslint/no-shadow': ['error'],
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'off',
'react/no-array-index-key': 'off',
'import/prefer-default-export': 'off',
'react/prop-types': 'off',
'react/jsx-props-no-spreading': 'off',

View File

@ -13,8 +13,10 @@
"@radix-ui/react-polymorphic": "^0.0.13",
"@radix-ui/react-portal": "^0.0.15",
"@radix-ui/react-toggle": "^0.0.10",
"@types/lodash": "^4.14.172",
"@urbit/api": "^1.4.0",
"@urbit/http-api": "^1.3.1",
"big-integer": "^1.6.48",
"classnames": "^2.3.1",
"clipboard-copy": "^4.0.1",
"color2k": "^1.2.4",
@ -1435,7 +1437,21 @@
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/big-integer": {
"version": "1.6.48",
@ -1517,6 +1533,20 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
@ -3598,7 +3628,21 @@
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/ignore": {
"version": "4.0.6",

View File

@ -19,8 +19,10 @@
"@radix-ui/react-polymorphic": "^0.0.13",
"@radix-ui/react-portal": "^0.0.15",
"@radix-ui/react-toggle": "^0.0.10",
"@types/lodash": "^4.14.172",
"@urbit/api": "^1.4.0",
"@urbit/http-api": "^1.3.1",
"big-integer": "^1.6.48",
"classnames": "^2.3.1",
"clipboard-copy": "^4.0.1",
"color2k": "^1.2.4",

View File

@ -1,23 +1,31 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Notification } from '@urbit/api';
import { useLeapStore } from './Nav';
import { Button } from '../components/Button';
import { Notification } from '../state/hark-types';
import { BasicNotification } from './notifications/BasicNotification';
import {
BaseBlockedNotification,
RuntimeLagNotification
} from './notifications/SystemNotification';
import { useNotifications } from '../state/notifications';
import { useHarkStore } from '../state/hark';
import { OnboardingNotification } from './notifications/OnboardingNotification';
function renderNotification(notification: Notification, key: string) {
if (notification.type === 'system-updates-blocked') {
return <BaseBlockedNotification key={key} notification={notification} />;
}
if (notification.type === 'runtime-lag') {
function renderNotification(notification: Notification, key: string, unread = false) {
// Special casing
if (notification.bin.place.desk === window.desk) {
if (notification.bin.place.path === '/lag') {
return <RuntimeLagNotification key={key} />;
}
return <BasicNotification key={key} notification={notification} />;
if (notification.bin.place.path === '/blocked') {
return <BaseBlockedNotification key={key} />;
}
if (notification.bin.place.path === '/onboard') {
return <OnboardingNotification key={key} unread={unread} />;
}
}
return <BasicNotification key={key} notification={notification} unread={unread} />;
}
const Empty = () => (
@ -28,16 +36,22 @@ const Empty = () => (
export const Notifications = () => {
const select = useLeapStore((s) => s.select);
const { notifications, systemNotifications, hasAnyNotifications } = useNotifications();
const { unreads, reads, systemNotifications, hasAnyNotifications } = useNotifications();
const markAllAsRead = () => {
const { readAll } = useHarkStore.getState();
readAll();
};
useEffect(() => {
select('Notifications');
const { getMore } = useHarkStore.getState();
getMore();
}, []);
return (
<div className="grid grid-rows-[auto,1fr] h-full p-4 md:p-8 overflow-hidden">
<header className="space-x-2 mb-8">
<Button variant="secondary" className="py-1.5 px-6 rounded-full">
<Button onClick={markAllAsRead} variant="secondary" className="py-1.5 px-6 rounded-full">
Mark All as Read
</Button>
<Button
@ -53,10 +67,16 @@ export const Notifications = () => {
{!hasAnyNotifications && <Empty />}
{hasAnyNotifications && (
<section className="text-gray-400 space-y-2 overflow-y-auto">
{notifications.map((n, index) => renderNotification(n, index.toString()))}
{systemNotifications.map((n, index) =>
renderNotification(n, (notifications.length + index).toString())
renderNotification(n, (unreads.length + index).toString())
)}
{unreads.map((n, index) =>
renderNotification(n, (systemNotifications.length + index).toString(), true)
)}
{Array.from(reads)
.map(([, nots]) => nots)
.flat()
.map((n, index) => renderNotification(n, `reads-${index}`))}
</section>
)}
</div>

View File

@ -1,8 +1,8 @@
import classNames from 'classnames';
import React from 'react';
import { Notification } from '@urbit/api';
import { Link, LinkProps } from 'react-router-dom';
import { Bullet } from '../components/icons/Bullet';
import { Notification } from '../state/hark-types';
import { useNotifications } from '../state/notifications';
type NotificationsState = 'empty' | 'unread' | 'attention-needed';
@ -28,8 +28,8 @@ type NotificationsLinkProps = Omit<LinkProps<HTMLAnchorElement>, 'to'> & {
};
export const NotificationsLink = ({ isOpen }: NotificationsLinkProps) => {
const { notifications, systemNotifications } = useNotifications();
const state = getNotificationsState(notifications, systemNotifications);
const { unreads, systemNotifications } = useNotifications();
const state = getNotificationsState(unreads, systemNotifications);
return (
<Link
@ -44,7 +44,7 @@ export const NotificationsLink = ({ isOpen }: NotificationsLinkProps) => {
)}
>
{state === 'empty' && <Bullet className="w-6 h-6" />}
{state === 'unread' && notifications.length}
{state === 'unread' && unreads.length}
{state === 'attention-needed' && (
<span className="h2">
! <span className="sr-only">Attention needed</span>

View File

@ -1,10 +1,80 @@
import React from 'react';
import { BasicNotification as BasicNotificationType } from '../../state/hark-types';
import cn from 'classnames';
import { Notification, harkBinToId, HarkContent } from '@urbit/api';
import { map, take } from 'lodash-es';
import { useCharge } from '../../state/docket';
import { Elbow } from '../../components/icons/Elbow';
import { ShipName } from '../../components/ShipName';
import { getAppHref } from '../../state/util';
interface BasicNotificationProps {
notification: BasicNotificationType;
notification: Notification;
unread?: boolean;
}
export const BasicNotification = ({ notification }: BasicNotificationProps) => (
<div>{notification.message}</div>
);
const MAX_CONTENTS = 20;
const NotificationText = ({ contents }: { contents: HarkContent[] }) => {
return (
<>
{contents.map((content, idx) => {
if ('ship' in content) {
return <ShipName key={idx} name={content.ship} />;
}
return content.text;
})}
</>
);
};
export const BasicNotification = ({ notification, unread = false }: BasicNotificationProps) => {
const { desk } = notification.bin.place;
const binId = harkBinToId(notification.bin);
const id = `notif-${notification.time}-${binId}`;
const charge = useCharge(desk);
const first = notification.body?.[0];
if (!first) {
return null;
}
const contents = map(notification.body, 'content').filter((c) => c.length > 0);
const large = contents.length === 0;
const link = `${getAppHref(charge.href)}?grid-note=${encodeURIComponent(first.link)}`;
return (
<a
href={link}
target={desk}
className={cn(
'text-black rounded',
unread ? 'bg-blue-100' : 'bg-gray-100',
large ? 'note-grid-no-content' : 'note-grid-content'
)}
aria-labelledby={id}
>
<header id={id} className="contents">
<div
className="note-grid-icon rounded w-full h-full"
style={{ backgroundColor: charge?.color ?? '#ee5432' }}
/>
<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}
<h2 id={`${id}-title`} className="note-grid-head font-semibold text-gray-600">
<NotificationText contents={first.title} />
</h2>
</header>
{contents.length > 0 ? (
<div className="note-grid-body space-y-2">
{take(contents, MAX_CONTENTS).map((content) => (
<p className="">
<NotificationText contents={content} />
</p>
))}
{contents.length > MAX_CONTENTS ? (
<p className="text-gray-300">and {contents.length - MAX_CONTENTS} more</p>
) : null}
</div>
) : null}
</a>
);
};

View File

@ -0,0 +1,86 @@
import React from 'react';
import cn from 'classnames';
import { Button } from '../../components/Button';
const cards: OnboardingCardProps[] = [
{
title: 'Terminal',
body: "Install a web terminal to access your Urbit's command line",
button: 'Install',
color: '#9CA4B1'
},
{
title: 'Groups',
body: 'Install Groups, a suite of social software to communicate with other urbit users',
button: 'Install',
color: '#D1DDD3'
},
{
title: 'Bitcoin',
body: 'Install a bitcoin wallet. You can send bitcoin to any btc address, or even another ship',
button: 'Install',
color: '#F6EBDB'
},
{
title: 'Debug',
body: "Install a debugger. You can inspect your ship's internals using this interface",
button: 'Install'
},
{
title: 'Build an app',
body: 'You can instantly get started building new things on Urbit. Just right click your Landscape and select “New App”',
button: 'Learn more',
color: '#82A6CA'
}
];
interface OnboardingCardProps {
title: string;
button: string;
body: string;
color?: string;
}
const OnboardingCard = ({ title, button, body, color }: OnboardingCardProps) => (
<div
className="p-4 flex flex-col space-y-2 text-black bg-gray-100 justify-between rounded-xl"
style={color ? { backgroundColor: color } : {}}
>
<div className="space-y-1">
<h4 className="font-semibold">{title}</h4>
<p>{body}</p>
</div>
<Button variant="primary" className="bg-gray-500">
{button}
</Button>
</div>
);
interface OnboardingNotificationProps {
unread: boolean;
}
export const OnboardingNotification = ({ unread }: OnboardingNotificationProps) => (
<section
className={cn('notification space-y-2 text-black', unread ? 'bg-blue-100' : 'bg-gray-100')}
aria-labelledby=""
>
<header id="system-updates-blocked" className="relative space-y-2">
<div className="flex space-x-2">
<span className="inline-block w-6 h-6 bg-orange-500 rounded-full" />
<span className="font-medium">Grid</span>
</div>
<div className="flex space-x-2">
<h2 id="runtime-lag">Hello there, welcome to Grid!</h2>
</div>
</header>
<div className="grid grid-cols-3 grid-rows-2 gap-4">
{
/* eslint-disable-next-line react/no-array-index-key */
cards.map((card, i) => (
<OnboardingCard key={i} {...card} />
))
}
</div>
</section>
);

View File

@ -1,21 +1,16 @@
import { pick } from 'lodash-es';
import React, { useCallback } from 'react';
import { kilnSuspend } from '@urbit/api/hood';
import { kilnSuspend } from '@urbit/api';
import { AppList } from '../../components/AppList';
import { Button } from '../../components/Button';
import { Dialog, DialogClose, DialogContent, DialogTrigger } from '../../components/Dialog';
import { Elbow } from '../../components/icons/Elbow';
import api from '../../state/api';
import { useCharges } from '../../state/docket';
import { BaseBlockedNotification as BaseBlockedNotificationType } from '../../state/hark-types';
import { NotificationButton } from './NotificationButton';
import { disableDefault } from '../../state/util';
interface BaseBlockedNotificationProps {
notification: BaseBlockedNotificationType;
}
export const RuntimeLagNotification = () => (
<section
className="notification pl-12 space-y-2 text-black bg-orange-50"
@ -40,8 +35,8 @@ export const RuntimeLagNotification = () => (
</section>
);
export const BaseBlockedNotification = ({ notification }: BaseBlockedNotificationProps) => {
const { desks } = notification;
export const BaseBlockedNotification = () => {
const desks: string[] = [];
const charges = useCharges();
const blockedCharges = Object.values(pick(charges, desks));
const count = blockedCharges.length;

View File

@ -1,12 +1,95 @@
/* eslint-disable no-param-reassign */
import create from 'zustand';
import { Notification } from './hark-types';
import { mockNotification } from './mock-data';
import { Notification, harkBinEq, makePatDa, readAll, decToUd, unixToDa } from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
/* eslint-disable-next-line camelcase */
import { unstable_batchedUpdates } from 'react-dom';
import produce from 'immer';
import { map } from 'lodash';
import bigInt from 'big-integer';
import api from './api';
import { useMockData } from './util';
import { mockNotifications } from './mock-data';
interface HarkStore {
notifications: Notification[];
unreads: Notification[];
reads: BigIntOrderedMap<Notification[]>;
set: (f: (s: HarkStore) => void) => void;
readAll: () => Promise<void>;
getMore: () => Promise<void>;
}
export const useHarkStore = create<HarkStore>(() => ({
notifications: useMockData ? [mockNotification] : []
export const useHarkStore = create<HarkStore>((set, get) => ({
unreads: useMockData ? mockNotifications : [],
reads: useMockData
? new BigIntOrderedMap<Notification[]>().gas([[bigInt.zero, mockNotifications]])
: new BigIntOrderedMap<Notification[]>(),
set: (f) => {
const newState = produce(get(), f);
set(newState);
},
readAll: async () => {
const { set: innerSet } = get();
innerSet((state) => {
state.unreads.forEach((note) => {
const time = makePatDa(note.time as any);
const box = state.reads.get(time) || [];
state.reads.set(time, [...box, note]);
});
state.unreads = [];
});
await api.poke(readAll);
},
getMore: async () => {
const { reads } = get();
const idx = decToUd((reads.peekSmallest()?.[0] || unixToDa(Date.now() * 1000)).toString());
const update = await api.scry({
app: 'hark-store',
path: `/recent/inbox/${idx}/5`
});
reduceHark(update);
}
}));
function reduceHark(u: any) {
const { set } = useHarkStore.getState();
if ('more' in u) {
u.more.forEach((upd) => {
reduceHark(upd);
});
} else if ('all-stats' in u) {
// TODO: probably ignore?
} else if ('added' in u) {
set((state) => {
state.unreads = state.unreads.filter((unread) => !harkBinEq(unread.bin, u.added.bin));
state.unreads.push(u.added);
});
} else if ('timebox' in u) {
const { timebox } = u;
const notifications = map(timebox.notifications, 'notification');
if (timebox.time) {
console.log(notifications);
set((state) => {
state.reads = state.reads.set(makePatDa(timebox.time), notifications);
});
} else {
set((state) => {
state.unreads = [...state.unreads, ...notifications];
});
}
}
}
api.subscribe({
app: 'hark-store',
path: '/updates',
event: (u: any) => {
/* eslint-ignore-next-line camelcase */
unstable_batchedUpdates(() => {
reduceHark(u);
});
}
});
window.hark = useHarkStore.getState;

View File

@ -1,8 +1,16 @@
import _ from 'lodash-es';
import { Allies, Charges, DocketHrefGlob, Treaties, Treaty } from '@urbit/api/docket';
import { Vat, Vats } from '@urbit/api/hood';
import {
Vat,
Vats,
Allies,
Charges,
DocketHrefGlob,
Treaties,
Treaty,
Notification,
HarkContent
} from '@urbit/api';
import systemUrl from '../assets/system.png';
import { BasicNotification } from './hark-types';
export const appMetaData: Pick<Treaty, 'cass' | 'hash' | 'website' | 'license' | 'version'> = {
cass: {
@ -155,11 +163,58 @@ export const mockAllies: Allies = [
'~nalrys'
].reduce((acc, val) => ({ ...acc, [val]: charter }), {});
export const mockNotification: BasicNotification = {
type: 'basic',
time: '',
message: 'test'
};
function createDmNotification(content: string): HarkContent {
return {
title: ' messaged you',
author: '~hastuc-dibtux',
time: Date.now() - 3_600,
content,
links: []
};
}
function createBitcoinNotif(amount: string) {
return {
title: ` sent you ${amount}`,
author: '~silnem',
time: Date.now() - 3_600,
content: '',
links: []
};
}
function createGroupNotif(to: string): HarkContent {
return {
title: ` invited you to ${to}`,
author: '~ridlur-figbud',
content: '',
time: Date.now() - 3_600,
links: []
};
}
export function createMockNotification(desk: string, contents: HarkContent[]) {
return {
bin: {
place: {
desk,
path: '/'
},
path: '/'
},
time: Date.now() - 3_600,
contents
};
}
export const mockNotifications: Notification[] = [
createMockNotification('groups', [
createDmNotification('ie the hook agent responsible for marking the notifications'),
createDmNotification('~hastuc-dibtux sent a link')
]),
createMockNotification('bitcoin-wallet', [createBitcoinNotif('0.025 BTC')]),
createMockNotification('groups', [createGroupNotif('a Group: Tlon Corporation')])
];
export const mockVat = (desk: string, blockers?: boolean): Vat => ({
cass: {

View File

@ -1,28 +1,13 @@
import shallow from 'zustand/shallow';
import { useHarkStore } from './hark';
import { Notification } from './hark-types';
import { useBlockers, useLag } from './kiln';
function getSystemNotifications(lag: boolean, blockers: string[]) {
const nots = [] as Notification[];
if (lag) {
nots.push({ type: 'runtime-lag' });
}
if (blockers.length > 0) {
nots.push({ type: 'system-updates-blocked', desks: blockers });
}
return nots;
}
export const useNotifications = () => {
const notifications = useHarkStore((s) => s.notifications);
const blockers = useBlockers();
const lag = useLag();
const systemNotifications = getSystemNotifications(lag, blockers);
const hasAnyNotifications = notifications.length > 0 || systemNotifications.length > 0;
const [unreads, reads] = useHarkStore((s) => [s.unreads, s.reads], shallow);
const hasAnyNotifications = unreads.length > 0 || reads.size > 0;
return {
notifications,
systemNotifications,
unreads,
reads,
hasAnyNotifications
};
};

View File

@ -0,0 +1,44 @@
.note-grid-content {
display: grid;
grid-template-columns: 1.5rem 1fr;
grid-template-rows: 1.5rem 1.5rem;
grid-gap: 0.5rem;
padding: 1rem;
grid-template-areas:
'icon title'
'arrow head'
'. body';
}
.note-grid-no-content {
display: grid;
width: 100%;
grid-template-rows: 1.75rem 1.75rem;
grid-template-columns: 3.5rem 1fr;
grid-column-gap: 0.75rem;
align-items: center;
grid-template-areas:
'icon title'
'icon head';
}
.note-grid-title {
grid-area: title;
}
.note-grid-icon {
grid-area: icon;
}
.note-grid-body {
grid-area: body;
}
.note-grid-arrow {
grid-area: arrow;
}
.note-grid-head {
grid-area: head;
}

View File

@ -6,3 +6,5 @@
@import "tailwindcss/utilities";
@import "./utilities.css";
@import "./grids.css";