mirror of
https://github.com/filecoin-project/slate.git
synced 2024-11-30 02:12:38 +03:00
514 lines
14 KiB
JavaScript
514 lines
14 KiB
JavaScript
import * as React from "react";
|
|
import * as Logging from "~/common/logging";
|
|
import * as Actions from "~/common/actions";
|
|
import * as Events from "~/common/custom-events";
|
|
import * as Constants from "~/common/constants";
|
|
|
|
import { v4 as uuid } from "uuid";
|
|
import { last } from "lodash";
|
|
|
|
export const useMounted = (callback, depedencies) => {
|
|
const mountedRef = React.useRef(false);
|
|
useIsomorphicLayoutEffect(() => {
|
|
if (mountedRef.current && callback) {
|
|
callback();
|
|
}
|
|
mountedRef.current = true;
|
|
}, depedencies);
|
|
};
|
|
/** NOTE(amine):
|
|
* useForm handles three main responsibilities
|
|
* - control inputs
|
|
* - control form
|
|
* - add validations
|
|
*
|
|
* For validations
|
|
* - Validate each field when onBlur event is triggered
|
|
* - Validate all fields before submit
|
|
* - font submit if there is errors
|
|
*/
|
|
|
|
export const useForm = ({
|
|
onSubmit,
|
|
validate,
|
|
initialValues,
|
|
validateOnBlur = true,
|
|
validateOnSubmit = true,
|
|
}) => {
|
|
const [internal, setInternal] = React.useState({ isSubmitting: false, isValidating: false });
|
|
const [state, setState] = React.useState({
|
|
isSubmitting: false,
|
|
values: initialValues,
|
|
errors: {},
|
|
touched: {},
|
|
});
|
|
|
|
const _hasError = (obj) => Object.keys(obj).some((name) => obj[name]);
|
|
const _mergeEventHandlers =
|
|
(events = []) =>
|
|
(e) =>
|
|
events.forEach((event) => {
|
|
if (event) event(e);
|
|
});
|
|
|
|
/** ---------- NOTE(amine): Input Handlers ---------- */
|
|
const createOnChangeHandler = (type) => (e) => {
|
|
const prevValue = state.values[e.target.name];
|
|
const fieldError = { [e.target.name]: undefined };
|
|
const fieldTouched = { [e.target.name]: false };
|
|
|
|
if (type === "checkbox" && Array.isArray(prevValue)) {
|
|
const targetValue = e.target.value;
|
|
const newValue = prevValue.some((value) => value === targetValue)
|
|
? prevValue.filter((value) => value !== targetValue)
|
|
: [...prevValue, targetValue];
|
|
|
|
setState((prev) => ({
|
|
...prev,
|
|
values: { ...prev.values, [e.target.name]: newValue },
|
|
errors: { ...prev.errors, ...fieldError },
|
|
touched: { ...prev.touched, ...fieldTouched },
|
|
}));
|
|
return;
|
|
}
|
|
|
|
setState((prev) => ({
|
|
...prev,
|
|
values: { ...prev.values, [e.target.name]: e.target.value },
|
|
errors: { ...prev.errors, ...fieldError },
|
|
touched: { ...prev.touched, ...fieldTouched },
|
|
}));
|
|
};
|
|
|
|
const handleOnBlur = async (e) => {
|
|
// NOTE(amine): validate the inputs onBlur and touch the current input
|
|
let errors = {};
|
|
if (validateOnBlur && validate) {
|
|
try {
|
|
setInternal((prev) => ({ ...prev, isValidating: true }));
|
|
errors = await validate(state.values, {});
|
|
} catch (e) {
|
|
Logging.error(e);
|
|
} finally {
|
|
setInternal((prev) => ({ ...prev, isValidating: false }));
|
|
setState((prev) => ({
|
|
...prev,
|
|
touched: { ...prev.touched, [e.target.name]: validateOnBlur },
|
|
errors,
|
|
}));
|
|
}
|
|
}
|
|
};
|
|
|
|
// Note(Amine): this prop getter will capture the field state
|
|
const getFieldProps = (name, { type = "text", onChange, onBlur, error } = {}) => ({
|
|
name: name,
|
|
value: state.values[name],
|
|
error: error || state.errors[name],
|
|
touched: state?.touched?.[name],
|
|
onChange: _mergeEventHandlers([onChange, createOnChangeHandler(type)]),
|
|
onBlur: _mergeEventHandlers([onBlur, handleOnBlur]),
|
|
});
|
|
|
|
/** ---------- NOTE(amine): Form Handlers ---------- */
|
|
|
|
const submitAsync = async () => {
|
|
// NOTE(amine): Don't submit if the form is validating or already submitting
|
|
if (internal.isSubmitting || internal.isValidating) return;
|
|
|
|
//NOTE(amine): touch all inputs
|
|
setState((prev) => {
|
|
const touched = Object.keys(prev.values).reduce((acc, key) => ({ ...acc, [key]: true }), {});
|
|
return { ...prev, touched };
|
|
});
|
|
|
|
// NOTE(amine): validate inputs
|
|
if (validateOnSubmit && validate) {
|
|
let errors = {};
|
|
try {
|
|
setInternal((prev) => ({ ...prev, isValidating: true }));
|
|
errors = await validate(state.values, {});
|
|
if (_hasError(errors)) return;
|
|
} catch (e) {
|
|
Logging.error(e);
|
|
} finally {
|
|
setInternal((prev) => ({ ...prev, isValidating: false }));
|
|
setState((prev) => ({ ...prev, errors }));
|
|
}
|
|
}
|
|
|
|
// NOTE(amine): submit the form
|
|
if (!onSubmit) return;
|
|
setInternal((prev) => ({ ...prev, isSubmitting: true }));
|
|
try {
|
|
await onSubmit(state.values);
|
|
} catch (e) {
|
|
Logging.error(e);
|
|
} finally {
|
|
setInternal((prev) => ({ ...prev, isSubmitting: false }));
|
|
}
|
|
};
|
|
|
|
const handleFormOnSubmit = (e) => {
|
|
e.preventDefault();
|
|
submitAsync()
|
|
.then()
|
|
.catch((e) => Logging.error(e));
|
|
};
|
|
|
|
// Note(Amine): this prop getter will override the form onSubmit handler
|
|
const getFormProps = () => ({
|
|
onSubmit: handleFormOnSubmit,
|
|
});
|
|
|
|
return {
|
|
getFieldProps,
|
|
getFormProps,
|
|
submitForm: submitAsync,
|
|
values: state.values,
|
|
isSubmitting: internal.isSubmitting,
|
|
isValidating: internal.isValidating,
|
|
};
|
|
};
|
|
|
|
/** NOTE(amine): Since we can use on our design system an input onSubmit,
|
|
* useField is a special case of useForm
|
|
*/
|
|
export const useField = ({
|
|
onSubmit,
|
|
validate,
|
|
initialValue,
|
|
onChange,
|
|
onBlur,
|
|
validateOnBlur = true,
|
|
validateOnSubmit = true,
|
|
}) => {
|
|
const [state, setState] = React.useState({
|
|
isSubmitting: false,
|
|
value: initialValue,
|
|
error: undefined,
|
|
touched: undefined,
|
|
});
|
|
|
|
const _mergeEventHandlers =
|
|
(events = []) =>
|
|
(e) =>
|
|
events.forEach((event) => {
|
|
if (event) event(e);
|
|
});
|
|
|
|
const setFieldValue = (value) =>
|
|
setState((prev) => ({
|
|
...prev,
|
|
value,
|
|
error: undefined,
|
|
touched: false,
|
|
}));
|
|
|
|
/** ---------- NOTE(amine): Input Handlers ---------- */
|
|
const handleFieldChange = (e) => setFieldValue(e.target.value);
|
|
const handleOnBlur = () => {
|
|
// NOTE(amine): validate the inputs onBlur and touch the current input
|
|
let error = {};
|
|
if (validateOnBlur && validate) error = validate(state.value);
|
|
setState((prev) => ({ ...prev, touched: validateOnBlur, error }));
|
|
};
|
|
|
|
const handleFormOnSubmit = () => {
|
|
//NOTE(amine): touch all inputs
|
|
setState((prev) => ({ ...prev, touched: true }));
|
|
|
|
// NOTE(amine): validate inputs
|
|
if (validateOnSubmit && validate) {
|
|
const error = validate(state.value);
|
|
setState((prev) => ({ ...prev, error }));
|
|
if (error) return;
|
|
}
|
|
|
|
// NOTE(amine): submit the form
|
|
if (!onSubmit) return;
|
|
setState((prev) => ({ ...prev, isSubmitting: true }));
|
|
onSubmit(state.value)
|
|
?.then(() => {
|
|
setState((prev) => ({ ...prev, isSubmitting: false }));
|
|
})
|
|
?.catch(() => {
|
|
setState((prev) => ({ ...prev, isSubmitting: false }));
|
|
});
|
|
};
|
|
|
|
// Note(Amine): this prop getter will capture the field state
|
|
const getFieldProps = (name) => ({
|
|
name: name,
|
|
value: state.value,
|
|
error: state.error,
|
|
touched: state.touched,
|
|
onChange: _mergeEventHandlers([onChange, handleFieldChange]),
|
|
onBlur: _mergeEventHandlers([onBlur, handleOnBlur]),
|
|
onSubmit: handleFormOnSubmit,
|
|
});
|
|
|
|
return {
|
|
getFieldProps,
|
|
submitField: handleFormOnSubmit,
|
|
value: state.value,
|
|
setFieldValue,
|
|
isSubmitting: state.isSubmitting,
|
|
};
|
|
};
|
|
|
|
export const useIntersection = ({ onIntersect, ref }, dependencies = []) => {
|
|
// NOTE(amine): fix for stale closure caused by hooks
|
|
const onIntersectRef = React.useRef();
|
|
onIntersectRef.current = onIntersect;
|
|
|
|
useIsomorphicLayoutEffect(() => {
|
|
if (!ref.current) return;
|
|
const lazyObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
if (onIntersectRef.current) onIntersectRef.current(lazyObserver, ref);
|
|
}
|
|
});
|
|
});
|
|
// start to observe element
|
|
lazyObserver.observe(ref.current);
|
|
return () => lazyObserver.unobserve(ref.current);
|
|
}, dependencies);
|
|
};
|
|
|
|
// NOTE(amine): the intersection will be called one time
|
|
export const useInView = ({ ref }) => {
|
|
const [isInView, setInView] = React.useState(false);
|
|
useIntersection({
|
|
ref,
|
|
onIntersect: (lazyObserver, ref) => {
|
|
setInView(true);
|
|
lazyObserver.unobserve(ref.current);
|
|
},
|
|
});
|
|
return { isInView };
|
|
};
|
|
|
|
export const useFollowProfileHandler = ({ user, viewer, onAction }) => {
|
|
const [isFollowing, setFollowing] = React.useState(
|
|
!viewer
|
|
? false
|
|
: !!viewer?.following.some((entry) => {
|
|
return entry.id === user.id;
|
|
})
|
|
);
|
|
|
|
const handleFollow = async (userId) => {
|
|
if (!viewer) {
|
|
Events.dispatchCustomEvent({ name: "slate-global-open-cta", detail: {} });
|
|
return;
|
|
}
|
|
|
|
setFollowing((prev) => !prev);
|
|
const response = await Actions.createSubscription({
|
|
userId,
|
|
});
|
|
|
|
if (Events.hasError(response)) {
|
|
setFollowing((prev) => !prev);
|
|
return;
|
|
}
|
|
|
|
onAction({
|
|
type: "UPDATE_VIEWER",
|
|
viewer: {
|
|
following: isFollowing
|
|
? viewer.following.filter((user) => user.id !== userId)
|
|
: viewer.following.concat([
|
|
{
|
|
id: user.id,
|
|
followerCount: user.followerCount + 1,
|
|
slateCount: user.slateCount,
|
|
username: user.username,
|
|
},
|
|
]),
|
|
},
|
|
});
|
|
};
|
|
|
|
return { handleFollow, isFollowing };
|
|
};
|
|
|
|
// NOTE(amine): use this hook when we need to evaluate dependencies manually
|
|
export function useMemoCompare(next, compare) {
|
|
const previousRef = React.useRef();
|
|
const previous = previousRef.current;
|
|
|
|
const isEqual = compare(previous, next);
|
|
|
|
if (!isEqual) {
|
|
previousRef.current = next;
|
|
}
|
|
|
|
return isEqual ? previous : next;
|
|
}
|
|
|
|
/**
|
|
* NOTE(amine): use this hook to get rid of nextJs warnings
|
|
* source: https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a
|
|
*/
|
|
export const useIsomorphicLayoutEffect =
|
|
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
|
|
|
|
export const useMediaQuery = () => {
|
|
const isMobileQuery = `(max-width: ${Constants.sizes.mobile}px)`;
|
|
|
|
const [isMobile, setMatch] = React.useState(true);
|
|
|
|
const handleResize = () => {
|
|
const isMobile = window.matchMedia(isMobileQuery).matches;
|
|
setMatch(isMobile);
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (!window) return;
|
|
|
|
handleResize();
|
|
window.addEventListener("resize", handleResize);
|
|
return () => window.removeEventListener("resize", handleResize);
|
|
}, []);
|
|
|
|
// NOTE(amine): currently only support mobile breakpoint, we can add more breakpoints as needed.
|
|
return {
|
|
mobile: isMobile,
|
|
};
|
|
};
|
|
|
|
export const useEventListener = ({ type, handler, ref, enabled = true }, dependencies) => {
|
|
React.useEffect(() => {
|
|
if (!enabled) return;
|
|
|
|
let element = window;
|
|
if (ref) element = ref.current;
|
|
|
|
if (!element) return;
|
|
|
|
element.addEventListener(type, handler);
|
|
return () => element.removeEventListener(type, handler);
|
|
}, dependencies);
|
|
};
|
|
|
|
export const useTimeout = (callback, ms, dependencies) => {
|
|
React.useEffect(() => {
|
|
const timeoutId = setTimeout(callback, ms);
|
|
return () => clearTimeout(timeoutId);
|
|
}, dependencies);
|
|
};
|
|
|
|
let layers = [];
|
|
const removeLayer = (id) => (layers = layers.filter((layer) => layer !== id));
|
|
const isDeepestLayer = (id) => last(layers) === id;
|
|
|
|
export const useEscapeKey = (callback) => {
|
|
const layerIdRef = React.useRef();
|
|
React.useEffect(() => {
|
|
layerIdRef.current = uuid();
|
|
layers.push(layerIdRef.current);
|
|
return () => removeLayer(layerIdRef.current);
|
|
}, []);
|
|
|
|
const handleKeyUp = React.useCallback(
|
|
(e) => {
|
|
if (e.key === "Escape" && isDeepestLayer(layerIdRef.current)) callback?.(e);
|
|
},
|
|
[callback]
|
|
);
|
|
useEventListener({ type: "keyup", handler: handleKeyUp }, [handleKeyUp]);
|
|
};
|
|
|
|
export const useLockScroll = ({ lock = true } = { lock: true }) => {
|
|
React.useEffect(() => {
|
|
if (!lock) return;
|
|
document.body.style.overflow = "hidden";
|
|
return () => (document.body.style.overflow = "visible");
|
|
}, [lock]);
|
|
};
|
|
|
|
export const useHover = () => {
|
|
const [isHovered, setHoverState] = React.useState(false);
|
|
|
|
const handleOnMouseEnter = () => setHoverState(true);
|
|
const handleOnMouseLeave = () => setHoverState(false);
|
|
|
|
return [isHovered, { handleOnMouseEnter, handleOnMouseLeave }];
|
|
};
|
|
|
|
export const useImage = ({ src, maxWidth }) => {
|
|
const [imgState, setImgState] = React.useState({
|
|
loaded: false,
|
|
error: true,
|
|
overflow: false,
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
if (!src) setImgState({ error: true, loaded: true });
|
|
|
|
const img = new Image();
|
|
img.src = src;
|
|
|
|
img.onload = () => {
|
|
if (maxWidth && img.naturalWidth < maxWidth) {
|
|
setImgState((prev) => ({ ...prev, loaded: true, error: false, overflow: true }));
|
|
} else {
|
|
setImgState({ loaded: true, error: false });
|
|
}
|
|
};
|
|
img.onerror = () => setImgState({ loaded: true, error: true });
|
|
}, []);
|
|
|
|
return imgState;
|
|
};
|
|
|
|
export const useDetectTextOverflow = ({ ref }, dependencies) => {
|
|
const [isTextOverflowing, setTextOverflow] = React.useState(false);
|
|
|
|
//SOURCE(amine): https://stackoverflow.com/a/60073230
|
|
const isEllipsisActive = (el) => {
|
|
const styles = getComputedStyle(el);
|
|
const widthEl = parseFloat(styles.width);
|
|
const ctx = document.createElement("canvas").getContext("2d");
|
|
ctx.font = `${styles.fontSize} ${styles.fontFamily}`;
|
|
const text = ctx.measureText(el.innerText);
|
|
return text.width > widthEl;
|
|
};
|
|
|
|
useIsomorphicLayoutEffect(() => {
|
|
if (!ref.current) return;
|
|
|
|
setTextOverflow(isEllipsisActive(ref.current));
|
|
}, dependencies);
|
|
|
|
return isTextOverflowing;
|
|
};
|
|
|
|
let cache = {};
|
|
export const useCache = () => {
|
|
const setCache = ({ key, value }) => (cache[key] = value);
|
|
return [cache, setCache];
|
|
};
|
|
|
|
// NOTE(amine): Slate extension will notify the app that it is installed, by injecting isDownloaded class to the element with browser_extension as its id
|
|
const checkIfExtensionIsDownloaded = () => {
|
|
const extensionElement = document.getElementById("browser_extension");
|
|
if (!extensionElement) return false;
|
|
return extensionElement.className.includes("isDownloaded");
|
|
};
|
|
export const useCheckIfExtensionIsInstalled = () => {
|
|
const [isExtensionDownloaded, setExtensionDownload] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
if (document) {
|
|
const isExtensionDownloaded = checkIfExtensionIsDownloaded();
|
|
setExtensionDownload(isExtensionDownloaded);
|
|
}
|
|
}, []);
|
|
|
|
return { isExtensionDownloaded };
|
|
};
|