Merge pull request #5302 from urbit/lf/landscape-notifications

landscape: revive notifications
This commit is contained in:
Hunter Miller 2021-10-06 17:42:01 -05:00 committed by GitHub
commit f2e00ba091
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 522 additions and 386 deletions

View File

@ -0,0 +1,90 @@
import useMetadataState from '../state/metadata';
import ob from 'urbit-ob';
function getGroupResourceRedirect(key: string) {
const association = useMetadataState.getState().associations.graph[`/ship/${key}`];
const { metadata } = association;
if(!association || !('graph' in metadata.config)) {
return '';
}
return `/~landscape${association.group}/resource/${metadata.config.graph}${association.resource}`;
}
function getPostRedirect(key: string, segs: string[]) {
const association = useMetadataState.getState().associations.graph[`/ship/${key}`];
const { metadata } = association;
if(!association || !('graph' in metadata.config)) {
return '';
}
return `/~landscape${association.group}/feed/thread/${segs.slice(0, -1).join('/')}`;
}
function getChatRedirect(chat: string, segs: string[]) {
const qs = segs.length > 0 ? `?msg=${segs[0]}` : '';
return `${getGroupResourceRedirect(chat)}${qs}`;
}
function getPublishRedirect(graphKey: string, segs: string[]) {
const base = getGroupResourceRedirect(graphKey);
if(segs.length === 3) {
return `${base}/note/${segs[0]}`;
} else if (segs.length === 4) {
return `${base}/note/${segs[0]}?selected=${segs[2]}`;
}
return base;
}
function getLinkRedirect(graphKey: string, segs: string[]) {
const base = getGroupResourceRedirect(graphKey);
if(segs.length === 1) {
return `${base}/index/${segs[0]}`;
} else if (segs.length === 3) {
return `${base}/index/${segs[0]}?selected=${segs[1]}`;
}
return base;
}
function getGraphRedirect(link: string) {
const [,mark, ship, name, ...rest] = link.split('/');
const graphKey = `${ship}/${name}`;
switch(mark) {
case 'graph-validator-dm':
return `/~landscape/messages/dm/${ob.patp(rest[0])}`;
case 'graph-validator-chat':
return getChatRedirect(graphKey, rest);
case 'graph-validator-publish':
return getPublishRedirect(graphKey, rest);
case 'graph-validator-link':
return getLinkRedirect(graphKey, rest);
case 'graph-validator-post':
return getPostRedirect(graphKey, rest);
default:
return'';
}
}
function getInviteRedirect(link: string) {
const [,,app,uid] = link.split('/');
return `/invites/${app}/${uid}`;
}
function getDmRedirect(link: string) {
const [,,ship] = link.split('/');
return `/~landscape/messages/dm/${ship}`;
}
function getGroupRedirect(link: string) {
const [,,ship,name] = link.split('/');
return `/~landscape/ship/${ship}/${name}`;
}
export function getNotificationRedirect(link: string) {
if(link.startsWith('/graph-validator')) {
return getGraphRedirect(link);
} else if (link.startsWith('/invite')) {
return getInviteRedirect(link);
} else if (link.startsWith('/dm')) {
return getDmRedirect(link);
} else if (link.startsWith('/groups')) {
return getGroupRedirect(link);
}
}

View File

@ -89,6 +89,7 @@ const otherIndex = function(config) {
const idx = { const idx = {
mychannel: result('My Channels', '/~landscape/home', 'home', null), mychannel: result('My Channels', '/~landscape/home', 'home', null),
profile: result('Profile', `/~profile/~${window.ship}`, 'profile', null), profile: result('Profile', `/~profile/~${window.ship}`, 'profile', null),
updates: result('Notifications', '/~notifications', 'notifications', null),
messages: result('Messages', '/~landscape/messages', 'messages', null), messages: result('Messages', '/~landscape/messages', 'messages', null),
logout: result('Log Out', '/~/logout', 'logout', null) logout: result('Log Out', '/~/logout', 'logout', null)
}; };

View File

@ -1,7 +1,9 @@
import { import {
HarkPlace, HarkPlace,
Timebox, Timebox,
HarkStats HarkStats,
harkBinToId,
makePatDa
} from '@urbit/api'; } from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap'; import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import _ from 'lodash'; import _ from 'lodash';
@ -12,7 +14,7 @@ import { HarkState as State } from '../state/hark';
type HarkState = State & BaseState<State>; type HarkState = State & BaseState<State>;
function calculateCount(json: any, state: HarkState) { function calculateCount(json: any, state: HarkState) {
state.notificationsCount = Object.keys(state.unreadNotes).length; state.notificationsCount = Object.keys(state.unseen).length;
return state; return state;
} }
@ -170,7 +172,8 @@ function allStats(json: any, state: HarkState): HarkState {
function clearState(state: HarkState): HarkState { function clearState(state: HarkState): HarkState {
const initialState = { const initialState = {
notifications: new BigIntOrderedMap<Timebox>(), notifications: new BigIntOrderedMap<Timebox>(),
archivedNotifications: new BigIntOrderedMap<Timebox>(), unseen: {},
seen: {},
notificationsGroupConfig: [], notificationsGroupConfig: [],
notificationsGraphConfig: { notificationsGraphConfig: {
watchOnSelf: false, watchOnSelf: false,
@ -204,6 +207,87 @@ function more(json: any, state: HarkState): HarkState {
return state; return state;
} }
function added(json: any, state: HarkState): HarkState {
if('added' in json) {
const { bin } = json.added;
const binId = harkBinToId(bin);
state.unseen[binId] = json.added;
}
return state;
}
function archived(json: any, state: HarkState): HarkState {
if('archived' in json) {
const { lid, notification } = json.archived;
const seen = 'seen' in lid ? 'seen' : 'unseen';
const binId = harkBinToId(notification.bin);
delete state[seen][binId];
const time = makePatDa(json.archived.time);
const timebox = state.archive?.get(time) || {};
timebox[binId] = notification;
state.archive = state.archive.set(time, timebox);
}
return state;
}
function timebox(json: any, state: HarkState): HarkState {
if('timebox' in json) {
const { timebox } = json;
const { lid, notifications } = timebox;
if('archive' in lid) {
const time = makePatDa(lid.archive);
const old = state.archive.get(time) || {};
notifications.forEach((note: any) => {
const binId = harkBinToId(note.bin);
old[binId] = note;
});
state.archive = state.archive.set(time, old);
} else {
const seen = 'seen' in lid ? 'seen' : 'unseen';
notifications.forEach((note: any) => {
const binId = harkBinToId(note.bin);
state[seen][binId] = note;
});
}
}
return state;
}
function opened(json: any, state: HarkState): HarkState {
if('opened' in json) {
const bins = Object.keys(state.unseen);
bins.forEach((bin) => {
const old = state.seen[bin];
const curr = state.unseen[bin];
curr.body = [...curr.body, ...(old?.body || [])];
state.seen[bin] = curr;
delete state.unseen[bin];
});
}
return state;
}
function delPlace(json: any, state: HarkState): HarkState {
if('del-place' in json) {
const { path, desk } = json['del-place'];
const pathId = `${desk}${path}`;
const wipeBox = (t: Timebox) => {
Object.keys(t).forEach((bin) => {
if (bin.startsWith(pathId)) {
delete t[bin];
}
});
};
wipeBox(state.unseen);
wipeBox(state.seen);
state.archive.keys().forEach((key) => {
wipeBox(state.archive.get(key)!);
});
}
return state;
}
export function reduce(data, state) { export function reduce(data, state) {
const reducers = [ const reducers = [
calculateCount, calculateCount,
@ -215,7 +299,12 @@ export function reduce(data, state) {
unreadSince, unreadSince,
unreadEach, unreadEach,
seenIndex, seenIndex,
readAll readAll,
added,
timebox,
archived,
opened,
delPlace
]; ];
const reducer = compose(reducers.map(r => (s) => { const reducer = compose(reducers.map(r => (s) => {
return r(data, s); return r(data, s);

View File

@ -2,9 +2,14 @@ import {
archive, archive,
HarkBin, HarkBin,
markCountAsRead, markCountAsRead,
Notification,
NotificationGraphConfig, NotificationGraphConfig,
Unreads Unreads,
Timebox,
HarkLid,
harkBinToId,
decToUd,
unixToDa,
opened
} from '@urbit/api'; } from '@urbit/api';
import { Poke } from '@urbit/http-api'; import { Poke } from '@urbit/http-api';
import { patp2dec } from 'urbit-ob'; import { patp2dec } from 'urbit-ob';
@ -17,40 +22,32 @@ import {
createState, createState,
createSubscription, createSubscription,
pokeOptimisticallyN, pokeOptimisticallyN,
reduceState,
reduceStateN reduceStateN
} from './base'; } from './base';
import { reduce, reduceGraph, reduceGroup } from '../reducers/hark-update'; import { reduce, reduceGraph, reduceGroup } from '../reducers/hark-update';
import { BigInteger } from 'big-integer';
export const HARK_FETCH_MORE_COUNT = 3; export const HARK_FETCH_MORE_COUNT = 3;
export interface HarkState { export interface HarkState {
archivedNotifications: BigIntOrderedMap<Notification[]>; archive: BigIntOrderedMap<Timebox>;
doNotDisturb: boolean; doNotDisturb: boolean;
poke: (poke: Poke<any>) => Promise<void>; poke: (poke: Poke<any>) => Promise<void>;
getMore: () => Promise<boolean>; getMore: () => Promise<boolean>;
getSubset: ( opened: () => void;
offset: number,
count: number,
isArchive: boolean
) => Promise<void>;
// getTimeSubset: (start?: Date, end?: Date) => Promise<void>; // getTimeSubset: (start?: Date, end?: Date) => Promise<void>;
notifications: BigIntOrderedMap<Notification[]>; unseen: Timebox;
unreadNotes: Notification[]; seen: Timebox;
notificationsCount: number; notificationsCount: number;
notificationsGraphConfig: NotificationGraphConfig; // TODO unthread this everywhere notificationsGraphConfig: NotificationGraphConfig; // TODO unthread this everywhere
notificationsGroupConfig: string[]; notificationsGroupConfig: string[];
unreads: Unreads; unreads: Unreads;
archive: (bin: HarkBin, time?: BigInteger) => Promise<void>; archiveNote: (bin: HarkBin, lid: HarkLid) => Promise<void>;
readNote: (bin: HarkBin) => Promise<void>;
readCount: (path: string) => Promise<void>;
} }
const useHarkState = createState<HarkState>( const useHarkState = createState<HarkState>(
'Hark', 'Hark',
(set, get) => ({ (set, get) => ({
archivedNotifications: new BigIntOrderedMap<Notification[]>(), archive: new BigIntOrderedMap<Timebox>(),
doNotDisturb: false, doNotDisturb: false,
unreadNotes: [], unreadNotes: [],
poke: async (poke: Poke<any>) => { poke: async (poke: Poke<any>) => {
@ -60,29 +57,36 @@ const useHarkState = createState<HarkState>(
const poke = markCountAsRead({ desk: (window as any).desk, path }); const poke = markCountAsRead({ desk: (window as any).desk, path });
await pokeOptimisticallyN(useHarkState, poke, [reduce]); await pokeOptimisticallyN(useHarkState, poke, [reduce]);
}, },
archive: async (bin: HarkBin, time?: BigInteger) => { opened: async () => {
const poke = archive(bin, time); reduceStateN(get(), { opened: null }, [reduce]);
await pokeOptimisticallyN(useHarkState, poke, [reduce]);
await api.poke(opened);
}, },
readNote: async (bin) => { archiveNote: async (bin: HarkBin, lid: HarkLid) => {
await pokeOptimisticallyN(useHarkState, readNote(bin), [reduce]); const poke = archive(bin, lid);
get().set((draft) => {
const key = 'seen' in lid ? 'seen' : 'unseen';
const binId = harkBinToId(bin);
delete draft[key][binId];
});
await api.poke(poke);
}, },
getMore: async (): Promise<boolean> => { getMore: async (): Promise<boolean> => {
const state = get(); const state = get();
const offset = state.notifications.size || 0; const oldSize = state.archive?.size || 0;
await state.getSubset(offset, HARK_FETCH_MORE_COUNT, false); const offset = decToUd(
const newState = get(); state.archive?.peekSmallest()?.[0].toString()
return offset === (newState?.notifications?.size || 0); || unixToDa(Date.now() * 1000).toString()
}, );
getSubset: async (offset, count, isArchive): Promise<void> => { const update = await api.scry({
const where = isArchive ? 'archive' : 'inbox';
const { harkUpdate } = await api.scry({
app: 'hark-store', app: 'hark-store',
path: `/recent/${where}/${offset}/${count}` path: `/recent/inbox/${offset}/5`
}); });
reduceState(useHarkState, harkUpdate, [reduce]); reduceStateN(useHarkState.getState(), update, [reduce]);
return get().archive?.size === oldSize;
}, },
notifications: new BigIntOrderedMap<Notification[]>(), unseen: {},
seen: {},
notificationsCount: 0, notificationsCount: 0,
notificationsGraphConfig: { notificationsGraphConfig: {
watchOnSelf: false, watchOnSelf: false,
@ -93,9 +97,9 @@ const useHarkState = createState<HarkState>(
unreads: {} unreads: {}
}), }),
[ [
'unreadNotes', 'seen',
'notifications', 'unseen',
'archivedNotifications', 'archive',
'unreads', 'unreads',
'notificationsCount' 'notificationsCount'
], ],

View File

@ -0,0 +1,31 @@
import { Box } from '@tlon/indigo-react';
import React, { useEffect } from 'react';
import useHarkState, { HarkState } from '~/logic/state/hark';
import { Notification } from './notification';
const selArchive = (s: HarkState) => s.archive;
export function Archive() {
const archive = useHarkState(selArchive);
const keys = archive.keys();
useEffect(() => {
useHarkState.getState().getMore();
}, []);
return (
<Box pt="2" overflowY="auto" overflowX="hidden">
{keys.map(key =>
Object.entries(archive.get(key)!)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n]) => (
<Notification
key={`${key.toString()}-${binId}`}
lid={{ time: key.toString() }}
notification={n}
/>
))
)}
</Box>
);
}

View File

@ -0,0 +1,58 @@
import { Box, Center, Col, Text } from '@tlon/indigo-react';
import React from 'react';
import useHarkState, { HarkState } from '~/logic/state/hark';
import { harkBinToId, HarkLid, Timebox } from '../../../../../npm/api/dist';
import { Notification } from './notification';
const unseenLid = { unseen: null };
const seenLid = { seen: null };
const selUnseen = (s: HarkState) => s.unseen;
const selSeen = (s: HarkState) => s.seen;
export function NewBox() {
const seen = useHarkState(selSeen);
const unseen = useHarkState(selUnseen);
const empty = Object.keys(seen).length + Object.keys(unseen).length === 0;
return (
<Box pt="2" overflowY="auto" overflowX="hidden">
{empty ? (
<Center p="3">
<Text>All clear!</Text>
</Center>
) : (
<>
<Lid lid={unseenLid} timebox={unseen} title="Unseen" />
<Lid lid={seenLid} timebox={seen} title="Seen" />
</>
)}
</Box>
);
}
function Lid({
lid,
timebox,
title
}: {
lid: HarkLid;
timebox: Timebox;
title: string;
}) {
if(Object.keys(timebox).length === 0) {
return null;
}
return (
<>
<Text gray p="2">
{title}
</Text>
<Col>
{Object.entries(timebox)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n]) => (
<Notification key={harkBinToId(n.bin)} lid={lid} notification={n} />
))}
</Col>
</>
);
}

View File

@ -1,135 +0,0 @@
import { Col } from '@tlon/indigo-react';
import {
IndexedNotification,
JoinRequests,
Notifications,
seen,
Timebox,
unixToDa
} from '@urbit/api';
import { BigInteger } from 'big-integer';
import _ from 'lodash';
import f from 'lodash/fp';
import moment from 'moment';
import React, { useEffect } from 'react';
import { getNotificationKey } from '~/logic/lib/hark';
import { daToUnix } from '~/logic/lib/util';
import useHarkState from '~/logic/state/hark';
import { Invites } from './invites';
import { Notification } from './notification';
import airlock from '~/logic/api';
type DatedTimebox = [BigInteger, Timebox];
function filterNotification(groups: string[]) {
if (groups.length === 0) {
return () => true;
}
return (n: IndexedNotification) => {
if ('graph' in n.index) {
const { group } = n.index.graph;
return groups.findIndex(g => group === g) !== -1;
} else if ('group' in n.index) {
const { group } = n.index.group;
return groups.findIndex(g => group === g) !== -1;
}
return true;
};
}
export default function Inbox(props: {
archive: Notifications;
showArchive?: boolean;
filter: string[];
pendingJoin: JoinRequests;
}) {
useEffect(() => {
let hasSeen = false;
setTimeout(() => {
hasSeen = true;
}, 3000);
return () => {
if (hasSeen) {
airlock.poke(seen());
}
};
}, []);
const ready = useHarkState(
s => Object.keys(s.unreads.graph).length > 0
);
const getMore = useHarkState(s => s.getMore);
const notificationState = useHarkState(state => state.notifications);
const unreadNotes = useHarkState(s => s.unreadNotes);
const archivedNotifications = useHarkState(state => state.archivedNotifications);
const notifications =
Array.from(props.showArchive ? archivedNotifications : notificationState) || [];
const notificationsByDay = f.flow(
f.map<DatedTimebox, DatedTimebox>(([date, nots]) => [
date,
nots.filter(filterNotification(props.filter))
]),
f.groupBy<DatedTimebox>(([d]) => {
const date = moment(daToUnix(d));
if (moment().subtract(6, 'hours').isBefore(date)) {
return 'latest';
} else {
return date.format('YYYYMMDD');
}
})
)(notifications);
const notificationsByDayMap = new Map<string, DatedTimebox[]>(
Object.keys(notificationsByDay).map((timebox) => {
return [timebox, notificationsByDay[timebox]];
})
);
const date = unixToDa(Date.now());
return (
<Col p={1} position="relative" height="100%" overflowY="auto" overflowX="hidden">
<Invites pendingJoin={props.pendingJoin} />
</Col>
);
}
function sortTimeboxes([a]: DatedTimebox, [b]: DatedTimebox) {
return b.subtract(a);
}
function sortIndexedNotification(
{ notification: a }: IndexedNotification,
{ notification: b }: IndexedNotification
) {
return b.time - a.time;
}
function DaySection({
timeboxes,
unread = false
}) {
const lent = timeboxes.map(([,nots]) => nots.length).reduce(f.add, 0);
if (lent === 0 || timeboxes.length === 0) {
return null;
}
return (
<>
{_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i: number) =>
_.map(nots.sort(sortIndexedNotification), (not, j: number) => (
<Notification
key={getNotificationKey(date, not)}
notification={not}
unread={unread}
time={!unread ? date : undefined}
/>
))
)}
</>
);
}

View File

@ -1,64 +1,112 @@
import { Box, Button, Icon, Row } from '@tlon/indigo-react'; import { Box, Col, Text, Button, Icon, Row } from '@tlon/indigo-react';
import { import {
GraphNotificationContents, HarkLid,
GroupNotificationContents, harkLidToId,
IndexedNotification harkBinToId,
Notification as INotification,
HarkContent
} from '@urbit/api'; } from '@urbit/api';
import { BigInteger } from 'big-integer'; import { BigInteger } from 'big-integer';
import React, { ReactNode, useCallback } from 'react'; import React, { useCallback } from 'react';
import { getNotificationKey } from '~/logic/lib/hark';
import { useHovering } from '~/logic/lib/util'; import { useHovering } from '~/logic/lib/util';
import useLocalState from '~/logic/state/local'; import useLocalState from '~/logic/state/local';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction'; import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
import { SwipeMenu } from '~/views/components/SwipeMenu'; import { SwipeMenu } from '~/views/components/SwipeMenu';
import { GraphNotification } from './graph';
import { GroupNotification } from './group';
import useHarkState from '~/logic/state/hark'; import useHarkState from '~/logic/state/hark';
import shallow from 'zustand/shallow'; 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';
export interface NotificationProps { export interface NotificationProps {
notification: IndexedNotification; notification: INotification;
time: BigInteger; time: BigInteger;
unread: boolean; unread: boolean;
} }
export function NotificationWrapper(props: { const MAX_CONTENTS = 5;
time?: BigInteger;
read?: boolean; interface NotificationTextProps extends PropFunc<typeof Box> {
notification?: IndexedNotification; contents: HarkContent[];
children: ReactNode; }
const NotificationText = ({ contents, ...rest }: NotificationTextProps) => {
return (
<>
{contents.map((content, idx) => {
if ('ship' in content) {
return (
<Mention
key={idx}
ship={content.ship}
first={idx === 0}
{...rest}
/>
);
}
return <Text key={idx} {...rest}>{content.text}</Text>;
})}
</>
);
};
export function Notification(props: {
lid: HarkLid;
notification: INotification;
}) { }) {
const { time, notification, children, read = false } = props; const { notification, lid } = props;
const read = !('unseen' in lid);
const key = `${harkLidToId(lid)}-${harkBinToId(notification.bin)}`;
const history = useHistory();
const isMobile = useLocalState(s => s.mobile); const isMobile = useLocalState(s => s.mobile);
const [archive, readNote] = useHarkState(s => [s.archive, s.readNote], shallow); const onArchive = useCallback(
async (e) => {
const onArchive = useCallback(async (e) => {
e.stopPropagation(); e.stopPropagation();
if (!notification) { if (!notification) {
return; return;
} }
await archive(notification.index, time); useHarkState.getState().archiveNote(notification.bin, lid);
}, [time, notification]); },
[notification, lid]
const onClick = (e: any) => { );
if (!notification || read) {
return;
}
return readNote(notification.index);
};
const { hovering, bind } = useHovering(); const { hovering, bind } = useHovering();
const dedupedBody = uniqBy(notification.body, item => item.link);
const contents = map(dedupedBody, 'content').filter(
c => c.length > 0
);
const first = notification.body[0];
if (!first) {
// should be unreachable
return null;
}
const onClick = (e: any) => {
const redirect = getNotificationRedirect(first.link);
if(redirect) {
history.push(redirect);
} else {
console.log('no redirect');
}
};
return ( return (
<SwipeMenu <SwipeMenu
key={(time && notification && getNotificationKey(time, notification)) ?? 'unknown'} key={key}
m={2} m={2}
menuWidth={100} menuWidth={100}
disabled={!isMobile} disabled={!isMobile}
menu={ menu={
<Button onClick={onArchive} ml={2} height="100%" width="92px" primary destructive> <Button
onClick={onArchive}
ml={2}
height="100%"
width="92px"
primary
destructive
>
Remove Remove
</Button> </Button>
} }
@ -71,10 +119,27 @@ export function NotificationWrapper(props: {
gridTemplateColumns={['1fr 24px', '1fr 200px']} gridTemplateColumns={['1fr 24px', '1fr 200px']}
gridTemplateRows="auto" gridTemplateRows="auto"
gridTemplateAreas="'header actions' 'main main'" gridTemplateAreas="'header actions' 'main main'"
p={2} p={3}
{...bind} {...bind}
> >
{children} <Col gapY={contents.length === 0 ? 0 : 2}>
<Row>
<NotificationText contents={first.title} fontWeight="medium" />
</Row>
<Col gapY="2">
{take(contents, MAX_CONTENTS).map((content, i) => (
<Box key={i}>
<NotificationText lineHeight="tall" contents={content} />
</Box>
))}
</Col>
{contents.length > MAX_CONTENTS ? (
<Text mt="2" gray display="block">
and {contents.length - MAX_CONTENTS} more
</Text>
) : null}
</Col>
<Row <Row
alignItems="flex-start" alignItems="flex-start"
gapX={2} gapX={2}
@ -82,7 +147,7 @@ export function NotificationWrapper(props: {
justifyContent="flex-end" justifyContent="flex-end"
opacity={[0, hovering ? 1 : 0]} opacity={[0, hovering ? 1 : 0]}
> >
{notification && ( {!('time' in lid) && (
<StatelessAsyncAction <StatelessAsyncAction
name="" name=""
borderRadius={1} borderRadius={1}
@ -97,46 +162,3 @@ export function NotificationWrapper(props: {
</SwipeMenu> </SwipeMenu>
); );
} }
export function Notification(props: NotificationProps) {
const { notification, unread } = props;
const { contents, time } = notification.notification;
const wrapperProps = {
notification,
read: !unread,
time: props.time
};
if ('graph' in notification.index) {
const index = notification.index.graph;
const c: GraphNotificationContents = (contents as any).graph;
return (
<NotificationWrapper {...wrapperProps}>
<GraphNotification
index={index}
contents={c}
read={!unread}
timebox={props.time}
time={time}
/>
</NotificationWrapper>
);
}
if ('group' in notification.index) {
const index = notification.index.group;
const c: GroupNotificationContents = (contents as any).group;
return (
<NotificationWrapper {...wrapperProps}>
<GroupNotification
index={index}
contents={c}
time={time}
/>
</NotificationWrapper>
);
}
return null;
}

View File

@ -1,29 +1,65 @@
import { Box, Col, Icon, Row, Text } from '@tlon/indigo-react'; import { Action, Box, Col, Icon, Row, Text } from '@tlon/indigo-react';
import React, { ReactElement, useCallback, useRef } from 'react'; import React, { ReactElement, ReactNode, useEffect, useRef } from 'react';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import { Link, Route, Switch } from 'react-router-dom'; import { Link, Route, Switch, useHistory, useLocation } from 'react-router-dom';
import useGroupState from '~/logic/state/group';
import useHarkState from '~/logic/state/hark'; import useHarkState from '~/logic/state/hark';
import { Body } from '~/views/components/Body'; import { Body } from '~/views/components/Body';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction'; import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
import { useTutorialModal } from '~/views/components/useTutorialModal'; import { useTutorialModal } from '~/views/components/useTutorialModal';
import Inbox from './inbox'; import { Archive } from './Archive';
import airlock from '~/logic/api'; import { NewBox } from './NewBox';
import { readAll } from '@urbit/api';
const baseUrl = '/~notifications'; const baseUrl = '/~notifications';
export function NavLink({
href,
children
}: {
href: string;
children: ReactNode;
}) {
const location = useLocation();
const { push } = useHistory();
const isActive = href === location.pathname;
const onClick = () => {
push(href);
};
return (
<Action
backgroundColor="transparent"
onClick={onClick}
color={isActive ? 'black' : 'gray'}
>
{children}
</Action>
);
}
export default function NotificationsScreen(props: any): ReactElement { export default function NotificationsScreen(props: any): ReactElement {
const relativePath = (p: string) => baseUrl + p; const relativePath = (p: string) => baseUrl + p;
const pendingJoin = useGroupState(s => s.pendingJoin);
const onReadAll = useCallback(async () => {
await airlock.poke(readAll());
}, []);
const anchorRef = useRef<HTMLElement | null>(null); const anchorRef = useRef<HTMLElement | null>(null);
useTutorialModal('notifications', true, anchorRef); useTutorialModal('notifications', true, anchorRef);
const notificationsCount = useHarkState(state => state.notificationsCount); const notificationsCount = useHarkState(state => state.notificationsCount);
const onReadAll = async () => {};
useEffect(() => {
function visibilitychange() {
if (document.visibilityState === 'hidden') {
useHarkState.getState().opened();
}
}
document.addEventListener('visibilitychange', visibilitychange);
return () => {
document.removeEventListener('visibilitychange', visibilitychange);
useHarkState.getState().opened();
};
}, []);
return ( return (
<Switch> <Switch>
<Route <Route
@ -33,7 +69,10 @@ export default function NotificationsScreen(props: any): ReactElement {
return ( return (
<> <>
<Helmet defer={false}> <Helmet defer={false}>
<title>{ notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape - Notifications</title> <title>
{notificationsCount ? `(${String(notificationsCount)}) ` : ''}
Groups - Notifications
</title>
</Helmet> </Helmet>
<Body> <Body>
<Col overflowY="hidden" height="100%"> <Col overflowY="hidden" height="100%">
@ -46,14 +85,20 @@ export default function NotificationsScreen(props: any): ReactElement {
borderBottom={1} borderBottom={1}
borderBottomColor="lightGray" borderBottomColor="lightGray"
> >
<Text
<Text fontWeight="bold" fontSize={2} lineHeight={1} ref={anchorRef}> fontWeight="bold"
fontSize={2}
lineHeight={1}
ref={anchorRef}
>
Notifications Notifications
</Text> </Text>
<Row <Row gapX="2">
justifyContent="space-between" <NavLink href="/~notifications">New</NavLink>
gapX={3} <NavLink href="/~notifications/archive">Archive</NavLink>
> </Row>
<Row justifyContent="space-between" gapX={3}>
{ (false as boolean) ? (
<StatelessAsyncAction <StatelessAsyncAction
overflow="hidden" overflow="hidden"
color="black" color="black"
@ -62,6 +107,7 @@ export default function NotificationsScreen(props: any): ReactElement {
> >
Mark All Read Mark All Read
</StatelessAsyncAction> </StatelessAsyncAction>
) : null}
<Link to="/~settings#notifications"> <Link to="/~settings#notifications">
<Box> <Box>
<Icon lineHeight={1} icon="Adjust" /> <Icon lineHeight={1} icon="Adjust" />
@ -69,11 +115,9 @@ export default function NotificationsScreen(props: any): ReactElement {
</Link> </Link>
</Row> </Row>
</Row> </Row>
{!view && <Inbox { view === 'archive' ? (
pendingJoin={pendingJoin} <Archive />
{...props} ) : <NewBox /> }
filter={[]}
/>}
</Col> </Col>
</Body> </Body>
</> </>

View File

@ -20,6 +20,7 @@ import ReconnectButton from './ReconnectButton';
import { StatusBarItem } from './StatusBarItem'; import { StatusBarItem } from './StatusBarItem';
import { useTutorialModal } from './useTutorialModal'; import { useTutorialModal } from './useTutorialModal';
import { StatusBarJoins } from './StatusBarJoins'; import { StatusBarJoins } from './StatusBarJoins';
import useHarkState from '~/logic/state/hark';
const localSel = selectLocalState(['toggleOmnibox']); const localSel = selectLocalState(['toggleOmnibox']);
@ -29,6 +30,7 @@ const StatusBar = (props) => {
const metaKey = window.navigator.platform.includes('Mac') ? '⌘' : 'Ctrl+'; const metaKey = window.navigator.platform.includes('Mac') ? '⌘' : 'Ctrl+';
const { toggleOmnibox } = useLocalState(localSel); const { toggleOmnibox } = useLocalState(localSel);
const { hideAvatars } = useSettingsState(selectCalmState); const { hideAvatars } = useSettingsState(selectCalmState);
const notificationsCount = useHarkState(s => s.notificationsCount);
const color = ourContact ? `#${uxToHex(ourContact.color)}` : '#000'; const color = ourContact ? `#${uxToHex(ourContact.color)}` : '#000';
const xPadding = !hideAvatars && ourContact?.avatar ? '0' : '2'; const xPadding = !hideAvatars && ourContact?.avatar ? '0' : '2';
@ -75,7 +77,7 @@ const StatusBar = (props) => {
> >
<Icon icon='Dashboard' color='black' /> <Icon icon='Dashboard' color='black' />
</Button> </Button>
<StatusBarItem float={floatLeap} mr={2} onClick={() => toggleOmnibox()}> <StatusBarItem position="relative" float={floatLeap} mr={2} onClick={() => toggleOmnibox()}>
<Icon icon='LeapArrow' /> <Icon icon='LeapArrow' />
<Text ref={anchorRef} ml={2} color='black'> <Text ref={anchorRef} ml={2} color='black'>
Leap Leap
@ -83,6 +85,11 @@ const StatusBar = (props) => {
<Text display={['none', 'inline']} ml={2} color='gray'> <Text display={['none', 'inline']} ml={2} color='gray'>
{metaKey}/ {metaKey}/
</Text> </Text>
{ notificationsCount > 0 && (
<Box position="absolute" right="-8px" top="-8px">
<Icon icon="Bullet" color="blue" />
</Box>
)}
</StatusBarItem> </StatusBarItem>
<StatusBarJoins /> <StatusBarJoins />
<ReconnectButton /> <ReconnectButton />

View File

@ -55,7 +55,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [selected, setSelected] = useState<[] | [string, string]>([]); const [selected, setSelected] = useState<[] | [string, string]>([]);
const contactState = useContactState(state => state.contacts); const contactState = useContactState(state => state.contacts);
const notifications = useHarkState(state => state.notifications); const notificationCount = useHarkState(state => state.notificationsCount);
const invites = useInviteState(state => state.invites); const invites = useInviteState(state => state.invites);
const tiles = useLaunchState(state => state.tiles); const tiles = useLaunchState(state => state.tiles);
const [leapCursor, setLeapCursor] = useState('pointer'); const [leapCursor, setLeapCursor] = useState('pointer');
@ -119,7 +119,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
if (category === 'other') { if (category === 'other') {
return [ return [
'other', 'other',
index.get('other').filter(({ app }) => app !== 'tutorial' && app !== 'inbox') index.get('other').filter(({ app }) => app !== 'tutorial')
]; ];
} }
return [category, []]; return [category, []];
@ -163,7 +163,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
app === 'Links' || app === 'Links' ||
app === 'Terminal' || app === 'Terminal' ||
app === 'home' || app === 'home' ||
app === 'inbox' app === 'notifications'
) { ) {
if(shift && app === 'profile') { if(shift && app === 'profile') {
// TODO: hacky, fix // TODO: hacky, fix
@ -351,6 +351,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
navigate={() => navigate(result.app, result.link, false)} navigate={() => navigate(result.app, result.link, false)}
setSelection={() => setSelection(result.app, result.link)} setSelection={() => setSelection(result.app, result.link)}
selected={sel} selected={sel}
hasNotifications={notificationCount !== 0}
/> />
))} ))}
</Box> </Box>
@ -358,7 +359,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
})} })}
</Box> </Box>
); );
}, [results, navigate, selected, contactState, notifications, invites]); }, [results, navigate, selected, contactState, invites]);
return ( return (
<Portal> <Portal>

View File

@ -39,6 +39,7 @@ interface OmniboxResultProps {
shiftLink?: string; shiftLink?: string;
shiftDescription?: string; shiftDescription?: string;
description?: string; description?: string;
hasNotifications?: boolean;
} }
interface OmniboxResultState { interface OmniboxResultState {
@ -142,14 +143,20 @@ export class OmniboxResult extends Component<OmniboxResultProps, OmniboxResultSt
); );
} else if (icon === 'notifications') { } else if (icon === 'notifications') {
graphic = ( graphic = (
<Box mr="2" height="18px" width="18px" position="relative" display="inline-block">
<Icon <Icon
display='inline-block' display='inline-block'
verticalAlign='middle' verticalAlign='middle'
icon='Notifications' icon='Notifications'
mr={2}
size='18px' size='18px'
color={iconFill} color={iconFill}
/> />
{this.props.hasNotifications ? (
<Box position="absolute" right="-6px" top="-4px">
<Icon icon="Bullet" color={(this.state.hovered || selected === link) ? 'white' : 'blue'} />
</Box>
) : null}
</Box>
); );
} else if (icon === 'messages') { } else if (icon === 'messages') {
graphic = ( graphic = (

View File

@ -2,7 +2,6 @@ import { Box } from '@tlon/indigo-react';
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { Route, Switch, useHistory, useLocation } from 'react-router-dom'; import { Route, Switch, useHistory, useLocation } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import ob from 'urbit-ob';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState'; import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import useMetadataState from '~/logic/state/metadata'; import useMetadataState from '~/logic/state/metadata';
import LaunchApp from '~/views/apps/launch/App'; import LaunchApp from '~/views/apps/launch/App';
@ -15,6 +14,7 @@ import { useShortcut } from '~/logic/state/settings';
import Landscape from '~/views/landscape/index'; import Landscape from '~/views/landscape/index';
import GraphApp from '../../apps/graph/App'; import GraphApp from '../../apps/graph/App';
import { getNotificationRedirect } from '~/logic/lib/notificationRedirects';
export const Container = styled(Box)` export const Container = styled(Box)`
flex-grow: 1; flex-grow: 1;
@ -23,94 +23,6 @@ export const Container = styled(Box)`
height: calc(100% - 62px); height: calc(100% - 62px);
`; `;
function getGroupResourceRedirect(key: string) {
const association = useMetadataState.getState().associations.graph[`/ship/${key}`];
const { metadata } = association;
if(!association || !('graph' in metadata.config)) {
return '';
}
return `/~landscape${association.group}/resource/${metadata.config.graph}${association.resource}`;
}
function getPostRedirect(key: string, segs: string[]) {
const association = useMetadataState.getState().associations.graph[`/ship/${key}`];
const { metadata } = association;
if(!association || !('graph' in metadata.config)) {
return '';
}
return `/~landscape${association.group}/feed/thread/${segs.slice(0, -1).join('/')}`;
}
function getChatRedirect(chat: string, segs: string[]) {
const qs = segs.length > 0 ? `?msg=${segs[0]}` : '';
return `${getGroupResourceRedirect(chat)}${qs}`;
}
function getPublishRedirect(graphKey: string, segs: string[]) {
const base = getGroupResourceRedirect(graphKey);
if(segs.length === 3) {
return `${base}/note/${segs[0]}`;
} else if (segs.length === 4) {
return `${base}/note/${segs[0]}?selected=${segs[2]}`;
}
return base;
}
function getLinkRedirect(graphKey: string, segs: string[]) {
const base = getGroupResourceRedirect(graphKey);
if(segs.length === 1) {
return `${base}/index/${segs[0]}`;
} else if (segs.length === 3) {
return `${base}/index/${segs[0]}?selected=${segs[1]}`;
}
return base;
}
function getGraphRedirect(link: string) {
const [,mark, ship, name, ...rest] = link.split('/');
const graphKey = `${ship}/${name}`;
switch(mark) {
case 'graph-validator-dm':
return `/~landscape/messages/dm/${ob.patp(rest[0])}`;
case 'graph-validator-chat':
return getChatRedirect(graphKey, rest);
case 'graph-validator-publish':
return getPublishRedirect(graphKey, rest);
case 'graph-validator-link':
return getLinkRedirect(graphKey, rest);
case 'graph-validator-post':
return getPostRedirect(graphKey, rest);
default:
return'';
}
}
function getInviteRedirect(link: string) {
const [,,app,uid] = link.split('/');
return `/invites/${app}/${uid}`;
}
function getDmRedirect(link: string) {
const [,,ship] = link.split('/');
return `/~landscape/messages/dm/${ship}`;
}
function getGroupRedirect(link: string) {
const [,,ship,name] = link.split('/');
return `/~landscape/ship/${ship}/${name}`;
}
function getNotificationRedirect(link: string) {
if(link.startsWith('/graph-validator')) {
return getGraphRedirect(link);
} else if (link.startsWith('/invite')) {
return getInviteRedirect(link);
} else if (link.startsWith('/dm')) {
return getDmRedirect(link);
} else if (link.startsWith('/groups')) {
return getGroupRedirect(link);
}
}
export const Content = (props) => { export const Content = (props) => {
const history = useHistory(); const history = useHistory();
const location = useLocation(); const location = useLocation();
@ -119,7 +31,7 @@ export const Content = (props) => {
useEffect(() => { useEffect(() => {
const query = new URLSearchParams(location.search); const query = new URLSearchParams(location.search);
if(mdLoaded && query.has('grid-note')) { if(mdLoaded && query.has('grid-note')) {
history.push(getNotificationRedirect(query.get('grid-note'))); history.push(getNotificationRedirect(query.get('grid-note')!));
} else if(mdLoaded && query.has('grid-link')) { } else if(mdLoaded && query.has('grid-link')) {
const link = decodeURIComponent(query.get('grid-link')!); const link = decodeURIComponent(query.get('grid-link')!);
history.push(`/perma${link}`); history.push(`/perma${link}`);

View File

@ -1,7 +1,7 @@
:~ title+'Groups' :~ title+'Groups'
info+'A suite of applications to communicate on Urbit' info+'A suite of applications to communicate on Urbit'
color+0xee.5432 color+0xee.5432
glob-http+['https://bootstrap.urbit.org/glob-0v4d800.vqmrl.r0js2.ip98v.dd8sh.glob' 0v4d800.vqmrl.r0js2.ip98v.dd8sh] glob-http+['https://bootstrap.urbit.org/glob-0v1.9ge2e.2cb12.25972.u0l29.j0kuv.glob' 0v1.9ge2e.2cb12.25972.u0l29.j0kuv]
base+'landscape' base+'landscape'
version+[1 3 5] version+[1 3 5]
website+'https://tlon.io' website+'https://tlon.io'

View File

@ -70,8 +70,6 @@ export const opened = harkAction({
opened: null opened: null
}); });
export const markCountAsRead = (place: HarkPlace): Poke<unknown> => export const markCountAsRead = (place: HarkPlace): Poke<unknown> =>
harkAction({ harkAction({
'read-count': place 'read-count': place
@ -147,3 +145,10 @@ export function harkBinEq(a: HarkBin, b: HarkBin): boolean {
a.path === b.path a.path === b.path
); );
} }
export function harkLidToId(lid: HarkLid): string {
if('time' in lid) {
return `archive-${lid.time}`;
}
return Object.keys(lid)[0];
}