mirror of
https://github.com/urbit/shrub.git
synced 2024-12-20 01:01:37 +03:00
Merge pull request #4782 from urbit/lf/notif-fixes
notifications: preferences screen, fix pagination
This commit is contained in:
commit
fe61b8b7a9
@ -4,6 +4,11 @@ import { dateToDa, decToUd } from '../lib/util';
|
|||||||
import { NotifIndex, IndexedNotification, Association, GraphNotifDescription } from '@urbit/api';
|
import { NotifIndex, IndexedNotification, Association, GraphNotifDescription } from '@urbit/api';
|
||||||
import { BigInteger } from 'big-integer';
|
import { BigInteger } from 'big-integer';
|
||||||
import { getParentIndex } from '../lib/notification';
|
import { getParentIndex } from '../lib/notification';
|
||||||
|
import useHarkState from '../state/hark';
|
||||||
|
|
||||||
|
function getHarkSize() {
|
||||||
|
return useHarkState.getState().notifications.size ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
export class HarkApi extends BaseApi<StoreState> {
|
export class HarkApi extends BaseApi<StoreState> {
|
||||||
private harkAction(action: any): Promise<any> {
|
private harkAction(action: any): Promise<any> {
|
||||||
@ -172,10 +177,10 @@ export class HarkApi extends BaseApi<StoreState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getMore(): Promise<boolean> {
|
async getMore(): Promise<boolean> {
|
||||||
const offset = this.store.state['notifications']?.size || 0;
|
const offset = getHarkSize();
|
||||||
const count = 3;
|
const count = 3;
|
||||||
await this.getSubset(offset, count, false);
|
await this.getSubset(offset, count, false);
|
||||||
return offset === (this.store.state.notifications?.size || 0);
|
return offset === getHarkSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSubset(offset:number, count:number, isArchive: boolean) {
|
async getSubset(offset:number, count:number, isArchive: boolean) {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import bigInt, { BigInteger } from 'big-integer';
|
import bigInt, { BigInteger } from 'big-integer';
|
||||||
import f from 'lodash/fp';
|
import f from 'lodash/fp';
|
||||||
import { Unreads } from '@urbit/api';
|
import { Unreads, NotificationGraphConfig } from '@urbit/api';
|
||||||
|
|
||||||
export function getLastSeen(
|
export function getLastSeen(
|
||||||
unreads: Unreads,
|
unreads: Unreads,
|
||||||
@ -34,3 +34,13 @@ export function getNotificationCount(
|
|||||||
.map(index => unread[index]?.notifications?.length || 0)
|
.map(index => unread[index]?.notifications?.length || 0)
|
||||||
.reduce(f.add, 0);
|
.reduce(f.add, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isWatching(
|
||||||
|
config: NotificationGraphConfig,
|
||||||
|
graph: string,
|
||||||
|
index = "/"
|
||||||
|
) {
|
||||||
|
return !!config.watching.find(
|
||||||
|
watch => watch.graph === graph && watch.index === index
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -4,3 +4,7 @@ const ua = window.navigator.userAgent;
|
|||||||
export const IS_IOS = ua.includes('iPhone');
|
export const IS_IOS = ua.includes('iPhone');
|
||||||
|
|
||||||
export const IS_SAFARI = ua.includes('Safari') && !ua.includes('Chrome');
|
export const IS_SAFARI = ua.includes('Safari') && !ua.includes('Chrome');
|
||||||
|
|
||||||
|
export const IS_ANDROID = ua.includes('Android');
|
||||||
|
|
||||||
|
export const IS_MOBILE = IS_IOS || IS_ANDROID;
|
||||||
|
@ -15,7 +15,7 @@ export interface HarkState extends BaseState<HarkState> {
|
|||||||
notifications: BigIntOrderedMap<Timebox>;
|
notifications: BigIntOrderedMap<Timebox>;
|
||||||
notificationsCount: number;
|
notificationsCount: number;
|
||||||
notificationsGraphConfig: NotificationGraphConfig; // TODO unthread this everywhere
|
notificationsGraphConfig: NotificationGraphConfig; // TODO unthread this everywhere
|
||||||
notificationsGroupConfig: []; // TODO type this
|
notificationsGroupConfig: string[];
|
||||||
unreads: Unreads;
|
unreads: Unreads;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
import _ from 'lodash';
|
||||||
import { MetadataUpdatePreview, Association, Associations } from "@urbit/api";
|
import { MetadataUpdatePreview, Association, Associations } from "@urbit/api";
|
||||||
|
|
||||||
import { BaseState, createState } from "./base";
|
import { BaseState, createState } from "./base";
|
||||||
@ -18,6 +19,11 @@ export function useAssocForGroup(group: string) {
|
|||||||
return useMetadataState(useCallback(s => s.associations.groups[group] as Association | undefined, [group]));
|
return useMetadataState(useCallback(s => s.associations.groups[group] as Association | undefined, [group]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useGraphsForGroup(group: string) {
|
||||||
|
const graphs = useMetadataState(s => s.associations.graph);
|
||||||
|
return _.pickBy(graphs, (a: Association) => a.group === group);
|
||||||
|
}
|
||||||
|
|
||||||
const useMetadataState = createState<MetadataState>('Metadata', {
|
const useMetadataState = createState<MetadataState>('Metadata', {
|
||||||
associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} },
|
associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} },
|
||||||
// preview: async (group): Promise<MetadataUpdatePreview> => {
|
// preview: async (group): Promise<MetadataUpdatePreview> => {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { ReactNode, useCallback, useMemo, useState } from "react";
|
import React, { ReactNode, useCallback, useMemo, useState } from "react";
|
||||||
import { Row, Box } from "@tlon/indigo-react";
|
import { Row, Box, Icon } from "@tlon/indigo-react";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import {
|
import {
|
||||||
GraphNotificationContents,
|
GraphNotificationContents,
|
||||||
@ -19,6 +19,7 @@ import { GraphNotification } from "./graph";
|
|||||||
import { BigInteger } from "big-integer";
|
import { BigInteger } from "big-integer";
|
||||||
import { useHovering } from "~/logic/lib/util";
|
import { useHovering } from "~/logic/lib/util";
|
||||||
import useHarkState from "~/logic/state/hark";
|
import useHarkState from "~/logic/state/hark";
|
||||||
|
import {IS_MOBILE} from "~/logic/lib/platform";
|
||||||
|
|
||||||
interface NotificationProps {
|
interface NotificationProps {
|
||||||
notification: IndexedNotification;
|
notification: IndexedNotification;
|
||||||
@ -102,39 +103,30 @@ export function NotificationWrapper(props: {
|
|||||||
}
|
}
|
||||||
borderRadius={2}
|
borderRadius={2}
|
||||||
display="grid"
|
display="grid"
|
||||||
gridTemplateColumns={["1fr", "1fr 200px"]}
|
gridTemplateColumns={["1fr 24px", "1fr 200px"]}
|
||||||
gridTemplateRows="auto"
|
gridTemplateRows="auto"
|
||||||
gridTemplateAreas={["'header' 'main'", "'header actions' 'main main'"]}
|
gridTemplateAreas="'header actions' 'main main'"
|
||||||
p={2}
|
p={2}
|
||||||
m={2}
|
m={2}
|
||||||
{...bind}
|
{...bind}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<Row
|
<Row
|
||||||
display={["none", "flex"]}
|
alignItems="flex-start"
|
||||||
alignItems="center"
|
|
||||||
gapX="2"
|
gapX="2"
|
||||||
gridArea="actions"
|
gridArea="actions"
|
||||||
justifyContent="flex-end"
|
justifyContent="flex-end"
|
||||||
opacity={[1, hovering ? 1 : 0]}
|
opacity={[1, (hovering || IS_MOBILE) ? 1 : 0]}
|
||||||
>
|
>
|
||||||
{time && notification && (
|
{time && notification && (
|
||||||
<>
|
|
||||||
<StatelessAsyncAction
|
|
||||||
name={changeMuteDesc}
|
|
||||||
onClick={onChangeMute}
|
|
||||||
backgroundColor="transparent"
|
|
||||||
>
|
|
||||||
{changeMuteDesc}
|
|
||||||
</StatelessAsyncAction>
|
|
||||||
<StatelessAsyncAction
|
<StatelessAsyncAction
|
||||||
name={time.toString()}
|
name={time.toString()}
|
||||||
|
borderRadius={1}
|
||||||
onClick={onArchive}
|
onClick={onArchive}
|
||||||
backgroundColor="transparent"
|
backgroundColor="white"
|
||||||
>
|
>
|
||||||
Dismiss
|
<Icon lineHeight="24px" size={16} icon="X" />
|
||||||
</StatelessAsyncAction>
|
</StatelessAsyncAction>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Row>
|
</Row>
|
||||||
</Box>
|
</Box>
|
||||||
|
@ -3,7 +3,7 @@ import _ from 'lodash';
|
|||||||
import { Link, Switch, Route } from 'react-router-dom';
|
import { Link, Switch, Route } from 'react-router-dom';
|
||||||
import Helmet from 'react-helmet';
|
import Helmet from 'react-helmet';
|
||||||
|
|
||||||
import { Box, Col, Text, Row } from '@tlon/indigo-react';
|
import { Box, Icon, Col, Text, Row } from '@tlon/indigo-react';
|
||||||
|
|
||||||
import { Body } from '~/views/components/Body';
|
import { Body } from '~/views/components/Body';
|
||||||
import { PropFunc } from '~/types/util';
|
import { PropFunc } from '~/types/util';
|
||||||
@ -15,6 +15,7 @@ import { useTutorialModal } from '~/views/components/useTutorialModal';
|
|||||||
import useHarkState from '~/logic/state/hark';
|
import useHarkState from '~/logic/state/hark';
|
||||||
import useMetadataState from '~/logic/state/metadata';
|
import useMetadataState from '~/logic/state/metadata';
|
||||||
import useGroupState from '~/logic/state/group';
|
import useGroupState from '~/logic/state/group';
|
||||||
|
import {StatelessAsyncAction} from '~/views/components/StatelessAsyncAction';
|
||||||
|
|
||||||
const baseUrl = '/~notifications';
|
const baseUrl = '/~notifications';
|
||||||
|
|
||||||
@ -46,8 +47,8 @@ export default function NotificationsScreen(props: any): ReactElement {
|
|||||||
const onSubmit = async ({ groups } : NotificationFilter) => {
|
const onSubmit = async ({ groups } : NotificationFilter) => {
|
||||||
setFilter({ groups });
|
setFilter({ groups });
|
||||||
};
|
};
|
||||||
const onReadAll = useCallback(() => {
|
const onReadAll = useCallback(async () => {
|
||||||
props.api.hark.readAll();
|
await props.api.hark.readAll();
|
||||||
}, []);
|
}, []);
|
||||||
const groupFilterDesc =
|
const groupFilterDesc =
|
||||||
filter.groups.length === 0
|
filter.groups.length === 0
|
||||||
@ -81,53 +82,25 @@ export default function NotificationsScreen(props: any): ReactElement {
|
|||||||
borderBottomColor="lightGray"
|
borderBottomColor="lightGray"
|
||||||
>
|
>
|
||||||
|
|
||||||
<Text ref={anchorRef}>Notifications</Text>
|
<Text fontWeight="bold" fontSize="2" lineHeight="1" ref={anchorRef}>
|
||||||
|
Notifications
|
||||||
|
</Text>
|
||||||
<Row
|
<Row
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
|
gapX="3"
|
||||||
>
|
>
|
||||||
<Box
|
<StatelessAsyncAction
|
||||||
mr="1"
|
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
|
color="black"
|
||||||
onClick={onReadAll}
|
onClick={onReadAll}
|
||||||
cursor="pointer"
|
|
||||||
>
|
>
|
||||||
<Text mr="1" color="blue">
|
|
||||||
Mark All Read
|
Mark All Read
|
||||||
</Text>
|
</StatelessAsyncAction>
|
||||||
</Box>
|
<Link to="/~settings#notifications">
|
||||||
|
|
||||||
<Dropdown
|
|
||||||
alignX="right"
|
|
||||||
alignY="top"
|
|
||||||
options={
|
|
||||||
<Col
|
|
||||||
p="2"
|
|
||||||
backgroundColor="white"
|
|
||||||
border={1}
|
|
||||||
borderRadius={1}
|
|
||||||
borderColor="lightGray"
|
|
||||||
gapY="2"
|
|
||||||
>
|
|
||||||
<FormikOnBlur
|
|
||||||
initialValues={filter}
|
|
||||||
onSubmit={onSubmit}
|
|
||||||
>
|
|
||||||
<GroupSearch
|
|
||||||
id="groups"
|
|
||||||
label="Filter Groups"
|
|
||||||
caption="Only show notifications from this group"
|
|
||||||
/>
|
|
||||||
</FormikOnBlur>
|
|
||||||
</Col>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text mr="1" gray>
|
<Icon lineHeight="1" icon="Adjust" />
|
||||||
Filter:
|
|
||||||
</Text>
|
|
||||||
<Text>{groupFilterDesc}</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Dropdown>
|
</Link>
|
||||||
</Row>
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
{!view && <Inbox
|
{!view && <Inbox
|
||||||
|
@ -0,0 +1,135 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
Icon,
|
||||||
|
ManagedToggleSwitchField,
|
||||||
|
StatelessToggleSwitchField,
|
||||||
|
Col,
|
||||||
|
Center,
|
||||||
|
} from "@tlon/indigo-react";
|
||||||
|
import _ from "lodash";
|
||||||
|
|
||||||
|
import useMetadataState, { useGraphsForGroup } from "~/logic/state/metadata";
|
||||||
|
import { Association, resourceFromPath } from "@urbit/api";
|
||||||
|
import { MetadataIcon } from "~/views/landscape/components/MetadataIcon";
|
||||||
|
import useGraphState from "~/logic/state/graph";
|
||||||
|
import { useField } from "formik";
|
||||||
|
import useHarkState from "~/logic/state/hark";
|
||||||
|
import { getModuleIcon } from "~/logic/lib/util";
|
||||||
|
import {isWatching} from "~/logic/lib/hark";
|
||||||
|
|
||||||
|
export function GroupChannelPicker(props: {}) {
|
||||||
|
const associations = useMetadataState((s) => s.associations);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Col gapY="3">
|
||||||
|
{_.map(associations.groups, (assoc: Association, group: string) => (
|
||||||
|
<GroupWithChannels key={group} association={assoc} />
|
||||||
|
))}
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupWithChannels(props: { association: Association }) {
|
||||||
|
const { association } = props;
|
||||||
|
const { metadata } = association;
|
||||||
|
|
||||||
|
const groupWatched = useHarkState((s) =>
|
||||||
|
s.notificationsGroupConfig.includes(association.group)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [{ value }, meta, { setValue }] = useField(
|
||||||
|
`groups["${association.group}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const onChange = () => {
|
||||||
|
setValue(!value);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(groupWatched);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const graphs = useGraphsForGroup(association.group);
|
||||||
|
const joinedGraphs = useGraphState((s) => s.graphKeys);
|
||||||
|
const joinedGroupGraphs = _.pickBy(graphs, (_, graph: string) => {
|
||||||
|
const { ship, name } = resourceFromPath(graph);
|
||||||
|
return joinedGraphs.has(`${ship.slice(1)}/${name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
display="grid"
|
||||||
|
gridTemplateColumns="24px 24px 1fr 24px 24px"
|
||||||
|
gridTemplateRows="auto"
|
||||||
|
gridGap="2"
|
||||||
|
gridTemplateAreas="'arrow icon title graphToggle groupToggle'"
|
||||||
|
>
|
||||||
|
{Object.keys(joinedGroupGraphs).length > 0 && (
|
||||||
|
<Center
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
gridArea="arrow"
|
||||||
|
>
|
||||||
|
<Icon icon={open ? "ChevronSouth" : "ChevronEast"} />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
<MetadataIcon
|
||||||
|
size="24px"
|
||||||
|
gridArea="icon"
|
||||||
|
metadata={association.metadata}
|
||||||
|
/>
|
||||||
|
<Box gridArea="title">
|
||||||
|
<Text>{metadata.title}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box gridArea="groupToggle">
|
||||||
|
<StatelessToggleSwitchField selected={value} onChange={onChange} />
|
||||||
|
</Box>
|
||||||
|
{open &&
|
||||||
|
_.map(joinedGroupGraphs, (a: Association, graph: string) => (
|
||||||
|
<Channel key={graph} association={a} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Channel(props: { association: Association }) {
|
||||||
|
const { association } = props;
|
||||||
|
const { metadata } = association;
|
||||||
|
const watching = useHarkState((s) => {
|
||||||
|
const config = s.notificationsGraphConfig;
|
||||||
|
return isWatching(config, association.resource);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [{ value }, meta, { setValue }] = useField(
|
||||||
|
`graph["${association.resource}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(watching);
|
||||||
|
}, [watching]);
|
||||||
|
|
||||||
|
const onChange = () => {
|
||||||
|
setValue(!value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const icon = getModuleIcon(metadata.config?.graph);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Center gridColumn="2">
|
||||||
|
<Icon icon={icon} />
|
||||||
|
</Center>
|
||||||
|
<Box gridColumn="3">
|
||||||
|
<Text> {metadata.title}</Text>
|
||||||
|
</Box>
|
||||||
|
<Box gridColumn="4">
|
||||||
|
<StatelessToggleSwitchField selected={value} onChange={onChange} />
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -10,11 +10,19 @@ import GlobalApi from "~/logic/api/global";
|
|||||||
import useHarkState from "~/logic/state/hark";
|
import useHarkState from "~/logic/state/hark";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import {AsyncButton} from "~/views/components/AsyncButton";
|
import {AsyncButton} from "~/views/components/AsyncButton";
|
||||||
|
import {GroupChannelPicker} from "./GroupChannelPicker";
|
||||||
|
import {isWatching} from "~/logic/lib/hark";
|
||||||
|
|
||||||
interface FormSchema {
|
interface FormSchema {
|
||||||
mentions: boolean;
|
mentions: boolean;
|
||||||
dnd: boolean;
|
dnd: boolean;
|
||||||
watchOnSelf: boolean;
|
watchOnSelf: boolean;
|
||||||
|
graph: {
|
||||||
|
[rid: string]: boolean;
|
||||||
|
};
|
||||||
|
groups: {
|
||||||
|
[rid: string]: boolean;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationPreferences(props: {
|
export function NotificationPreferences(props: {
|
||||||
@ -23,6 +31,7 @@ export function NotificationPreferences(props: {
|
|||||||
const { api } = props;
|
const { api } = props;
|
||||||
const dnd = useHarkState(state => state.doNotDisturb);
|
const dnd = useHarkState(state => state.doNotDisturb);
|
||||||
const graphConfig = useHarkState(state => state.notificationsGraphConfig);
|
const graphConfig = useHarkState(state => state.notificationsGraphConfig);
|
||||||
|
const groupConfig = useHarkState(s => s.notificationsGroupConfig);
|
||||||
const initialValues = {
|
const initialValues = {
|
||||||
mentions: graphConfig.mentions,
|
mentions: graphConfig.mentions,
|
||||||
dnd: dnd,
|
dnd: dnd,
|
||||||
@ -41,6 +50,16 @@ export function NotificationPreferences(props: {
|
|||||||
if (values.dnd !== dnd && !_.isUndefined(values.dnd)) {
|
if (values.dnd !== dnd && !_.isUndefined(values.dnd)) {
|
||||||
promises.push(api.hark.setDoNotDisturb(values.dnd))
|
promises.push(api.hark.setDoNotDisturb(values.dnd))
|
||||||
}
|
}
|
||||||
|
_.forEach(values.graph, (listen: boolean, graph: string) => {
|
||||||
|
if(listen !== isWatching(graphConfig, graph)) {
|
||||||
|
promises.push(api.hark[listen ? "listenGraph" : "ignoreGraph"](graph, "/"))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_.forEach(values.groups, (listen: boolean, group: string) => {
|
||||||
|
if(listen !== groupConfig.includes(group)) {
|
||||||
|
promises.push(api.hark[listen ? "listenGroup" : "ignoreGroup"](group));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
actions.setStatus({ success: null });
|
actions.setStatus({ success: null });
|
||||||
@ -81,6 +100,15 @@ export function NotificationPreferences(props: {
|
|||||||
id="mentions"
|
id="mentions"
|
||||||
caption="Notify me if someone mentions my @p in a channel I've joined"
|
caption="Notify me if someone mentions my @p in a channel I've joined"
|
||||||
/>
|
/>
|
||||||
|
<Col gapY="3">
|
||||||
|
<Text lineHeight="tall">
|
||||||
|
Activity
|
||||||
|
</Text>
|
||||||
|
<Text gray>
|
||||||
|
Set which groups will send you notifications.
|
||||||
|
</Text>
|
||||||
|
<GroupChannelPicker />
|
||||||
|
</Col>
|
||||||
<AsyncButton primary width="fit-content">
|
<AsyncButton primary width="fit-content">
|
||||||
Save
|
Save
|
||||||
</AsyncButton>
|
</AsyncButton>
|
||||||
|
Loading…
Reference in New Issue
Block a user