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 = {
mychannel: result('My Channels', '/~landscape/home', 'home', null),
profile: result('Profile', `/~profile/~${window.ship}`, 'profile', null),
updates: result('Notifications', '/~notifications', 'notifications', null),
messages: result('Messages', '/~landscape/messages', 'messages', null),
logout: result('Log Out', '/~/logout', 'logout', null)
};

View File

@ -1,7 +1,9 @@
import {
HarkPlace,
Timebox,
HarkStats
HarkStats,
harkBinToId,
makePatDa
} from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import _ from 'lodash';
@ -12,7 +14,7 @@ import { HarkState as State } from '../state/hark';
type HarkState = State & BaseState<State>;
function calculateCount(json: any, state: HarkState) {
state.notificationsCount = Object.keys(state.unreadNotes).length;
state.notificationsCount = Object.keys(state.unseen).length;
return state;
}
@ -170,7 +172,8 @@ function allStats(json: any, state: HarkState): HarkState {
function clearState(state: HarkState): HarkState {
const initialState = {
notifications: new BigIntOrderedMap<Timebox>(),
archivedNotifications: new BigIntOrderedMap<Timebox>(),
unseen: {},
seen: {},
notificationsGroupConfig: [],
notificationsGraphConfig: {
watchOnSelf: false,
@ -204,6 +207,87 @@ function more(json: any, state: HarkState): HarkState {
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) {
const reducers = [
calculateCount,
@ -215,7 +299,12 @@ export function reduce(data, state) {
unreadSince,
unreadEach,
seenIndex,
readAll
readAll,
added,
timebox,
archived,
opened,
delPlace
];
const reducer = compose(reducers.map(r => (s) => {
return r(data, s);

View File

@ -2,9 +2,14 @@ import {
archive,
HarkBin,
markCountAsRead,
Notification,
NotificationGraphConfig,
Unreads
Unreads,
Timebox,
HarkLid,
harkBinToId,
decToUd,
unixToDa,
opened
} from '@urbit/api';
import { Poke } from '@urbit/http-api';
import { patp2dec } from 'urbit-ob';
@ -17,40 +22,32 @@ import {
createState,
createSubscription,
pokeOptimisticallyN,
reduceState,
reduceStateN
} from './base';
import { reduce, reduceGraph, reduceGroup } from '../reducers/hark-update';
import { BigInteger } from 'big-integer';
export const HARK_FETCH_MORE_COUNT = 3;
export interface HarkState {
archivedNotifications: BigIntOrderedMap<Notification[]>;
archive: BigIntOrderedMap<Timebox>;
doNotDisturb: boolean;
poke: (poke: Poke<any>) => Promise<void>;
getMore: () => Promise<boolean>;
getSubset: (
offset: number,
count: number,
isArchive: boolean
) => Promise<void>;
opened: () => void;
// getTimeSubset: (start?: Date, end?: Date) => Promise<void>;
notifications: BigIntOrderedMap<Notification[]>;
unreadNotes: Notification[];
unseen: Timebox;
seen: Timebox;
notificationsCount: number;
notificationsGraphConfig: NotificationGraphConfig; // TODO unthread this everywhere
notificationsGroupConfig: string[];
unreads: Unreads;
archive: (bin: HarkBin, time?: BigInteger) => Promise<void>;
readNote: (bin: HarkBin) => Promise<void>;
readCount: (path: string) => Promise<void>;
archiveNote: (bin: HarkBin, lid: HarkLid) => Promise<void>;
}
const useHarkState = createState<HarkState>(
'Hark',
(set, get) => ({
archivedNotifications: new BigIntOrderedMap<Notification[]>(),
archive: new BigIntOrderedMap<Timebox>(),
doNotDisturb: false,
unreadNotes: [],
poke: async (poke: Poke<any>) => {
@ -60,29 +57,36 @@ const useHarkState = createState<HarkState>(
const poke = markCountAsRead({ desk: (window as any).desk, path });
await pokeOptimisticallyN(useHarkState, poke, [reduce]);
},
archive: async (bin: HarkBin, time?: BigInteger) => {
const poke = archive(bin, time);
await pokeOptimisticallyN(useHarkState, poke, [reduce]);
opened: async () => {
reduceStateN(get(), { opened: null }, [reduce]);
await api.poke(opened);
},
readNote: async (bin) => {
await pokeOptimisticallyN(useHarkState, readNote(bin), [reduce]);
archiveNote: async (bin: HarkBin, lid: HarkLid) => {
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> => {
const state = get();
const offset = state.notifications.size || 0;
await state.getSubset(offset, HARK_FETCH_MORE_COUNT, false);
const newState = get();
return offset === (newState?.notifications?.size || 0);
},
getSubset: async (offset, count, isArchive): Promise<void> => {
const where = isArchive ? 'archive' : 'inbox';
const { harkUpdate } = await api.scry({
const oldSize = state.archive?.size || 0;
const offset = decToUd(
state.archive?.peekSmallest()?.[0].toString()
|| unixToDa(Date.now() * 1000).toString()
);
const update = await api.scry({
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,
notificationsGraphConfig: {
watchOnSelf: false,
@ -93,9 +97,9 @@ const useHarkState = createState<HarkState>(
unreads: {}
}),
[
'unreadNotes',
'notifications',
'archivedNotifications',
'seen',
'unseen',
'archive',
'unreads',
'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 {
GraphNotificationContents,
GroupNotificationContents,
IndexedNotification
HarkLid,
harkLidToId,
harkBinToId,
Notification as INotification,
HarkContent
} from '@urbit/api';
import { BigInteger } from 'big-integer';
import React, { ReactNode, useCallback } from 'react';
import { getNotificationKey } from '~/logic/lib/hark';
import React, { useCallback } from 'react';
import { useHovering } from '~/logic/lib/util';
import useLocalState from '~/logic/state/local';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
import { SwipeMenu } from '~/views/components/SwipeMenu';
import { GraphNotification } from './graph';
import { GroupNotification } from './group';
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 {
notification: IndexedNotification;
notification: INotification;
time: BigInteger;
unread: boolean;
}
export function NotificationWrapper(props: {
time?: BigInteger;
read?: boolean;
notification?: IndexedNotification;
children: ReactNode;
const MAX_CONTENTS = 5;
interface NotificationTextProps extends PropFunc<typeof Box> {
contents: HarkContent[];
}
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 [archive, readNote] = useHarkState(s => [s.archive, s.readNote], shallow);
const onArchive = useCallback(async (e) => {
e.stopPropagation();
if (!notification) {
return;
}
await archive(notification.index, time);
}, [time, notification]);
const onClick = (e: any) => {
if (!notification || read) {
return;
}
return readNote(notification.index);
};
const onArchive = useCallback(
async (e) => {
e.stopPropagation();
if (!notification) {
return;
}
useHarkState.getState().archiveNote(notification.bin, lid);
},
[notification, lid]
);
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 (
<SwipeMenu
key={(time && notification && getNotificationKey(time, notification)) ?? 'unknown'}
key={key}
m={2}
menuWidth={100}
disabled={!isMobile}
menu={
<Button onClick={onArchive} ml={2} height="100%" width="92px" primary destructive>
<Button
onClick={onArchive}
ml={2}
height="100%"
width="92px"
primary
destructive
>
Remove
</Button>
}
@ -71,10 +119,27 @@ export function NotificationWrapper(props: {
gridTemplateColumns={['1fr 24px', '1fr 200px']}
gridTemplateRows="auto"
gridTemplateAreas="'header actions' 'main main'"
p={2}
p={3}
{...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
alignItems="flex-start"
gapX={2}
@ -82,7 +147,7 @@ export function NotificationWrapper(props: {
justifyContent="flex-end"
opacity={[0, hovering ? 1 : 0]}
>
{notification && (
{!('time' in lid) && (
<StatelessAsyncAction
name=""
borderRadius={1}
@ -97,46 +162,3 @@ export function NotificationWrapper(props: {
</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 React, { ReactElement, useCallback, useRef } from 'react';
import { Action, Box, Col, Icon, Row, Text } from '@tlon/indigo-react';
import React, { ReactElement, ReactNode, useEffect, useRef } from 'react';
import Helmet from 'react-helmet';
import { Link, Route, Switch } from 'react-router-dom';
import useGroupState from '~/logic/state/group';
import { Link, Route, Switch, useHistory, useLocation } from 'react-router-dom';
import useHarkState from '~/logic/state/hark';
import { Body } from '~/views/components/Body';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
import { useTutorialModal } from '~/views/components/useTutorialModal';
import Inbox from './inbox';
import airlock from '~/logic/api';
import { readAll } from '@urbit/api';
import { Archive } from './Archive';
import { NewBox } from './NewBox';
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 {
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);
useTutorialModal('notifications', true, anchorRef);
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 (
<Switch>
<Route
@ -33,7 +69,10 @@ export default function NotificationsScreen(props: any): ReactElement {
return (
<>
<Helmet defer={false}>
<title>{ notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape - Notifications</title>
<title>
{notificationsCount ? `(${String(notificationsCount)}) ` : ''}
Groups - Notifications
</title>
</Helmet>
<Body>
<Col overflowY="hidden" height="100%">
@ -46,22 +85,29 @@ export default function NotificationsScreen(props: any): ReactElement {
borderBottom={1}
borderBottomColor="lightGray"
>
<Text fontWeight="bold" fontSize={2} lineHeight={1} ref={anchorRef}>
Notifications
</Text>
<Row
justifyContent="space-between"
gapX={3}
<Text
fontWeight="bold"
fontSize={2}
lineHeight={1}
ref={anchorRef}
>
<StatelessAsyncAction
overflow="hidden"
color="black"
backgroundColor="white"
onClick={onReadAll}
>
Mark All Read
</StatelessAsyncAction>
Notifications
</Text>
<Row gapX="2">
<NavLink href="/~notifications">New</NavLink>
<NavLink href="/~notifications/archive">Archive</NavLink>
</Row>
<Row justifyContent="space-between" gapX={3}>
{ (false as boolean) ? (
<StatelessAsyncAction
overflow="hidden"
color="black"
backgroundColor="white"
onClick={onReadAll}
>
Mark All Read
</StatelessAsyncAction>
) : null}
<Link to="/~settings#notifications">
<Box>
<Icon lineHeight={1} icon="Adjust" />
@ -69,11 +115,9 @@ export default function NotificationsScreen(props: any): ReactElement {
</Link>
</Row>
</Row>
{!view && <Inbox
pendingJoin={pendingJoin}
{...props}
filter={[]}
/>}
{ view === 'archive' ? (
<Archive />
) : <NewBox /> }
</Col>
</Body>
</>

View File

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

View File

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

View File

@ -39,6 +39,7 @@ interface OmniboxResultProps {
shiftLink?: string;
shiftDescription?: string;
description?: string;
hasNotifications?: boolean;
}
interface OmniboxResultState {
@ -142,14 +143,20 @@ export class OmniboxResult extends Component<OmniboxResultProps, OmniboxResultSt
);
} else if (icon === 'notifications') {
graphic = (
<Box mr="2" height="18px" width="18px" position="relative" display="inline-block">
<Icon
display='inline-block'
verticalAlign='middle'
icon='Notifications'
mr={2}
size='18px'
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') {
graphic = (

View File

@ -2,7 +2,6 @@ import { Box } from '@tlon/indigo-react';
import React, { useCallback, useEffect } from 'react';
import { Route, Switch, useHistory, useLocation } from 'react-router-dom';
import styled from 'styled-components';
import ob from 'urbit-ob';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import useMetadataState from '~/logic/state/metadata';
import LaunchApp from '~/views/apps/launch/App';
@ -15,6 +14,7 @@ import { useShortcut } from '~/logic/state/settings';
import Landscape from '~/views/landscape/index';
import GraphApp from '../../apps/graph/App';
import { getNotificationRedirect } from '~/logic/lib/notificationRedirects';
export const Container = styled(Box)`
flex-grow: 1;
@ -23,94 +23,6 @@ export const Container = styled(Box)`
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) => {
const history = useHistory();
const location = useLocation();
@ -119,7 +31,7 @@ export const Content = (props) => {
useEffect(() => {
const query = new URLSearchParams(location.search);
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')) {
const link = decodeURIComponent(query.get('grid-link')!);
history.push(`/perma${link}`);

View File

@ -1,7 +1,7 @@
:~ title+'Groups'
info+'A suite of applications to communicate on Urbit'
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'
version+[1 3 5]
website+'https://tlon.io'

View File

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