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 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 useDocketState from './state/docket';
import { PermalinkRoutes } from './pages/PermalinkRoutes';
@ -9,9 +9,26 @@ import { usePreferencesStore } from './nav/preferences/usePreferencesStore';
import useContactState from './state/contact';
import api from './state/api';
const getNoteRedirect = (path: string) => {
if (path.startsWith('/desk/')) {
const [, , desk] = path.split('/');
return `/app/${desk}`;
}
return '';
};
const AppRoutes = () => {
const { push } = useHistory();
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(
(e: MediaQueryListEvent) => {
@ -36,6 +53,8 @@ const AppRoutes = () => {
};
}, []);
useEffect(() => {}, []);
useEffect(() => {
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 { Link } from 'react-router-dom';
import { Link, NavLink, Route, Switch } from 'react-router-dom';
import { Notification } from '@urbit/api';
import { useLeapStore } from './Nav';
import { Button } from '../components/Button';
@ -11,6 +11,7 @@ import {
import { useNotifications } from '../state/notifications';
import { useHarkStore } from '../state/hark';
import { OnboardingNotification } from './notifications/OnboardingNotification';
import { Inbox } from './notifications/Inbox';
function renderNotification(notification: Notification, key: string, unread = false) {
// Special casing
@ -36,7 +37,7 @@ const Empty = () => (
export const Notifications = () => {
const select = useLeapStore((s) => s.select);
const { unreads, reads, hasAnyNotifications } = useNotifications();
const { unseen, seen, hasAnyNotifications } = useNotifications();
const markAllAsRead = () => {
const { readAll } = useHarkStore.getState();
readAll();
@ -46,12 +47,37 @@ export const Notifications = () => {
select('Notifications');
const { getMore } = useHarkStore.getState();
getMore();
function visibilitychange() {
useHarkStore.getState().opened();
}
document.addEventListener('visibilitychange', visibilitychange);
return () => {
document.removeEventListener('visibilitychange', visibilitychange);
useHarkStore.getState().opened();
};
}, []);
// const select = useLeapStore((s) => s.select);
return (
<div className="grid grid-rows-[auto,1fr] h-full p-4 md:p-8 overflow-hidden">
<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">
Mark All as Read
</Button>
@ -64,17 +90,14 @@ export const Notifications = () => {
Notification Settings
</Button>
</header>
{!hasAnyNotifications && <Empty />}
{hasAnyNotifications && (
<section className="text-gray-400 space-y-2 overflow-y-auto">
{unreads.map((n, index) => renderNotification(n, index.toString(), true))}
{Array.from(reads)
.map(([, nots]) => nots)
.flat()
.map((n, index) => renderNotification(n, `reads-${index}`))}
</section>
)}
<Switch>
<Route path="/leap/notifications" exact>
<Inbox />
</Route>
<Route path="/leap/notifications/archive" exact>
<Inbox archived />
</Route>
</Switch>
</div>
);
};

View File

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

View File

@ -1,25 +1,28 @@
import React from 'react';
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 { useCharge } from '../../state/docket';
import { Elbow } from '../../components/icons/Elbow';
import { ShipName } from '../../components/ShipName';
import { getAppHref } from '../../state/util';
import { Link } from 'react-router-dom';
import { DeskLink } from '../../components/DeskLink';
import {useHarkStore} from '../../state/hark';
interface BasicNotificationProps {
notification: Notification;
unread?: boolean;
lid: HarkLid;
}
const MAX_CONTENTS = 20;
const MAX_CONTENTS = 5;
const NotificationText = ({ contents }: { contents: HarkContent[] }) => {
return (
<>
{contents.map((content, idx) => {
if ('ship' in content) {
return <ShipName key={idx} name={content.ship} />;
return <ShipName className="color-blue" key={idx} name={content.ship} />;
}
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 binId = harkBinToId(notification.bin);
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 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 (
<a
href={link}
target={desk}
<DeskLink
onClick={archive}
to={`?grid-note=${encodeURIComponent(first.link)}`}
desk={desk}
className={cn(
'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'
)}
aria-labelledby={id}
@ -75,6 +82,6 @@ export const BasicNotification = ({ notification, unread = false }: BasicNotific
) : null}
</div>
) : 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 } : {}}
>
<div className="space-y-1">
<h4 className="font-semibold">{title}</h4>
<h4 className="font-semibold text-black">{title}</h4>
<p>{body}</p>
</div>
<Button variant="primary" className="bg-gray-500">

View File

@ -6,7 +6,11 @@ import { usePreferencesStore } from './usePreferencesStore';
const selDnd = (s: SettingsState) => s.display.doNotDisturb;
async function toggleDnd() {
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 = () => {

View File

@ -1,6 +1,20 @@
/* eslint-disable no-param-reassign */
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';
/* eslint-disable-next-line camelcase */
import { unstable_batchedUpdates } from 'react-dom';
@ -9,37 +23,42 @@ import { map } from 'lodash';
import api from './api';
import { useMockData } from './util';
import { mockNotifications } from './mock-data';
import useDocketState from './docket';
import { useSettingsState } from './settings';
interface HarkStore {
unreads: Notification[];
reads: BigIntOrderedMap<Notification[]>;
seen: Timebox;
unseen: Timebox;
archive: BigIntOrderedMap<Timebox>;
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>;
webNotes: {
[binId: string]: Notification[];
};
}
export const useHarkStore = create<HarkStore>((set, get) => ({
unreads: useMockData ? mockNotifications : [],
reads: new BigIntOrderedMap<Notification[]>(),
seen: {},
unseen: {},
archive: new BigIntOrderedMap<Timebox>(),
webNotes: {},
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);
archiveAll: async () => {},
archiveNote: async (bin, lid) => {
await api.poke(archive(bin, lid));
},
opened: async () => {
await api.poke(opened);
},
getMore: async () => {
const { reads } = get();
const idx = decToUd((reads.peekSmallest()?.[0] || unixToDa(Date.now() * 1000)).toString());
const { archive } = get();
const idx = decToUd((archive.peekSmallest()?.[0] || unixToDa(Date.now() * 1000)).toString());
const update = await api.scry({
app: 'hark-store',
path: `/recent/inbox/${idx}/5`
@ -60,35 +79,59 @@ function reduceHark(u: any) {
} 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.unshift(u.added);
set((draft) => {
const { bin } = u.added;
const binId = harkBinToId(bin);
draft.unseen[binId] = u.added;
});
} else if ('timebox' in u) {
const { timebox } = u;
const notifications = map(timebox.notifications, 'notification');
if (timebox.time) {
console.log(timebox);
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);
set((state) => {
state.reads = state.reads.set(makePatDa(timebox.time), notifications);
draft.archive = draft.archive.set(time, timebox);
});
} else {
set((state) => {
state.unreads = [...state.unreads, ...notifications];
set((draft) => {
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) {
console.log(u.archive);
set((state) => {
const time = u.archive?.time;
if (time) {
let box = state.reads.get(makePatDa(time)) || [];
box = box.filter((n) => !harkBinEq(u.archive.bin, n.bin));
state.reads = state.reads.set(makePatDa(time), box);
} else {
state.unreads = state.unreads.filter((n) => !harkBinEq(u.archive.bin, n.bin));
}
} else if ('archived' in u) {
const { lid, notification } = u.archived;
console.log(u.archived);
set((draft) => {
const seen = 'seen' in lid ? 'seen' : 'unseen';
const binId = harkBinToId(notification.bin);
delete draft[seen][binId];
const time = makePatDa(u.archived.time);
const timebox = draft.archive.get(time) || {};
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';
export const useNotifications = () => {
const [unreads, reads] = useHarkStore((s) => [s.unreads, s.reads], shallow);
const hasAnyNotifications = unreads.length > 0 || reads.size > 0;
const [unseen, seen] = useHarkStore((s) => [s.unseen, s.seen], shallow);
const hasAnyNotifications = Object.keys(seen).length > 0 || Object.keys(unseen).length > 0;
return {
unreads,
reads,
unseen,
seen,
hasAnyNotifications
};
};

View File

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