grid: update for new hark timeboxing

This commit is contained in:
Liam Fitzgerald 2021-09-17 10:45:06 +10:00
parent 1d94d08d60
commit 993b529b9d
11 changed files with 333 additions and 77 deletions

View File

@ -1,6 +1,6 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import Mousetrap from 'mousetrap'; import Mousetrap from 'mousetrap';
import { BrowserRouter, Switch, Route, useHistory } from 'react-router-dom'; import { BrowserRouter, Switch, Route, useHistory, useLocation } from 'react-router-dom';
import { Grid } from './pages/Grid'; import { Grid } from './pages/Grid';
import useDocketState from './state/docket'; import useDocketState from './state/docket';
import { PermalinkRoutes } from './pages/PermalinkRoutes'; import { PermalinkRoutes } from './pages/PermalinkRoutes';
@ -9,9 +9,26 @@ import { usePreferencesStore } from './nav/preferences/usePreferencesStore';
import useContactState from './state/contact'; import useContactState from './state/contact';
import api from './state/api'; import api from './state/api';
const getNoteRedirect = (path: string) => {
if (path.startsWith('/desk/')) {
const [, , desk] = path.split('/');
return `/app/${desk}`;
}
return '';
};
const AppRoutes = () => { const AppRoutes = () => {
const { push } = useHistory(); const { push } = useHistory();
const theme = usePreferencesStore((s) => s.theme); const theme = usePreferencesStore((s) => s.theme);
const { search } = useLocation();
useEffect(() => {
const query = new URLSearchParams(location.search);
if (query.has('grid-note')) {
const redir = getNoteRedirect(query.get('grid-note')!);
push(redir);
}
}, [location.search]);
const updateThemeClass = useCallback( const updateThemeClass = useCallback(
(e: MediaQueryListEvent) => { (e: MediaQueryListEvent) => {
@ -36,6 +53,8 @@ const AppRoutes = () => {
}; };
}, []); }, []);
useEffect(() => {}, []);
useEffect(() => { useEffect(() => {
window.name = 'grid'; window.name = 'grid';

View File

@ -0,0 +1,32 @@
import React, { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { useCharge } from '../state/docket';
import { getAppHref } from '../state/util';
interface DeskLinkProps extends React.AnchorHTMLAttributes<any> {
desk: string;
to?: string;
children?: ReactNode;
className?: string;
}
export function DeskLink({ children, className, desk, to = '', ...rest }: DeskLinkProps) {
const charge = useCharge(desk);
if (!charge) {
return null;
}
if (desk === window.desk) {
return (
<Link to={to} className={className} {...rest}>
{children}
</Link>
);
}
const href = `${getAppHref(charge.href)}${to}`;
return (
<a href={href} target={desk} className={className} {...rest}>
{children}
</a>
);
}

View File

@ -1,5 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link, NavLink, Route, Switch } from 'react-router-dom';
import { Notification } from '@urbit/api'; import { Notification } from '@urbit/api';
import { useLeapStore } from './Nav'; import { useLeapStore } from './Nav';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
@ -11,6 +11,7 @@ import {
import { useNotifications } from '../state/notifications'; import { useNotifications } from '../state/notifications';
import { useHarkStore } from '../state/hark'; import { useHarkStore } from '../state/hark';
import { OnboardingNotification } from './notifications/OnboardingNotification'; import { OnboardingNotification } from './notifications/OnboardingNotification';
import { Inbox } from './notifications/Inbox';
function renderNotification(notification: Notification, key: string, unread = false) { function renderNotification(notification: Notification, key: string, unread = false) {
// Special casing // Special casing
@ -36,7 +37,7 @@ const Empty = () => (
export const Notifications = () => { export const Notifications = () => {
const select = useLeapStore((s) => s.select); const select = useLeapStore((s) => s.select);
const { unreads, reads, hasAnyNotifications } = useNotifications(); const { unseen, seen, hasAnyNotifications } = useNotifications();
const markAllAsRead = () => { const markAllAsRead = () => {
const { readAll } = useHarkStore.getState(); const { readAll } = useHarkStore.getState();
readAll(); readAll();
@ -46,12 +47,37 @@ export const Notifications = () => {
select('Notifications'); select('Notifications');
const { getMore } = useHarkStore.getState(); const { getMore } = useHarkStore.getState();
getMore(); getMore();
function visibilitychange() {
useHarkStore.getState().opened();
}
document.addEventListener('visibilitychange', visibilitychange);
return () => {
document.removeEventListener('visibilitychange', visibilitychange);
useHarkStore.getState().opened();
};
}, []); }, []);
// const select = useLeapStore((s) => s.select); // const select = useLeapStore((s) => s.select);
return ( return (
<div className="grid grid-rows-[auto,1fr] h-full p-4 md:p-8 overflow-hidden"> <div className="grid grid-rows-[auto,1fr] h-full p-4 md:p-8 overflow-hidden">
<header className="space-x-2 mb-8"> <header className="space-x-2 mb-8">
<NavLink
exact
activeClassName="text-black"
className="text-base font-semibold px-4"
to="/leap/notifications"
>
New
</NavLink>
<NavLink
activeClassName="text-black"
className="text-base font-semibold px-4"
to="/leap/notifications/archive"
>
Archive
</NavLink>
<Button onClick={markAllAsRead} 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 Mark All as Read
</Button> </Button>
@ -64,17 +90,14 @@ export const Notifications = () => {
Notification Settings Notification Settings
</Button> </Button>
</header> </header>
<Switch>
{!hasAnyNotifications && <Empty />} <Route path="/leap/notifications" exact>
{hasAnyNotifications && ( <Inbox />
<section className="text-gray-400 space-y-2 overflow-y-auto"> </Route>
{unreads.map((n, index) => renderNotification(n, index.toString(), true))} <Route path="/leap/notifications/archive" exact>
{Array.from(reads) <Inbox archived />
.map(([, nots]) => nots) </Route>
.flat() </Switch>
.map((n, index) => renderNotification(n, `reads-${index}`))}
</section>
)}
</div> </div>
); );
}; };

View File

@ -1,6 +1,6 @@
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React from 'react';
import { Notification } from '@urbit/api'; import { Notification, Timebox } from '@urbit/api';
import { Link, LinkProps } from 'react-router-dom'; import { Link, LinkProps } from 'react-router-dom';
import { Bullet } from '../components/icons/Bullet'; import { Bullet } from '../components/icons/Bullet';
import { useNotifications } from '../state/notifications'; import { useNotifications } from '../state/notifications';
@ -8,7 +8,8 @@ import { MenuState } from './Nav';
type NotificationsState = 'empty' | 'unread' | 'attention-needed'; type NotificationsState = 'empty' | 'unread' | 'attention-needed';
function getNotificationsState(notifications: Notification[]): NotificationsState { function getNotificationsState(box: Timebox): NotificationsState {
const notifications = Object.values(box);
if (notifications.filter(({ bin }) => bin.place.desk === window.desk).length > 0) { if (notifications.filter(({ bin }) => bin.place.desk === window.desk).length > 0) {
return 'attention-needed'; return 'attention-needed';
} }
@ -27,8 +28,8 @@ type NotificationsLinkProps = Omit<LinkProps<HTMLAnchorElement>, 'to'> & {
}; };
export const NotificationsLink = ({ navOpen, menu }: NotificationsLinkProps) => { export const NotificationsLink = ({ navOpen, menu }: NotificationsLinkProps) => {
const { unreads } = useNotifications(); const { unseen } = useNotifications();
const state = getNotificationsState(unreads); const state = getNotificationsState(unseen);
return ( return (
<Link <Link
@ -44,7 +45,7 @@ export const NotificationsLink = ({ navOpen, menu }: NotificationsLinkProps) =>
)} )}
> >
{state === 'empty' && <Bullet className="w-6 h-6" />} {state === 'empty' && <Bullet className="w-6 h-6" />}
{state === 'unread' && unreads.length} {state === 'unread' && Object.keys(unseen).length}
{state === 'attention-needed' && ( {state === 'attention-needed' && (
<span className="h2"> <span className="h2">
! <span className="sr-only">Attention needed</span> ! <span className="sr-only">Attention needed</span>

View File

@ -1,25 +1,28 @@
import React from 'react'; import React from 'react';
import cn from 'classnames'; import cn from 'classnames';
import { Notification, harkBinToId, HarkContent } from '@urbit/api'; import { Notification, harkBinToId, HarkContent, HarkLid } from '@urbit/api';
import { map, take } from 'lodash'; import { map, take } from 'lodash';
import { useCharge } from '../../state/docket'; import { useCharge } from '../../state/docket';
import { Elbow } from '../../components/icons/Elbow'; import { Elbow } from '../../components/icons/Elbow';
import { ShipName } from '../../components/ShipName'; import { ShipName } from '../../components/ShipName';
import { getAppHref } from '../../state/util'; import { getAppHref } from '../../state/util';
import { Link } from 'react-router-dom';
import { DeskLink } from '../../components/DeskLink';
import {useHarkStore} from '../../state/hark';
interface BasicNotificationProps { interface BasicNotificationProps {
notification: Notification; notification: Notification;
unread?: boolean; lid: HarkLid;
} }
const MAX_CONTENTS = 20; const MAX_CONTENTS = 5;
const NotificationText = ({ contents }: { contents: HarkContent[] }) => { const NotificationText = ({ contents }: { contents: HarkContent[] }) => {
return ( return (
<> <>
{contents.map((content, idx) => { {contents.map((content, idx) => {
if ('ship' in content) { if ('ship' in content) {
return <ShipName key={idx} name={content.ship} />; return <ShipName className="color-blue" key={idx} name={content.ship} />;
} }
return content.text; return content.text;
})} })}
@ -27,7 +30,7 @@ const NotificationText = ({ contents }: { contents: HarkContent[] }) => {
); );
}; };
export const BasicNotification = ({ notification, unread = false }: BasicNotificationProps) => { export const BasicNotification = ({ notification, lid }: BasicNotificationProps) => {
const { desk } = notification.bin.place; const { desk } = notification.bin.place;
const binId = harkBinToId(notification.bin); const binId = harkBinToId(notification.bin);
const id = `notif-${notification.time}-${binId}`; const id = `notif-${notification.time}-${binId}`;
@ -39,15 +42,19 @@ export const BasicNotification = ({ notification, unread = false }: BasicNotific
} }
const contents = map(notification.body, 'content').filter((c) => c.length > 0); const contents = map(notification.body, 'content').filter((c) => c.length > 0);
const large = contents.length === 0; const large = contents.length === 0;
const link = `${getAppHref(charge.href)}?grid-note=${encodeURIComponent(first.link)}`; const archive = () => {
const { bin } = notification;
useHarkStore.getState().archiveNote(notification.bin, lid);
};
return ( return (
<a <DeskLink
href={link} onClick={archive}
target={desk} to={`?grid-note=${encodeURIComponent(first.link)}`}
desk={desk}
className={cn( className={cn(
'text-black rounded', 'text-black rounded',
unread ? 'bg-blue-100' : 'bg-gray-100', 'unseen' in lid ? 'bg-blue-100' : 'bg-gray-100',
large ? 'note-grid-no-content' : 'note-grid-content' large ? 'note-grid-no-content' : 'note-grid-content'
)} )}
aria-labelledby={id} aria-labelledby={id}
@ -75,6 +82,6 @@ export const BasicNotification = ({ notification, unread = false }: BasicNotific
) : null} ) : null}
</div> </div>
) : null} ) : null}
</a> </DeskLink>
); );
}; };

View File

@ -0,0 +1,92 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { HarkLid, Notification } from '@urbit/api';
import { useLeapStore } from '../Nav';
import { Button } from '../../components/Button';
import { BasicNotification } from './BasicNotification';
import { BaseBlockedNotification, RuntimeLagNotification } from './SystemNotification';
import { useNotifications } from '../../state/notifications';
import { useHarkStore } from '../../state/hark';
import { OnboardingNotification } from './OnboardingNotification';
function renderNotification(notification: Notification, key: string, lid: HarkLid) {
// Special casing
if (notification.bin.place.desk === window.desk) {
if (notification.bin.place.path === '/lag') {
return <RuntimeLagNotification key={key} />;
}
if (notification.bin.place.path === '/blocked') {
return <BaseBlockedNotification key={key} />;
}
if (notification.bin.place.path === '/onboard') {
return <OnboardingNotification key={key} unread={false} />;
}
}
return <BasicNotification key={key} notification={notification} lid={lid} />;
}
const Empty = () => (
<section className="flex justify-center items-center min-h-[480px] text-gray-400 space-y-2">
<span className="h4">All clear!</span>
</section>
);
export const Inbox = ({ archived = false }) => {
const select = useLeapStore((s) => s.select);
const { unseen, seen, hasAnyNotifications } = useNotifications();
const archive = useHarkStore((s) => s.archive);
const markAllAsRead = () => {};
useEffect(() => {
useHarkStore.getState().getMore();
}, [archived]);
useEffect(() => {
select('Notifications');
const { getMore } = useHarkStore.getState();
getMore();
function visibilitychange() {
setTimeout(() => useHarkStore.getState().opened(), 100);
}
document.addEventListener('visibilitychange', visibilitychange);
return () => {
document.removeEventListener('visibilitychange', visibilitychange);
visibilitychange();
};
}, []);
// const select = useLeapStore((s) => s.select);
if (false) {
return <Empty />;
}
return (
<div className="text-gray-400 space-y-2 overflow-y-auto">
{archived ? (
Array.from(archive).map(([key, box]) => {
return Object.entries(box)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n], index) => renderNotification(n, `${key.toString}-${binId}`, { time: key.toString() }));
})
) : (
<>
<header>Unseen</header>
<section className="space-y-2">
{Object.entries(unseen)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n], index) => renderNotification(n, `unseen-${binId}`, { unseen: null }))}
</section>
<header>Seen</header>
<section className="space-y-2">
{Object.entries(seen)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n], index) => renderNotification(n, `seen-${binId}`, { seen: null }))}
</section>
</>
)}
</div>
);
};

View File

@ -47,7 +47,7 @@ const OnboardingCard = ({ title, button, body, color }: OnboardingCardProps) =>
style={color ? { backgroundColor: color } : {}} style={color ? { backgroundColor: color } : {}}
> >
<div className="space-y-1"> <div className="space-y-1">
<h4 className="font-semibold">{title}</h4> <h4 className="font-semibold text-black">{title}</h4>
<p>{body}</p> <p>{body}</p>
</div> </div>
<Button variant="primary" className="bg-gray-500"> <Button variant="primary" className="bg-gray-500">

View File

@ -6,7 +6,11 @@ import { usePreferencesStore } from './usePreferencesStore';
const selDnd = (s: SettingsState) => s.display.doNotDisturb; const selDnd = (s: SettingsState) => s.display.doNotDisturb;
async function toggleDnd() { async function toggleDnd() {
const state = useSettingsState.getState(); const state = useSettingsState.getState();
await state.putEntry('display', 'doNotDisturb', !selDnd(state)); const curr = selDnd(state);
if(curr) {
Notification.requestPermission();
}
await state.putEntry('display', 'doNotDisturb', !curr);
} }
export const NotificationPrefs = () => { export const NotificationPrefs = () => {

View File

@ -1,6 +1,20 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import create from 'zustand'; import create from 'zustand';
import { Notification, harkBinEq, makePatDa, readAll, decToUd, unixToDa } from '@urbit/api'; import {
Notification as HarkNotification,
harkBinEq,
makePatDa,
readAll,
decToUd,
unixToDa,
Timebox,
harkBinToId,
opened,
HarkBin,
HarkLid,
archive,
HarkContent
} from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap'; import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
/* eslint-disable-next-line camelcase */ /* eslint-disable-next-line camelcase */
import { unstable_batchedUpdates } from 'react-dom'; import { unstable_batchedUpdates } from 'react-dom';
@ -9,37 +23,42 @@ import { map } from 'lodash';
import api from './api'; import api from './api';
import { useMockData } from './util'; import { useMockData } from './util';
import { mockNotifications } from './mock-data'; import { mockNotifications } from './mock-data';
import useDocketState from './docket';
import { useSettingsState } from './settings';
interface HarkStore { interface HarkStore {
unreads: Notification[]; seen: Timebox;
reads: BigIntOrderedMap<Notification[]>; unseen: Timebox;
archive: BigIntOrderedMap<Timebox>;
set: (f: (s: HarkStore) => void) => void; set: (f: (s: HarkStore) => void) => void;
readAll: () => Promise<void>; opened: () => Promise<void>;
archiveAll: () => Promise<void>;
archiveNote: (bin: HarkBin, lid: HarkLid) => Promise<void>;
getMore: () => Promise<void>; getMore: () => Promise<void>;
webNotes: {
[binId: string]: Notification[];
};
} }
export const useHarkStore = create<HarkStore>((set, get) => ({ export const useHarkStore = create<HarkStore>((set, get) => ({
unreads: useMockData ? mockNotifications : [], seen: {},
reads: new BigIntOrderedMap<Notification[]>(), unseen: {},
archive: new BigIntOrderedMap<Timebox>(),
webNotes: {},
set: (f) => { set: (f) => {
const newState = produce(get(), f); const newState = produce(get(), f);
set(newState); set(newState);
}, },
readAll: async () => { archiveAll: async () => {},
const { set: innerSet } = get(); archiveNote: async (bin, lid) => {
innerSet((state) => { await api.poke(archive(bin, lid));
state.unreads.forEach((note) => { },
const time = makePatDa(note.time as any); opened: async () => {
const box = state.reads.get(time) || []; await api.poke(opened);
state.reads.set(time, [...box, note]);
});
state.unreads = [];
});
await api.poke(readAll);
}, },
getMore: async () => { getMore: async () => {
const { reads } = get(); const { archive } = get();
const idx = decToUd((reads.peekSmallest()?.[0] || unixToDa(Date.now() * 1000)).toString()); const idx = decToUd((archive.peekSmallest()?.[0] || unixToDa(Date.now() * 1000)).toString());
const update = await api.scry({ const update = await api.scry({
app: 'hark-store', app: 'hark-store',
path: `/recent/inbox/${idx}/5` path: `/recent/inbox/${idx}/5`
@ -60,35 +79,59 @@ function reduceHark(u: any) {
} else if ('all-stats' in u) { } else if ('all-stats' in u) {
// TODO: probably ignore? // TODO: probably ignore?
} else if ('added' in u) { } else if ('added' in u) {
set((state) => { set((draft) => {
state.unreads = state.unreads.filter((unread) => !harkBinEq(unread.bin, u.added.bin)); const { bin } = u.added;
const binId = harkBinToId(bin);
state.unreads.unshift(u.added); draft.unseen[binId] = u.added;
}); });
} else if ('timebox' in u) { } else if ('timebox' in u) {
const { timebox } = u; const { timebox } = u;
const notifications = map(timebox.notifications, 'notification'); console.log(timebox);
if (timebox.time) { const { lid, notifications } = timebox;
if ('archive' in lid) {
set((draft) => {
const time = makePatDa(lid.archive);
const timebox = draft.archive.get(time) || {};
console.log(timebox);
notifications.forEach((note: any) => {
console.log(note);
const binId = harkBinToId(note.bin);
timebox[binId] = note;
});
console.log(notifications); console.log(notifications);
set((state) => { draft.archive = draft.archive.set(time, timebox);
state.reads = state.reads.set(makePatDa(timebox.time), notifications);
}); });
} else { } else {
set((state) => { set((draft) => {
state.unreads = [...state.unreads, ...notifications]; const seen = 'seen' in lid ? 'seen' : 'unseen';
notifications.forEach((note: any) => {
const binId = harkBinToId(note.bin);
draft[seen][binId] = note;
});
}); });
} }
} else if ('archive' in u) { } else if ('archived' in u) {
console.log(u.archive); const { lid, notification } = u.archived;
set((state) => { console.log(u.archived);
const time = u.archive?.time; set((draft) => {
if (time) { const seen = 'seen' in lid ? 'seen' : 'unseen';
let box = state.reads.get(makePatDa(time)) || []; const binId = harkBinToId(notification.bin);
box = box.filter((n) => !harkBinEq(u.archive.bin, n.bin)); delete draft[seen][binId];
state.reads = state.reads.set(makePatDa(time), box); const time = makePatDa(u.archived.time);
} else { const timebox = draft.archive.get(time) || {};
state.unreads = state.unreads.filter((n) => !harkBinEq(u.archive.bin, n.bin)); timebox[binId] = notification;
} draft.archive = draft.archive.set(time, timebox);
});
} else if ('opened' in u) {
set((draft) => {
const bins = Object.keys(draft.unseen);
bins.forEach((bin) => {
const old = draft.seen[bin];
const curr = draft.unseen[bin];
curr.body = [...curr.body, ...(old?.body || [])];
draft.seen[bin] = curr;
delete draft.unseen[bin];
});
}); });
} }
} }
@ -103,3 +146,37 @@ api.subscribe({
}); });
} }
}); });
function harkContentsToPlainText(contents: HarkContent[]) {
return contents
.map((c) => {
if ('ship' in c) {
return c.ship;
}
return c.text;
})
.join('');
}
api.subscribe({
app: 'hark-store',
path: '/notes',
event: (u: any) => {
if ('add-note' in u) {
if (useSettingsState.getState().display.doNotDisturb) {
//return;
}
const { bin, body } = u['add-note'];
const binId = harkBinToId(bin);
const { title, content } = body;
const image = useDocketState.getState().charges[bin.desk]?.image;
const notification = new Notification(harkContentsToPlainText(title), {
body: harkContentsToPlainText(content),
tag: binId,
renotify: true
});
}
}
});
window.hark = useHarkStore.getState;

View File

@ -2,12 +2,12 @@ import shallow from 'zustand/shallow';
import { useHarkStore } from './hark'; import { useHarkStore } from './hark';
export const useNotifications = () => { export const useNotifications = () => {
const [unreads, reads] = useHarkStore((s) => [s.unreads, s.reads], shallow); const [unseen, seen] = useHarkStore((s) => [s.unseen, s.seen], shallow);
const hasAnyNotifications = unreads.length > 0 || reads.size > 0; const hasAnyNotifications = Object.keys(seen).length > 0 || Object.keys(unseen).length > 0;
return { return {
unreads, unseen,
reads, seen,
hasAnyNotifications hasAnyNotifications
}; };
}; };

View File

@ -20,6 +20,7 @@ export default ({ mode }) => {
mode === 'mock' mode === 'mock'
? undefined ? undefined
: { : {
https: true,
proxy: { proxy: {
'^/apps/grid/desk.js': { '^/apps/grid/desk.js': {
target: SHIP_URL target: SHIP_URL