mirror of
https://github.com/urbit/shrub.git
synced 2024-12-19 16:51:42 +03:00
Merge pull request #4897 from urbit/lf/optimistic-notif
interface: optimistic updating
This commit is contained in:
commit
b77b83e446
14
pkg/interface/package-lock.json
generated
14
pkg/interface/package-lock.json
generated
@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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[]) {
|
||||
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -35,7 +35,7 @@ export const HarkReducer = (json: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
function reduce(data, state) {
|
||||
export function reduce(data, state) {
|
||||
const reducers = [
|
||||
calculateCount,
|
||||
unread,
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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: [
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user