notifications: adding state to nav link and cleaning up

This commit is contained in:
Hunter Miller 2021-08-25 15:11:09 -05:00
parent 63515c573a
commit 432ee1c548
19 changed files with 21196 additions and 94 deletions

File diff suppressed because it is too large Load Diff

View File

@ -18,14 +18,15 @@
"@radix-ui/react-dropdown-menu": "^0.0.23",
"@radix-ui/react-polymorphic": "^0.0.13",
"@radix-ui/react-portal": "^0.0.15",
"@urbit/http-api": "^1.3.1",
"@urbit/api": "^1.4.0",
"@urbit/http-api": "^1.3.1",
"classnames": "^2.3.1",
"clipboard-copy": "^4.0.1",
"color2k": "^1.2.4",
"fuzzy": "^0.1.3",
"immer": "^9.0.5",
"lodash-es": "^4.17.21",
"moment": "^2.29.1",
"mousetrap": "^1.6.5",
"postcss-import": "^14.0.2",
"query-string": "^7.0.1",

View File

@ -52,7 +52,7 @@ export const AppLink = <T extends Docket>({
return link(
<>
<div
className={classNames('flex-none relative bg-gray-200 rounded-lg', sizeMap[size])}
className={classNames('flex-none relative bg-gray-200', sizeMap[size])}
style={{ backgroundColor: app.color }}
>
{app.image && (

View File

@ -1,5 +1,6 @@
import React, { MouseEvent, useCallback } from 'react';
import { Docket } from '@urbit/api';
import classNames from 'classnames';
import { MatchItem } from '../nav/Nav';
import { useRecentsStore } from '../nav/search/Home';
import { AppLink, AppLinkProps } from './AppLink';
@ -27,18 +28,28 @@ export const AppList = <T extends Docket>({
matchAgainst,
onClick,
listClass = 'space-y-8',
size = 'default',
...props
}: AppListProps<T>) => {
const addRecentApp = useRecentsStore((state) => state.addRecentApp);
const selected = useCallback((app: Docket) => appMatches(app, matchAgainst), [matchAgainst]);
return (
<ul className={listClass} aria-labelledby={labelledBy}>
<ul
className={classNames(
size === 'default' && 'space-y-8',
size === 'small' && 'space-y-4',
size === 'xs' && 'space-y-2',
listClass
)}
aria-labelledby={labelledBy}
>
{apps.map((app) => (
<li key={app.title} id={app.title} role="option" aria-selected={selected(app)}>
<AppLink
{...props}
app={app}
size={size}
selected={selected(app)}
onClick={(e) => {
addRecentApp(app);

View File

@ -1,5 +1,6 @@
import React, { MouseEvent, useCallback } from 'react';
import { Provider } from '@urbit/api';
import classNames from 'classnames';
import { MatchItem } from '../nav/Nav';
import { useRecentsStore } from '../nav/search/Home';
import { ProviderLink, ProviderLinkProps } from './ProviderLink';
@ -9,6 +10,7 @@ export type ProviderListProps = {
labelledBy: string;
matchAgainst?: MatchItem;
onClick?: (e: MouseEvent<HTMLAnchorElement>, p: Provider) => void;
listClass?: string;
} & Omit<ProviderLinkProps, 'provider' | 'onClick'>;
export function providerMatches(target: Provider, match?: MatchItem): boolean {
@ -25,6 +27,8 @@ export const ProviderList = ({
labelledBy,
matchAgainst,
onClick,
listClass,
small = false,
...props
}: ProviderListProps) => {
const addRecentDev = useRecentsStore((state) => state.addRecentDev);
@ -34,11 +38,15 @@ export const ProviderList = ({
);
return (
<ul className="space-y-8" aria-labelledby={labelledBy}>
<ul
className={classNames(small ? 'space-y-4' : 'space-y-8', listClass)}
aria-labelledby={labelledBy}
>
{providers.map((p) => (
<li key={p.shipName} id={p.shipName} role="option" aria-selected={selected(p)}>
<ProviderLink
{...props}
small={small}
provider={p}
selected={selected(p)}
onClick={(e) => {

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Treaty, daToDate } from '@urbit/api';
import { Treaty } from '@urbit/api';
import moment from 'moment';
import { Attribute } from './Attribute';
@ -9,14 +9,13 @@ const meta = ['license', 'website', 'version'] as const;
export function TreatyMeta(props: { treaty: Treaty }) {
const { treaty } = props;
const { desk, ship, cass } = treaty;
console.log(cass.da);
return (
<div className="mt-5 sm:mt-8 space-y-5 sm:space-y-8">
<Attribute title="Developer Desk" attr="desk">
{ship}/{desk}
</Attribute>
<Attribute title="Last Software Update" attr="case">
{moment(daToDate(cass.da as unknown as string)).format('YYYY.MM.DD')}
{moment(cass.da).format('YYYY.MM.DD')}
</Attribute>
{meta.map((d) => (
<Attribute key={d} attr={d}>

View File

@ -2,6 +2,6 @@ import React from 'react';
export const Bullet = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 16 16">
<circle cx="8" cy="8" r="3" />
<circle className="fill-current" cx="8" cy="8" r="3" />
</svg>
);

View File

@ -2,12 +2,13 @@ import { DialogContent } from '@radix-ui/react-dialog';
import * as Portal from '@radix-ui/react-portal';
import classNames from 'classnames';
import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react';
import { Link, Route, Switch, useHistory } from 'react-router-dom';
import { Route, Switch, useHistory } from 'react-router-dom';
import create from 'zustand';
import { Dialog } from '../components/Dialog';
import { Help } from './Help';
import { Leap } from './Leap';
import { Notifications } from './Notifications';
import { NotificationsLink } from './NotificationsLink';
import { Search } from './Search';
import { SystemMenu } from './SystemMenu';
import { SystemPreferences } from './SystemPreferences';
@ -141,12 +142,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
showOverlay={!isOpen}
className={classNames('relative z-50 flex-none', eitherOpen ? 'bg-white' : 'bg-gray-100')}
/>
<Link
to="/leap/notifications"
className="relative z-50 flex-none circle-button bg-blue-400 text-white h4"
>
3
</Link>
<NotificationsLink isOpen={isOpen} />
<Leap
ref={inputRef}
menu={menuState}
@ -170,7 +166,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
<DialogContent
onOpenAutoFocus={onOpen}
onInteractOutside={disableCloseWhenDropdownOpen}
className="fixed bottom-0 sm:top-0 scroll-left-50 flex flex-col scroll-full-width max-w-3xl px-4 text-gray-400 -translate-x-1/2 outline-none"
className="fixed bottom-0 sm:top-0 scroll-left-50 flex flex-col scroll-full-width max-w-3xl px-4 pb-4 text-gray-400 -translate-x-1/2 outline-none"
role="combobox"
aria-controls="leap-items"
aria-owns="leap-items"
@ -179,7 +175,7 @@ export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
<header ref={dialogNavRef} className="my-6 order-last sm:order-none" />
<div
id="leap-items"
className="grid grid-rows-[fit-content(calc(100vh-7.5rem))] bg-white rounded-3xl overflow-hidden default-ring"
className="grid grid-rows-[fit-content(100vh)] bg-white rounded-3xl overflow-hidden default-ring"
tabIndex={0}
role="listbox"
>

View File

@ -1,15 +1,14 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useLeapStore } from './Nav';
import { useBlockers, useLag } from '../state/kiln';
import { Button } from '../components/Button';
import { useHarkStore } from '../state/hark';
import { Notification } from '../state/hark-types';
import { BasicNotification } from './notifications/BasicNotification';
import {
BaseBlockedNotification,
RuntimeLagNotification
} from './notifications/SystemNotification';
import { useNotifications } from '../state/notifications';
function renderNotification(notification: Notification, key: string) {
if (notification.type === 'system-updates-blocked') {
@ -27,31 +26,16 @@ const Empty = () => (
</section>
);
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 Notifications = () => {
const select = useLeapStore((s) => s.select);
const notifications = useHarkStore((s) => s.notifications);
const blockers = useBlockers();
const lag = useLag();
const systemNotifications = getSystemNotifications(lag, blockers);
const hasNotifications = notifications.length > 0 || systemNotifications.length > 0;
const { notifications, systemNotifications, hasAnyNotifications } = useNotifications();
useEffect(() => {
select('Notifications');
}, []);
return (
<div className="p-4 md:p-8">
<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">
Mark All as Read
@ -66,9 +50,9 @@ export const Notifications = () => {
</Button>
</header>
{!hasNotifications && <Empty />}
{hasNotifications && (
<section className="min-h-[480px] text-gray-400 space-y-2 overflow-y-auto">
{!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())

View File

@ -0,0 +1,55 @@
import classNames from 'classnames';
import React from 'react';
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';
function getNotificationsState(
notifications: Notification[],
systemNotifications: Notification[]
): NotificationsState {
if (systemNotifications.length > 0) {
return 'attention-needed';
}
// TODO: when real structure, this should be actually be unread not just existence
if (notifications.length > 0) {
return 'unread';
}
return 'empty';
}
type NotificationsLinkProps = Omit<LinkProps<HTMLAnchorElement>, 'to'> & {
isOpen: boolean;
};
export const NotificationsLink = ({ isOpen }: NotificationsLinkProps) => {
const { notifications, systemNotifications } = useNotifications();
const state = getNotificationsState(notifications, systemNotifications);
return (
<Link
to="/leap/notifications"
className={classNames(
'relative z-50 flex-none circle-button h4',
isOpen && 'text-opacity-60',
state === 'empty' && !isOpen && 'text-gray-400 bg-gray-100',
state === 'empty' && isOpen && 'text-gray-400 bg-white',
state === 'unread' && 'bg-blue-400 text-white',
state === 'attention-needed' && 'text-white bg-orange-500'
)}
>
{state === 'empty' && <Bullet className="w-6 h-6" />}
{state === 'unread' && notifications.length}
{state === 'attention-needed' && (
<span className="h2">
! <span className="sr-only">Attention needed</span>
</span>
)}
</Link>
);
};

View File

@ -4,6 +4,7 @@ import clipboardCopy from 'clipboard-copy';
import React, { HTMLAttributes, useCallback, useState } from 'react';
import { Link } from 'react-router-dom';
import { Adjust } from '../components/icons/Adjust';
import { disableDefault } from '../state/util';
type SystemMenuProps = HTMLAttributes<HTMLButtonElement> & {
open: boolean;
@ -36,7 +37,7 @@ export const SystemMenu = ({ open, setOpen, className, showOverlay = false }: Sy
</DropdownMenu.Trigger>
<DropdownMenu.Content
onCloseAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={disableDefault}
sideOffset={12}
className="dropdown min-w-64 p-6 font-semibold text-gray-500 bg-white"
>

View File

@ -7,11 +7,10 @@ import { Dialog, DialogClose, DialogContent, DialogTrigger } from '../../compone
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 { BaseBlockedNotification as BaseBlockedNotificationType } from '../../state/hark-types';
import { NotificationButton } from './NotificationButton';
import { disableDefault } from '../../state/util';
interface BaseBlockedNotificationProps {
notification: BaseBlockedNotificationType;
@ -50,7 +49,7 @@ export const BaseBlockedNotification = ({ notification }: BaseBlockedNotificatio
const handlePauseOTAs = useCallback(() => {}, []);
const handleArchiveApps = useCallback(async () => {
await Promise.all(desks.map(d => api.poke(kilnSuspend(d))));
await Promise.all(desks.map((d) => api.poke(kilnSuspend(d))));
// TODO: retrigger OTA?
}, [desks]);
@ -69,13 +68,7 @@ export const BaseBlockedNotification = ({ notification }: BaseBlockedNotificatio
<h2 id="blocked-apps">The following ({count}) apps blocked a System Update:</h2>
</div>
</header>
<AppList
apps={blockedCharges}
labelledBy="blocked-apps"
size="xs"
className="font-medium"
listClass="space-y-2"
/>
<AppList apps={blockedCharges} labelledBy="blocked-apps" size="xs" className="font-medium" />
<div className="space-y-6">
<p>
In order to proceed with the System Update, youll need to temporarily archive these apps,
@ -120,6 +113,7 @@ export const BaseBlockedNotification = ({ notification }: BaseBlockedNotificatio
</DialogTrigger>
<DialogContent
showClose={false}
onOpenAutoFocus={disableDefault}
className="max-w-[400px] space-y-6 text-base tracking-tight"
>
<h2 className="h4">Archive ({count}) Apps and Apply System Update</h2>
@ -131,7 +125,7 @@ export const BaseBlockedNotification = ({ notification }: BaseBlockedNotificatio
apps={blockedCharges}
labelledBy="blocked-apps"
size="xs"
listClass="space-y-2"
className="text-sm"
/>
<div className="flex space-x-6">
<DialogClose as={Button} variant="secondary">

View File

@ -29,7 +29,7 @@ export const useRecentsStore = create<RecentsStore>(
addRecentApp: (docket) => {
set(
produce((draft: RecentsStore) => {
const hasApp = draft.recentApps.find((app) => app.href === docket.href);
const hasApp = draft.recentApps.find((app) => app.title === docket.title);
if (!hasApp) {
draft.recentApps.unshift(docket);
}
@ -85,22 +85,27 @@ export const Home = () => {
}, [recentApps, recentDevs]);
return (
<div className="h-full p-4 md:p-8 space-y-8 font-semibold leading-tight text-black overflow-y-auto">
<h2 id="recent-apps" className="h4 text-gray-500">
<div className="h-full p-4 md:p-8 font-semibold leading-tight text-black overflow-y-auto">
<h2 id="recent-apps" className="mb-6 h4 text-gray-500">
Recent Apps
</h2>
{recentApps.length === 0 && (
<div className="min-h-[150px] p-6 rounded-xl bg-gray-100">
<p className="mb-4">Apps you use will be listed here, in the order you used them.</p>
<p className="mb-6">You can click/tap/keyboard on a listed app to open it.</p>
{groups && <AppLink app={groups} small onClick={() => addRecentApp(groups)} />}
{groups && <AppLink app={groups} size="small" onClick={() => addRecentApp(groups)} />}
</div>
)}
{recentApps.length > 0 && (
<AppList apps={recentApps} labelledBy="recent-apps" matchAgainst={selectedMatch} small />
<AppList
apps={recentApps}
labelledBy="recent-apps"
matchAgainst={selectedMatch}
size="small"
/>
)}
<hr className="-mx-4 md:-mx-8" />
<h2 id="recent-devs" className="h4 text-gray-500">
<hr className="-mx-4 my-6 md:-mx-8 md:my-9" />
<h2 id="recent-devs" className="mb-6 h4 text-gray-500">
Recent Developers
</h2>
{recentDevs.length === 0 && (

View File

@ -1,6 +1,6 @@
import create from 'zustand';
import { Notification } from './hark-types';
import { mockBlockedChargeNotification } from './mock-data';
import { mockNotification } from './mock-data';
import { useMockData } from './util';
interface HarkStore {
@ -8,5 +8,5 @@ interface HarkStore {
}
export const useHarkStore = create<HarkStore>(() => ({
notifications: useMockData ? [mockBlockedChargeNotification] : []
notifications: useMockData ? [mockNotification] : []
}));

View File

@ -2,10 +2,13 @@ import _ from 'lodash-es';
import { Allies, Charges, DocketHrefGlob, Treaties, Treaty } from '@urbit/api/docket';
import { Vat, Vats } from '@urbit/api/hood';
import systemUrl from '../assets/system.png';
// import { SystemNotification } from './hark-types';
import { BasicNotification } from './hark-types';
export const appMetaData: Pick<Treaty, 'cass' | 'hash' | 'website' | 'license' | 'version'> = {
cass: '~2021.8.11..05.11.10..b721',
cass: {
da: 1629849472746,
ud: 1
},
hash: '0v6.nj6ls.l7unh.l9bhk.d839n.n8nlq.m2dmc.fj80i.pvqun.uhg6g.1kk0h',
website: 'https://tlon.io',
license: 'MIT',
@ -28,7 +31,7 @@ export const mockTreaties: Treaties = {
title: 'Messages',
ship: '~zod',
desk: 'messages',
href: makeHref('messaages'),
href: makeHref('messages'),
info: 'A lengthier description of the app down here',
color: '#8BE789',
...appMetaData
@ -152,9 +155,10 @@ export const mockAllies: Allies = [
'~nalrys'
].reduce((acc, val) => ({ ...acc, [val]: charter }), {});
export const mockBlockedChargeNotification: any = {
type: 'system-updates-blocked',
charges: ['groups', 'pomodoro']
export const mockNotification: BasicNotification = {
type: 'basic',
time: '',
message: 'test'
};
export const mockVat = (desk: string, blockers?: boolean): Vat => ({
@ -176,7 +180,7 @@ export const mockVat = (desk: string, blockers?: boolean): Vat => ({
hash: '0vh.lhfn6.julg1.fs52d.g2lqj.q5kp0.2o7j3.2bljl.jdm34.hd46v.9uv5v'
});
const badVats = ['inbox', 'system', 'terminal', 'base'];
const badVats = []; // ['inbox', 'system', 'terminal', 'base'];
export const mockVats = _.reduce(
mockCharges,
(vats, charge, desk) => {

View File

@ -0,0 +1,28 @@
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;
return {
notifications,
systemNotifications,
hasAnyNotifications
};
};

View File

@ -19,3 +19,7 @@ export async function fakeRequest<T>(data: T, time = 300): Promise<T> {
export function getAppHref(href: DocketHref) {
return 'site' in href ? href.site : `/apps/${href.glob.base}`;
}
export function disableDefault<T extends Event>(e: T): void {
e.preventDefault();
}

View File

@ -4,6 +4,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import useDocketState from '../state/docket';
import { disableDefault } from '../state/util';
export interface TileMenuProps {
desk: string;
@ -66,7 +67,7 @@ export const TileMenu = ({ desk, active, menuColor, lightText, className }: Tile
align="start"
alignOffset={-32}
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={disableDefault}
className={classNames(
'dropdown py-2 font-semibold',
lightText ? 'text-gray-100' : 'text-gray-800'

File diff suppressed because it is too large Load Diff