Merge pull request #4897 from urbit/lf/optimistic-notif

interface: optimistic updating
This commit is contained in:
matildepark 2021-05-13 09:56:48 -04:00 committed by GitHub
commit b77b83e446
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 180 additions and 129 deletions

View File

@ -6643,9 +6643,9 @@
"dev": true
},
"immer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz",
"integrity": "sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA=="
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.2.tgz",
"integrity": "sha512-mkcmzLtIfSp40vAqteRr1MbWNSoI7JE+/PB36FNPoSfJ9RQRmNKuTYCjKkyXyuq3Dgn07HuJBrwJd4ZSk2yUbw=="
},
"import-fresh": {
"version": "3.3.0",
@ -11313,6 +11313,7 @@
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
"dev": true,
"optional": true,
"requires": {
"is-extendable": "^0.1.0"
}
@ -11379,6 +11380,7 @@
"resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
"integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
"dev": true,
"optional": true,
"requires": {
"kind-of": "^3.0.2"
}
@ -12195,9 +12197,9 @@
}
},
"zustand": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-3.3.1.tgz",
"integrity": "sha512-o0rgrBsi29nCkPHdhtkAHisCIlmRUoXOV+1AmDMeCgkGG0i5edFSpGU0KiZYBvFmBYycnck4Z07JsLYDjSET9g=="
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-3.5.1.tgz",
"integrity": "sha512-7J56Ve814z4zap71iaKFD+t65LFI//jEq/Vf55BTSVqJZCm+w9rov8OMBg+YSwIPQk54bfoIWHTrOWuAbpEDMw=="
}
}
}

View File

@ -22,7 +22,7 @@
"css-loader": "^3.6.0",
"file-saver": "^2.0.5",
"formik": "^2.1.5",
"immer": "^8.0.1",
"immer": "^9.0.2",
"lodash": "^4.17.21",
"moment": "^2.29.1",
"mousetrap": "^1.6.5",
@ -56,7 +56,7 @@
"workbox-recipes": "^6.0.2",
"workbox-routing": "^6.0.2",
"yup": "^0.29.3",
"zustand": "^3.3.1"
"zustand": "^3.5.0"
},
"devDependencies": {
"@babel/core": "^7.12.10",

View File

@ -1,6 +1,9 @@
import { Patp } from '@urbit/api';
import { ContactEditField } from '@urbit/api/contacts';
import _ from 'lodash';
import {edit} from '../reducers/contact-update';
import {doOptimistically} from '../state/base';
import useContactState from '../state/contact';
import { StoreState } from '../store/type';
import BaseApi from './base';
@ -26,13 +29,14 @@ export default class ContactsApi extends BaseApi<StoreState> {
{add-group: {ship, name}}
{remove-group: {ship, name}}
*/
return this.storeAction({
const action = {
edit: {
ship,
'edit-field': editField,
timestamp: Date.now()
}
});
}
doOptimistically(useContactState, action, this.storeAction.bind(this), [edit])
}
allowShips(ships: Patp[]) {

View File

@ -1,7 +1,10 @@
import { Association, GraphNotifDescription, IndexedNotification, NotifIndex } from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import { BigInteger } from 'big-integer';
import { getParentIndex } from '../lib/notification';
import { dateToDa, decToUd } from '../lib/util';
import {reduce} from '../reducers/hark-update';
import {doOptimistically, optReduceState} from '../state/base';
import useHarkState from '../state/hark';
import { StoreState } from '../store/type';
import BaseApi from './base';
@ -51,8 +54,15 @@ export class HarkApi extends BaseApi<StoreState> {
});
}
archive(time: BigInteger, index: NotifIndex) {
return this.actOnNotification('archive', time, index);
async archive(intTime: BigInteger, index: NotifIndex) {
const time = decToUd(intTime.toString());
const action = {
archive: {
time,
index
}
};
await doOptimistically(useHarkState, action, this.harkAction.bind(this), [reduce])
}
read(time: BigInteger, index: NotifIndex) {

View File

@ -1,4 +1,4 @@
import { ContactUpdate } from '@urbit/api';
import { ContactUpdate, deSig } from '@urbit/api';
import _ from 'lodash';
import { reduceState } from '../state/base';
import useContactState, { ContactState } from '../state/contact';
@ -52,9 +52,9 @@ const remove = (json: ContactUpdate, state: ContactState): ContactState => {
return state;
};
const edit = (json: ContactUpdate, state: ContactState): ContactState => {
export const edit = (json: ContactUpdate, state: ContactState): ContactState => {
const data = _.get(json, 'edit', false);
const ship = `~${data.ship}`;
const ship = `~${deSig(data.ship)}`;
if (
data &&
(ship in state.contacts)

View File

@ -35,7 +35,7 @@ export const HarkReducer = (json: any) => {
}
};
function reduce(data, state) {
export function reduce(data, state) {
const reducers = [
calculateCount,
unread,

View File

@ -1,24 +1,39 @@
import produce, { setAutoFreeze } from 'immer';
import produce, { applyPatches, Patch, produceWithPatches, setAutoFreeze, enablePatches } from 'immer';
import { compose } from 'lodash/fp';
import create, { State, UseStore } from 'zustand';
import _ from 'lodash';
import create, { UseStore } from 'zustand';
import { persist } from 'zustand/middleware';
setAutoFreeze(false);
enablePatches();
export const stateSetter = <StateType>(
fn: (state: StateType) => void,
set
export const stateSetter = <T extends {}>(
fn: (state: Readonly<T & BaseState<T>>) => void,
set: (newState: T & BaseState<T>) => void
): void => {
set(produce(fn));
set(produce(fn) as any);
};
export const optStateSetter = <T extends {}>(
fn: (state: T & BaseState<T>) => void,
set: (newState: T & BaseState<T>) => void,
get: () => T & BaseState<T>
): string => {
const old = get();
const id = _.uniqueId()
const [state, ,patches] = produceWithPatches(old, fn) as readonly [(T & BaseState<T>), any, Patch[]];
set({ ...state, patches: { ...state.patches, [id]: patches }});
return id;
};
export const reduceState = <
StateType extends BaseState<StateType>,
UpdateType
S extends {},
U
>(
state: UseStore<StateType>,
data: UpdateType,
reducers: ((data: UpdateType, state: StateType) => StateType)[]
state: UseStore<S & BaseState<S>>,
data: U,
reducers: ((data: U, state: S & BaseState<S>) => S & BaseState<S>)[]
): void => {
const reducer = compose(reducers.map(r => sta => r(data, sta)));
state.getState().set((state) => {
@ -26,6 +41,18 @@ export const reduceState = <
});
};
export const optReduceState = <S, U>(
state: UseStore<S & BaseState<S>>,
data: U,
reducers: ((data: U, state: S & BaseState<S>) => BaseState<S> & S)[]
): string => {
const reducer = compose(reducers.map(r => sta => r(data, sta)));
return state.getState().optSet((state) => {
reducer(state);
});
};
export let stateStorageKeys: string[] = [];
export const stateStorageKey = (stateName: string) => {
@ -40,19 +67,56 @@ export const stateStorageKey = (stateName: string) => {
});
};
export interface BaseState<StateType> extends State {
set: (fn: (state: StateType) => void) => void;
export interface BaseState<StateType> {
rollback: (id: string) => void;
patches: {
[id: string]: Patch[];
};
set: (fn: (state: BaseState<StateType>) => void) => void;
addPatch: (id: string, ...patch: Patch[]) => void;
removePatch: (id: string) => void;
optSet: (fn: (state: BaseState<StateType>) => void) => string;
}
export const createState = <T extends BaseState<T>>(
export const createState = <T extends {}>(
name: string,
properties: { [K in keyof Omit<T, 'set'>]: T[K] },
blacklist: string[] = []
): UseStore<T> => create(persist((set, get) => ({
properties: T,
blacklist: (keyof BaseState<T> | keyof T)[] = []
): UseStore<T & BaseState<T>> => create<T & BaseState<T>>(persist<T & BaseState<T>>((set, get) => ({
set: fn => stateSetter(fn, set),
...properties as any
optSet: fn => {
return optStateSetter(fn, set, get);
},
patches: {},
addPatch: (id: string, ...patch: Patch[]) => {
set(({ patches }) => ({ patches: {...patches, [id]: patch }}));
},
removePatch: (id: string) => {
set(({ patches }) => ({ patches: _.omit(patches, id)}));
},
rollback: (id: string) => {
set(state => {
const applying = state.patches[id]
return {...applyPatches(state, applying), patches: _.omit(state.patches, id) }
});
},
...properties
}), {
blacklist,
name: stateStorageKey(name),
version: process.env.LANDSCAPE_SHORTHASH as any
}));
export async function doOptimistically<A, S extends {}>(state: UseStore<S & BaseState<S>>, action: A, call: (a: A) => Promise<any>, reduce: ((a: A, fn: S & BaseState<S>) => S & BaseState<S>)[]) {
let num: string | undefined = undefined;
try {
num = optReduceState(state, action, reduce);
await call(action);
state.getState().removePatch(num)
} catch (e) {
console.error(e);
if(num) {
state.getState().rollback(num);
}
}
}

View File

@ -1,11 +1,11 @@
import { NotificationGraphConfig, Timebox, Unreads } from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
// import { harkGraphHookReducer, harkGroupHookReducer, harkReducer } from "~/logic/subscription/hark";
import { BaseState, createState } from './base';
import { createState } from './base';
export const HARK_FETCH_MORE_COUNT = 3;
export interface HarkState extends BaseState<HarkState> {
export interface HarkState {
archivedNotifications: BigIntOrderedMap<Timebox>;
doNotDisturb: boolean;
// getMore: () => Promise<boolean>;

View File

@ -90,11 +90,12 @@ export function EditProfile(props: any): ReactElement {
const onSubmit = async (values: any, actions: any) => {
try {
await Object.keys(values).reduce((acc, key) => {
Object.keys(values).forEach((key) => {
const newValue = key !== 'color' ? values[key] : uxToHex(values[key]);
if (newValue !== contact[key]) {
if (key === 'isPublic') {
return acc.then(() => api.contacts.setPublic(newValue));
api.contacts.setPublic(newValue)
return;
} else if (key === 'groups') {
const toRemove: string[] = _.difference(
contact?.groups || [],
@ -104,24 +105,18 @@ export function EditProfile(props: any): ReactElement {
newValue,
contact?.groups || []
);
const promises: Promise<any>[] = [];
promises.concat(
toRemove.map(e =>
toRemove.forEach(e =>
api.contacts.edit(ship, { 'remove-group': resourceFromPath(e) })
)
);
promises.concat(
toAdd.map(e =>
toAdd.forEach(e =>
api.contacts.edit(ship, { 'add-group': resourceFromPath(e) })
)
);
return acc.then(() => Promise.all(promises));
} else if (key !== 'last-updated' && key !== 'isPublic') {
return acc.then(() => api.contacts.edit(ship, { [key]: newValue }));
api.contacts.edit(ship, { [key]: newValue });
return;
}
}
return acc;
}, Promise.resolve());
});
// actions.setStatus({ success: null });
history.push(`/~profile/${ship}`);
} catch (e) {

View File

@ -3,6 +3,7 @@ import {
ManagedRadioButtonField as Radio, Row, Text
} from '@tlon/indigo-react';
import {useField} from 'formik';
import React, { ReactElement } from 'react';
import GlobalApi from '~/logic/api/global';
import { ColorInput } from '~/views/components/ColorInput';
@ -10,11 +11,7 @@ import { ImageInput } from '~/views/components/ImageInput';
export type BgType = 'none' | 'url' | 'color';
export function BackgroundPicker({
bgType,
bgUrl,
api
}: {
export function BackgroundPicker({ api }: {
bgType: BgType;
bgUrl?: string;
api: GlobalApi;
@ -40,7 +37,6 @@ export function BackgroundPicker({
id="bgUrl"
placeholder="Drop or upload a file, or paste a link here"
name="bgUrl"
url={bgUrl || ''}
/>
</Col>
</Row>

View File

@ -6,9 +6,11 @@ import {
import { Form, Formik, FormikHelpers } from 'formik';
import React, { useCallback } from 'react';
import GlobalApi from '~/logic/api/global';
import useSettingsState, { selectSettingsState } from '~/logic/state/settings';
import useSettingsState, { selectSettingsState, SettingsState } from '~/logic/state/settings';
import { AsyncButton } from '~/views/components/AsyncButton';
import { BackButton } from './BackButton';
import _ from 'lodash';
import {FormikOnBlur} from '~/views/components/FormikOnBlur';
interface FormSchema {
hideAvatars: boolean;
@ -22,57 +24,39 @@ interface FormSchema {
videoShown: boolean;
}
const settingsSel = selectSettingsState(['calm', 'remoteContentPolicy']);
const settingsSel = (s: SettingsState): FormSchema => ({
hideAvatars: s.calm.hideAvatars,
hideNicknames: s.calm.hideAvatars,
hideUnreads: s.calm.hideUnreads,
hideGroups: s.calm.hideGroups,
hideUtilities: s.calm.hideUtilities,
imageShown: !s.remoteContentPolicy.imageShown,
videoShown: !s.remoteContentPolicy.videoShown,
oembedShown: !s.remoteContentPolicy.oembedShown,
audioShown: !s.remoteContentPolicy.audioShown
});
export function CalmPrefs(props: {
api: GlobalApi;
}) {
const { api } = props;
const {
calm: {
hideAvatars,
hideNicknames,
hideUnreads,
hideGroups,
hideUtilities
},
remoteContentPolicy: {
imageShown,
videoShown,
oembedShown,
audioShown
}
} = useSettingsState(settingsSel);
const initialValues: FormSchema = {
hideAvatars,
hideNicknames,
hideUnreads,
hideGroups,
hideUtilities,
imageShown: !imageShown,
videoShown: !videoShown,
oembedShown: !oembedShown,
audioShown: !audioShown
};
const initialValues = useSettingsState(settingsSel);
const onSubmit = useCallback(async (v: FormSchema, actions: FormikHelpers<FormSchema>) => {
await Promise.all([
api.settings.putEntry('calm', 'hideAvatars', v.hideAvatars),
api.settings.putEntry('calm', 'hideNicknames', v.hideNicknames),
api.settings.putEntry('calm', 'hideUnreads', v.hideUnreads),
api.settings.putEntry('calm', 'hideGroups', v.hideGroups),
api.settings.putEntry('calm', 'hideUtilities', v.hideUtilities),
api.settings.putEntry('remoteContentPolicy', 'imageShown', !v.imageShown),
api.settings.putEntry('remoteContentPolicy', 'videoShown', !v.videoShown),
api.settings.putEntry('remoteContentPolicy', 'audioShown', !v.audioShown),
api.settings.putEntry('remoteContentPolicy', 'oembedShown', !v.oembedShown)
]);
let promises: Promise<any>[] = [];
_.forEach(v, (bool, key) => {
const bucket = ['imageShown', 'videoShown', 'audioShown', 'oembedShown'].includes(key) ? 'remoteContentPolicy' : 'calm';
if(initialValues[key] !== bool) {
promises.push(api.settings.putEntry(bucket, key, bool));
}
})
await Promise.all(promises);
actions.setStatus({ success: null });
}, [api]);
return (
<Formik initialValues={initialValues} onSubmit={onSubmit}>
<FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
<Form>
<BackButton />
<Col borderBottom={1} borderBottomColor="washedGray" p={5} pt={4} gapY={5}>
@ -132,12 +116,8 @@ export function CalmPrefs(props: {
id="oembedShown"
caption="Embedded content may contain scripts that can track you"
/>
<AsyncButton primary width="fit-content" type="submit">
Save
</AsyncButton>
</Col>
</Form>
</Formik>
</FormikOnBlur>
);
}

View File

@ -11,6 +11,7 @@ import GlobalApi from '~/logic/api/global';
import { uxToHex } from '~/logic/lib/util';
import useSettingsState, { selectSettingsState } from '~/logic/state/settings';
import { AsyncButton } from '~/views/components/AsyncButton';
import {FormikOnBlur} from '~/views/components/FormikOnBlur';
import { BackButton } from './BackButton';
import { BackgroundPicker, BgType } from './BackgroundPicker';
@ -58,7 +59,7 @@ export default function DisplayForm(props: DisplayFormProps) {
const bgType = backgroundType || 'none';
return (
<Formik
<FormikOnBlur
validationSchema={formSchema}
initialValues={
{
@ -86,7 +87,6 @@ export default function DisplayForm(props: DisplayFormProps) {
actions.setStatus({ success: null });
}}
>
{props => (
<Form>
<BackButton />
<Col p={5} pt={4} gapY={5}>
@ -98,10 +98,7 @@ export default function DisplayForm(props: DisplayFormProps) {
Customize visual interfaces across your Landscape
</Text>
</Col>
<BackgroundPicker
bgType={props.values.bgType}
bgUrl={props.values.bgUrl}
api={api}
<BackgroundPicker api={api}
/>
<Label>Theme</Label>
<Radio name="theme" id="light" label="Light" />
@ -112,7 +109,6 @@ export default function DisplayForm(props: DisplayFormProps) {
</AsyncButton>
</Col>
</Form>
)}
</Formik>
</FormikOnBlur>
);
}

View File

@ -3,7 +3,8 @@ import {
Center, Col, Icon,
StatelessToggleSwitchField, Text
ToggleSwitch, Text,
StatelessToggleSwitchField
} from '@tlon/indigo-react';
import { Association, GraphConfig, resourceFromPath } from '@urbit/api';
import { useField } from 'formik';
@ -100,7 +101,7 @@ function Channel(props: { association: Association }) {
return isWatching(config, association.resource);
});
const [{ value }, meta, { setValue }] = useField(
const [{ value }, meta, { setValue, setTouched }] = useField(
`graph["${association.resource}"]`
);
@ -108,9 +109,11 @@ function Channel(props: { association: Association }) {
setValue(watching);
}, [watching]);
const onChange = () => {
const onClick = () => {
setValue(!value);
};
setTouched(true);
}
const icon = getModuleIcon((metadata.config as GraphConfig)?.graph as GraphModule);
@ -123,7 +126,7 @@ function Channel(props: { association: Association }) {
<Text> {metadata.title}</Text>
</Box>
<Box gridColumn={4}>
<StatelessToggleSwitchField selected={value} onChange={onChange} />
<StatelessToggleSwitchField selected={value} onClick={onClick} />
</Box>
</>
);

View File

@ -50,7 +50,6 @@ export function LeapSettings(props: { api: GlobalApi; }) {
const { leap, set: setSettingsState } = useSettingsState(settingsSel);
const categories = leap.categories as LeapCategories[];
const missing = _.difference(leapCategories, categories);
console.log(categories);
const initialValues = {
categories: [

View File

@ -10,6 +10,7 @@ import GlobalApi from '~/logic/api/global';
import { isWatching } from '~/logic/lib/hark';
import useHarkState from '~/logic/state/hark';
import { AsyncButton } from '~/views/components/AsyncButton';
import {FormikOnBlur} from '~/views/components/FormikOnBlur';
import { BackButton } from './BackButton';
import { GroupChannelPicker } from './GroupChannelPicker';
@ -82,7 +83,7 @@ export function NotificationPreferences(props: {
messaging
</Text>
</Col>
<Formik initialValues={initialValues} onSubmit={onSubmit}>
<FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
<Form>
<Col gapY={4}>
<Toggle
@ -109,12 +110,9 @@ export function NotificationPreferences(props: {
</Text>
<GroupChannelPicker />
</Col>
<AsyncButton primary width="fit-content">
Save
</AsyncButton>
</Col>
</Form>
</Formik>
</FormikOnBlur>
</Col>
</>
);

View File

@ -1,28 +1,32 @@
import { FormikConfig, FormikProvider, FormikValues, useFormik } from 'formik';
import React, { useEffect, useImperativeHandle } from 'react';
import React, { useEffect, useImperativeHandle, useState } from 'react';
export function FormikOnBlur<
Values extends FormikValues = FormikValues,
ExtraProps = {}
>(props: FormikConfig<Values> & ExtraProps) {
const formikBag = useFormik<Values>({ ...props, validateOnBlur: true });
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (
Object.keys(formikBag.errors || {}).length === 0 &&
Object.keys(formikBag.touched || {}).length !== 0 &&
!formikBag.isSubmitting
formikBag.dirty &&
!formikBag.isSubmitting &&
!submitting
) {
setSubmitting(true);
const { values } = formikBag;
formikBag.submitForm().then(() => {
formikBag.resetForm({ values, touched: {} });
formikBag.resetForm({ values })
setSubmitting(false);
});
}
}, [
formikBag.errors,
formikBag.touched,
formikBag.submitForm,
formikBag.values
formikBag.dirty,
submitting,
formikBag.isSubmitting
]);
const { children, innerRef } = props;