slate/common/hooks.js

520 lines
15 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 };
};
export const useLocalStorage = (key) => ({
setItem: (value) => localStorage?.setItem(key, value),
getItem: () => localStorage?.getItem(key),
removeItem: () => localStorage?.removeItem(key),
});