Merge pull request #3915 from urbit/lf/hark-qa-fixes

hark: lazier notifications loading, final bugfixes
This commit is contained in:
matildepark 2020-11-11 22:27:04 -05:00 committed by GitHub
commit 118e7d2c8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 198 additions and 80 deletions

View File

@ -48,7 +48,7 @@
(on-watch:def path)
:_ this
=; =cage
[%give %fact ~[/updates] cage]~
[%give %fact ~ cage]~
:- %hark-group-hook-update
!> ^- update:hook
[%initial watching]

View File

@ -15,7 +15,7 @@
=notifications:store
archive=notifications:store
last-seen=@da
dnd=?
dnd=_|
==
+$ inflated-state
$: state-0
@ -25,6 +25,7 @@
:: albeit expensively
+$ cache
$: unread-count=@ud
graph-unreads=(map resource @ud)
~
==
::
@ -71,14 +72,17 @@
^- update:store
:- %more
^- (list update:store)
:- [%graph-unreads graph-unreads]
:+ [%set-dnd dnd]
[%count unread-count]
%+ weld
%+ turn
(tap-nonempty archive)
%+ scag 5
(tap-nonempty:ha archive)
(timebox-update &)
%+ turn
(tap-nonempty notifications)
%+ scag 5
(tap-nonempty:ha notifications)
(timebox-update |)
::
++ timebox-update
@ -86,12 +90,6 @@
|= [time=@da =timebox:store]
^- update:store
[%timebox time archived ~(tap by timebox)]
::
++ tap-nonempty
|= =notifications:store
^- (list [@da timebox:store])
%+ skip (tap:orm notifications)
|=([@da =timebox:store] =(0 ~(wyt by timebox)))
--
::
++ on-peek
@ -104,11 +102,13 @@
(slav %ud i.t.t.path)
=/ length=@ud
(slav %ud i.t.t.t.path)
:^ ~ ~ %noun
:^ ~ ~ %hark-update
!> ^- update:store
:- %more
%+ turn
(scag length (slag offset (tap:orm notifications)))
%+ scag length
%+ slag offset
(tap-nonempty:ha notifications)
|= [time=@da =timebox:store]
^- update:store
:^ %timebox time %.n
@ -154,6 +154,7 @@
(~(put by timebox) index new)
:- (give:ha [/updates]~ %added last-seen index new)
%_ state
+ ?~(existing-notif (upd-unreads:ha index %.n) +.state)
notifications (put:orm notifications last-seen new-timebox)
unread-count ?~(existing-notif +(unread-count) unread-count)
==
@ -169,7 +170,7 @@
(~(del by timebox) index)
:- (give:ha [/updates]~ %archive time index)
%_ state
unread-count ?.(read.notification (dec unread-count) unread-count)
+ ?.(read.notification (upd-unreads:ha index %.y) +.state)
::
notifications
(put:orm notifications time new-timebox)
@ -186,6 +187,7 @@
^- (quip card _state)
:- (give:ha [/updates]~ %read time index)
%_ state
+ (upd-unreads:ha index %.y)
unread-count (dec unread-count)
notifications (change-read-status:ha time index %.y)
==
@ -195,6 +197,7 @@
^- (quip card _state)
:- (give:ha [/updates]~ %unread time index)
%_ state
+ (upd-unreads:ha index %.n)
unread-count +(unread-count)
notifications (change-read-status:ha time index %.n)
==
@ -231,6 +234,12 @@
|_ =bowl:gall
+* met ~(. metadata bowl)
::
++ tap-nonempty
|= =notifications:store
^- (list [@da timebox:store])
%+ skip (tap:orm notifications)
|=([@da =timebox:store] =(0 ~(wyt by timebox)))
::
++ merge-notification
|= [existing=notification:store new=notification:store]
^- notification:store
@ -292,22 +301,37 @@
^- (list card)
[%give %fact paths [%hark-update !>(update)]]~
::
++ upd-unreads
|= [=index:store read=?]
^+ +.state
=/ f=$-(@ @)
?: read
dec
|=(a=@ +(a))
=. unread-count (f unread-count)
?. ?=(%graph -.index)
+.state
=/ curr-unread=@ud
(~(gut by graph-unreads) graph.index 0)
+.state(graph-unreads (~(put by graph-unreads) graph.index (f curr-unread)))
::
++ inflate-cache
|= state-0
^- cache
:_ ~
%+ roll
^+ +.state
=/ nots=(list [p=@da =timebox:store])
(tap:orm notifications)
|= [[time=@da =timebox:store] out=@ud]
=/ unreads ~(tap by timebox)
|-
?~ unreads out
|- =* outer $
?~ nots
+.state
=/ unreads ~(tap by timebox.i.nots)
|- =* inner $
?~ unreads
outer(nots t.nots)
=* notification q.i.unreads
=* index p.i.unreads
?: read.notification
out
%_ $
unreads t.unreads
::
out +(out)
==
inner(unreads t.unreads)
=. +.state
(upd-unreads index read.notification)
inner(unreads t.unreads)
--

View File

@ -78,12 +78,22 @@
%timebox (timebox +.upd)
%set-dnd b+dnd.upd
%count (numb count.upd)
%graph-unreads (graph-unreads map.upd)
%more (more +.upd)
::
?(%archive %read %unread)
(notif-ref +.upd)
==
::
++ graph-unreads
|= =(map resource @ud)
^- json
%- pairs
%+ turn
~(tap by map)
|= [rid=resource unread=@ud]
(enjs-path:resource rid)^(numb unread)
::
++ added
|= [tim=@da idx=^index not=^notification]
^- json

View File

@ -46,5 +46,6 @@
[%added time=@da =index =notification]
[%timebox time=@da archived=? =(list [index notification])]
[%count count=@ud]
[%graph-unreads =(map resource @ud)]
==
--

View File

@ -149,10 +149,21 @@ export class HarkApi extends BaseApi<StoreState> {
});
}
getMore() {
const offset = this.store.state.notifications.size;
const count = 10;
return this.getSubset(offset,count);
}
async getSubset(offset:number, count:number) {
const data = await this.scry("hark-store", `/recent/${offset}/${count}`);
this.store.handleEvent({ data });
}
async getTimeSubset(start?: Date, end?: Date) {
const s = start ? dateToDa(start) : "-";
const e = end ? dateToDa(end) : "-";
const result = await this.scry("hark-hook", `/time-subset/${s}/${e}`);
const result = await this.scry("hark-hook", `/recent/${s}/${e}`);
this.store.handleEvent({
data: result,
});

View File

@ -23,7 +23,6 @@ export const HarkReducer = (json: any, state: HarkState) => {
}
const graphHookData = _.get(json, "hark-graph-hook-update", false);
if (graphHookData) {
console.log(graphHookData);
graphInitial(graphHookData, state);
graphIgnore(graphHookData, state);
graphListen(graphHookData, state);
@ -133,6 +132,7 @@ function graphWatchSelf(json: any, state: HarkState) {
}
function reduce(data: any, state: HarkState) {
console.log(data);
unread(data, state);
read(data, state);
archive(data, state);
@ -141,6 +141,14 @@ function reduce(data: any, state: HarkState) {
dnd(data, state);
count(data, state);
added(data, state);
graphUnreads(data, state);
}
function graphUnreads(json: any, state: HarkState) {
const data = _.get(json, 'graph-unreads');
if(data) {
state.graphUnreads = data;
}
}
function added(json: any, state: HarkState) {
@ -158,6 +166,10 @@ function added(json: any, state: HarkState) {
} else {
state.notifications.set(time, [...timebox, { index, notification }]);
state.notificationsCount++;
if('graph' in index) {
const curr = state.graphUnreads[index.graph.graph] || 0;
state.graphUnreads[index.graph.graph] = curr+1;
}
}
}
}
@ -243,6 +255,11 @@ function read(json: any, state: HarkState) {
if (data) {
const { time, index } = data;
state.notificationsCount--;
if('graph' in index) {
const curr = state.graphUnreads[index.graph.graph] || 0;
state.graphUnreads[index.graph.graph] = curr-1;
}
setRead(time, index, true, state);
}
}
@ -252,6 +269,10 @@ function unread(json: any, state: HarkState) {
if (data) {
const { time, index } = data;
state.notificationsCount++;
if('graph' in index) {
const curr = state.graphUnreads[index.graph.graph] || 0;
state.graphUnreads[index.graph.graph] = curr+1;
}
setRead(time, index, false, state);
}
}

View File

@ -106,7 +106,8 @@ export default class GlobalStore extends BaseStore<StoreState> {
mentions: false,
watching: [],
},
notificationsCount: 0
notificationsCount: 0,
graphUnreads: {}
};
}

View File

@ -65,4 +65,5 @@ export interface StoreState {
notificationsChatConfig: string[];
notificationsCount: number,
doNotDisturb: boolean;
graphUnreads: Record<string, number>;
}

View File

@ -86,7 +86,8 @@ export default class ChatMessage extends Component<ChatMessageProps> {
unreadMarkerRef,
history,
api,
highlighted
highlighted,
fontSize
} = this.props;
const renderSigil = Boolean((nextMsg && msg.author !== nextMsg.author) || !nextMsg || msg.number === 1);
@ -118,7 +119,8 @@ export default class ChatMessage extends Component<ChatMessageProps> {
history,
api,
scrollWindow,
highlighted
highlighted,
fontSize
};
const unreadContainerStyle = {
@ -131,7 +133,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
width='100%'
display='flex'
flexWrap='wrap'
pt={renderSigil ? 3 : 0}
pt={this.props.pt ? this.props.pt : renderSigil ? 3 : 0}
pr={3}
pb={isLastMessage ? 3 : 0}
ref={this.divRef}
@ -170,7 +172,7 @@ interface MessageProps {
export class MessageWithSigil extends PureComponent<MessageProps> {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
render() {
const {
msg,
@ -184,14 +186,15 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
measure,
api,
history,
scrollWindow
scrollWindow,
fontSize
} = this.props;
const datestamp = moment.unix(msg.when / 1000).format(DATESTAMP_FORMAT);
const contact = msg.author in contacts ? contacts[msg.author] : false;
const showNickname = !hideNicknames && contact && contact.nickname;
const name = showNickname ? contact.nickname : cite(msg.author);
const color = contact ? `#${uxToHex(contact.color)}` : this.isDark ? '#000000' :'#FFFFFF'
const color = contact ? `#${uxToHex(contact.color)}` : this.isDark ? '#000000' :'#FFFFFF'
const sigilClass = contact ? '' : this.isDark ? 'mix-blend-diff' : 'mix-blend-darken';
let nameSpan = null;
@ -245,7 +248,7 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
<Text flexShrink='0' gray mono className="v-mid">{timestamp}</Text>
<Text gray mono ml={2} className="v-mid child dn-s">{datestamp}</Text>
</Box>
<Box fontSize='14px'><MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} /></Box>
<Box fontSize={fontSize ? fontSize : '14px'}><MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} fontSize={fontSize} /></Box>
</Box>
</>
);
@ -261,12 +264,12 @@ export const MessageWithoutSigil = ({ timestamp, msg, remoteContentPolicy, measu
</>
);
export const MessageContent = ({ content, remoteContentPolicy, measure }) => {
export const MessageContent = ({ content, remoteContentPolicy, measure, fontSize }) => {
if ('code' in content) {
return <CodeContent content={content} />;
} else if ('url' in content) {
return (
<Text fontSize='14px' lineHeight="tall" color='black'>
<Text fontSize={fontSize ? fontSize : '14px'} lineHeight="tall" color='black'>
<RemoteContent
url={content.url}
remoteContentPolicy={remoteContentPolicy}
@ -282,13 +285,13 @@ export const MessageContent = ({ content, remoteContentPolicy, measure }) => {
);
} else if ('me' in content) {
return (
<Text fontStyle='italic' fontSize='14px' lineHeight='tall' color='black'>
<Text fontStyle='italic' fontSize={fontSize ? fontSize : '14px'} lineHeight='tall' color='black'>
{content.me}
</Text>
);
}
else if ('text' in content) {
return <TextContent content={content} />;
return <TextContent fontSize={fontSize} content={content} />;
} else {
return null;
}

View File

@ -16,7 +16,6 @@ export default class CodeContent extends Component {
p='1'
my='0'
borderRadius='1'
fontSize='14px'
overflow='auto'
maxHeight='10em'
maxWidth='100%'
@ -35,7 +34,6 @@ export default class CodeContent extends Component {
my='0'
p='1'
borderRadius='1'
fontSize='14px'
overflow='auto'
maxHeight='10em'
maxWidth='100%'

View File

@ -26,13 +26,12 @@ const DISABLED_INLINE_TOKENS = [
const renderers = {
inlineCode: ({language, value}) => {
return <Text mono fontSize='14px' backgroundColor='washedGray' style={{ whiteSpace: 'preWrap'}}>{value}</Text>
return <Text mono backgroundColor='washedGray' style={{ whiteSpace: 'preWrap'}}>{value}</Text>
},
code: ({language, value}) => {
return <Text
p='1'
className='clamp-message'
fontSize='14px'
display='block'
borderRadius='1'
mono
@ -84,7 +83,7 @@ export default class TextContent extends Component {
&& (urbitOb.isValidPatp(group[2]) // valid patp?
&& (group[0] === content.text))) { // entire message is room name?
return (
<Text fontSize='14px' color='black' lineHeight="tall">
<Text fontSize={props.fontSize ? props.fontSize : '14px'} color='black' lineHeight="tall">
<Link
className="bb b--black b--white-d mono"
to={'/~landscape/join/' + group.input}>
@ -94,7 +93,7 @@ export default class TextContent extends Component {
);
} else {
return (
<Text color='black' fontSize='14px' lineHeight="tall" style={{ overflowWrap: 'break-word' }}>
<Text color='black' fontSize={props.fontSize ? props.fontSize : '14px'} lineHeight="tall" style={{ overflowWrap: 'break-word' }}>
<MessageMarkdown source={content.text} />
</Text>
);

View File

@ -83,6 +83,8 @@ export function ChatNotification(props: {
isLastRead={false}
group={group}
contacts={groupContacts}
fontSize='0'
pt='2'
/>
</Link>
);

View File

@ -51,8 +51,8 @@ function describeNotification(description: string, plural: boolean) {
}
const GraphUrl = ({ url, title }) => (
<Box borderRadius="1" p="2" bg="washedGray">
<Anchor target="_blank" color="gray" href={url}>
<Box borderRadius="2" p="2" bg="scales.black05">
<Anchor underline={false} target="_blank" color="black" href={url}>
<Icon verticalAlign="bottom" mr="2" icon="ArrowExternal" />
{title}
</Anchor>
@ -140,7 +140,7 @@ const GraphNode = ({
return (
<Link to={nodeUrl}>
<Row gapX="2" py="2">
<Row gapX="2" pt="2">
<Col>{img}</Col>
<Col alignItems="flex-start">
<Row

View File

@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useCallback } from "react";
import f from "lodash/fp";
import _ from "lodash";
import { Icon, Col, Row, Box, Text, Anchor } from "@tlon/indigo-react";
@ -69,13 +69,20 @@ export default function Inbox(props: {
f.values
)(notifications);
const onScroll = useCallback((e) => {
let container = e.target;
if(!props.showArchive && (container.scrollHeight - container.scrollTop === container.clientHeight)) {
api.hark.getMore();
}
}, [api]);
const incomingGroups = Object.values(invites?.['contacts'] || {});
const getKeyByValue = (object, value) => {
const getKeyByValue = (object, value) => {
return Object.keys(object).find(key => object[key] === value);
};
const acceptInvite = (invite) => {
const acceptInvite = (invite) => {
const resource = {
ship: `~${invite.resource.ship}`,
name: invite.resource.name
@ -86,11 +93,9 @@ const acceptInvite = (invite) => {
};
return (
<Col overflowY="auto" flexGrow="1">
<Col onScroll={onScroll} overflowY="auto" flexGrow="1" minHeight='0' flexShrink='0'>
{incomingGroups.map((invite) => (
<Box
height='100%'
width='100%'
bg='white'
p='3'
fontSize='0'>

View File

@ -87,9 +87,9 @@ function NotificationWrapper(props: {
const changeMuteDesc = isMuted ? "Unmute" : "Mute";
return (
<Row alignItems="center" justifyContent="space-between">
<Row alignItems="top" justifyContent="space-between">
{children}
<Row gapX="2" p="2" alignItems="center">
<Row gapX="2" p="2" pt='3' alignItems="top">
<StatelessAsyncAction name={changeMuteDesc} onClick={onChangeMute} backgroundColor="transparent">
{changeMuteDesc}
</StatelessAsyncAction>

View File

@ -53,7 +53,7 @@ export default function NotificationsScreen(props: any) {
const { view } = routeProps.match.params;
return (
<Body>
<Col height="100%">
<Col height="100%" minHeight='0' overflowY='scroll'>
<Row
p="3"
alignItems="center"

View File

@ -0,0 +1,36 @@
import React, { ReactNode, useState, useEffect, useCallback } from "react";
import {
StatelessToggleSwitchField as Toggle,
LoadingSpinner,
Text
} from "@tlon/indigo-react";
import { useFormikContext } from "formik";
import { useStatelessAsyncClickable } from "~/logic/lib/useStatelessAsyncClickable";
interface AsyncToggleProps {
name?: string;
onClick: (e: React.MouseEvent) => Promise<void>;
}
export function StatelessAsyncToggle({
onClick,
name = "",
...rest
}: AsyncToggleProps & Parameters<typeof Toggle>[0]) {
const {
onClick: handleClick,
buttonState: state,
} = useStatelessAsyncClickable(onClick, name);
return state === "error" ? (
<Text mr="2">Error</Text>
) : state === "loading" ? (
<LoadingSpinner mr="2" foreground={"white"} background="gray" />
) : state === "success" ? (
<Text mr="2">Done</Text>
) : (
<Toggle onClick={handleClick} {...rest} />
);
}

View File

@ -26,7 +26,7 @@ const StatusBar = (props) => {
<StatusBarItem mr={2} onClick={() => props.api.local.setOmnibox()}>
{ !props.doNotDisturb && (props.notificationsCount > 0 || invites.length > 0) &&
(<Box display="block" right="-5px" top="-5px" position="absolute" >
(<Box display="block" right="-8px" top="-8px" position="absolute" >
<Icon color="blue" icon="Bullet" />
</Box>
)}

View File

@ -20,6 +20,7 @@ export function StatusBarItem({
color="washedGray"
bg="white"
px={2}
overflow='visible'
{...props}
>
{children}

View File

@ -9,9 +9,9 @@ import {
Col,
Label,
Button,
LoadingSpinner,
BaseLabel
} from "@tlon/indigo-react";
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
import { FormError } from "~/views/components/FormError";
import { Group, GroupPolicy } from "~/types/group-update";
import { Enc } from "~/types/noun";
import { Association } from "~/types/metadata-update";
@ -24,6 +24,7 @@ import { useHistory } from "react-router-dom";
import { uxToHex } from "~/logic/lib/util";
import { FormikOnBlur } from "~/views/components/FormikOnBlur";
import {GroupNotificationsConfig} from "~/types";
import {StatelessAsyncToggle} from "~/views/components/StatelessAsyncToggle";
function DeleteGroup(props: {
owner: boolean;
@ -42,7 +43,7 @@ function DeleteGroup(props: {
const action = props.owner ? "Delete" : "Leave";
const description = props.owner
? "Permanently delete this group. (All current members will no longer see this group.)"
: "Leave this group. You can rejoin if it is an open group, or if you are reinvited";
: "You can rejoin if it is an open group, or if you are reinvited";
return (
<Col>
@ -50,7 +51,7 @@ function DeleteGroup(props: {
<Label gray mt="2">
{description}
</Label>
<StatelessAsyncButton onClick={onDelete} mt={2} destructive>
<StatelessAsyncButton onClick={onDelete} mt={2} destructive={props.owner}>
{action} this group
</StatelessAsyncButton>
</Col>
@ -71,27 +72,27 @@ export function GroupPersonalSettings(props: {
const watching = props.notificationsGroupConfig.findIndex(g => g === groupPath) !== -1;
const initialValues: FormSchema = {
watching
};
const onSubmit = async (values: FormSchema) => {
if(values.watching === watching) {
return;
}
const func = values.watching ? 'listenGroup' : 'ignoreGroup';
const onClick = async () => {
const func = !watching ? 'listenGroup' : 'ignoreGroup';
await props.api.hark[func](groupPath);
};
const owner = (props.group?.tags?.role?.admin.has(window.ship) || false);
return (
<Col gapY="4">
<FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
<Toggle
id="watching"
label="Notify me on group activity"
caption="Send me notifications when this group changes"
/>
</FormikOnBlur>
<DeleteGroup association={props.association} owner api={props.api} />
<BaseLabel
htmlFor="asyncToggle"
display="flex"
cursor="pointer"
>
<StatelessAsyncToggle selected={watching} onClick={onClick} />
<Col>
<Label>Notify me on group activity</Label>
<Label mt="2" gray>Send me notifications when this group changes</Label>
</Col>
</BaseLabel>
<DeleteGroup association={props.association} owner={owner} api={props.api} />
</Col>
);
}

View File

@ -42,9 +42,13 @@ export function useChat(
export function useGraphModule(
graphKeys: Set<string>,
graphs: Graphs,
graphUnreads: Record<string, number>
): SidebarAppConfig {
const getStatus = useCallback(
(s: string) => {
if((graphUnreads[s] || 0) > 0) {
return 'unread';
}
const [, , host, name] = s.split("/");
const graphKey = `${host.slice(1)}/${name}`;

View File

@ -43,7 +43,7 @@ interface SkeletonProps {
export function Skeleton(props: SkeletonProps) {
const chatConfig = useChat(props.inbox, props.chatSynced);
const graphConfig = useGraphModule(props.graphKeys, props.graphs);
const graphConfig = useGraphModule(props.graphKeys, props.graphs, props.graphUnreads);
const config = useMemo(
() => ({
graph: graphConfig,