mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-09-20 15:08:34 +03:00
Merge pull request #5302 from urbit/lf/landscape-notifications
landscape: revive notifications
This commit is contained in:
commit
f2e00ba091
90
pkg/interface/src/logic/lib/notificationRedirects.ts
Normal file
90
pkg/interface/src/logic/lib/notificationRedirects.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
};
|
};
|
||||||
|
@ -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);
|
||||||
|
@ -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'
|
||||||
],
|
],
|
||||||
|
31
pkg/interface/src/views/apps/notifications/Archive.tsx
Normal file
31
pkg/interface/src/views/apps/notifications/Archive.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
58
pkg/interface/src/views/apps/notifications/NewBox.tsx
Normal file
58
pkg/interface/src/views/apps/notifications/NewBox.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -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 />
|
||||||
|
@ -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>
|
||||||
|
@ -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 = (
|
||||||
|
@ -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}`);
|
||||||
|
@ -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'
|
||||||
|
@ -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];
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user