diff --git a/.eslintrc b/.eslintrc index de12661f255..84f6452c94e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -77,6 +77,8 @@ "error", "prefer-single" ], + "indent": "off", + "no-extra-parens": "off", "react/jsx-uses-react": "error", "react/jsx-uses-vars": "error", "react/jsx-no-undef": "error", @@ -87,7 +89,21 @@ "@typescript-eslint/ban-ts-ignore": "off", "@typescript-eslint/indent": [ "error", - "tab" + "tab", + { + "SwitchCase": 1 + } + ], + "@typescript-eslint/no-extra-parens": [ + "error", + "all", + { + "conditionalAssign": true, + "nestedBinaryExpressions": false, + "returnAssign": true, + "ignoreJSX": "all", + "enforceForArrowConditionals": false + } ], "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/interface-name-prefix": [ diff --git a/app/threads/client/components/hooks/useUserRoom.js b/app/threads/client/components/hooks/useUserRoom.js index 64653e1562b..b4d858e54cb 100644 --- a/app/threads/client/components/hooks/useUserRoom.js +++ b/app/threads/client/components/hooks/useUserRoom.js @@ -1,4 +1,7 @@ +import { useCallback } from 'react'; + import { useReactiveValue } from '../../../../../client/hooks/useReactiveValue'; import { Rooms } from '../../../../models/client'; -export const useUserRoom = (rid, fields) => useReactiveValue(() => Rooms.findOne({ _id: rid }, { fields }), [rid, fields]); +export const useUserRoom = (rid, fields) => + useReactiveValue(useCallback(() => Rooms.findOne({ _id: rid }, { fields }), [rid, fields])); diff --git a/app/threads/client/components/hooks/useUserSubscription.js b/app/threads/client/components/hooks/useUserSubscription.js index af65338d643..4bcf81e79a6 100644 --- a/app/threads/client/components/hooks/useUserSubscription.js +++ b/app/threads/client/components/hooks/useUserSubscription.js @@ -1,4 +1,7 @@ +import { useCallback } from 'react'; + import { useReactiveValue } from '../../../../../client/hooks/useReactiveValue'; import { Subscriptions } from '../../../../models/client'; -export const useUserSubscription = (rid, fields) => useReactiveValue(() => Subscriptions.findOne({ rid }, { fields }), [rid, fields]); +export const useUserSubscription = (rid, fields) => + useReactiveValue(useCallback(() => Subscriptions.findOne({ rid }, { fields }), [rid, fields])); diff --git a/client/admin/integrations/IntegrationsRoute.js b/client/admin/integrations/IntegrationsRoute.js index e1af75ed11e..0af00b43ca1 100644 --- a/client/admin/integrations/IntegrationsRoute.js +++ b/client/admin/integrations/IntegrationsRoute.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { useAtLeastOnePermission } from '../../contexts/AuthorizationContext'; import { useRouteParameter } from '../../contexts/RouterContext'; @@ -9,12 +9,14 @@ import EditIntegrationsPage from './edit/EditIntegrationsPage'; import OutgoingWebhookHistoryPage from './edit/OutgoingWebhookHistoryPage'; function IntegrationsRoute() { - const canViewIntegrationsPage = useAtLeastOnePermission([ - 'manage-incoming-integrations', - 'manage-outgoing-integrations', - 'manage-own-incoming-integrations', - 'manage-own-outgoing-integrations', - ]); + const canViewIntegrationsPage = useAtLeastOnePermission( + useMemo(() => [ + 'manage-incoming-integrations', + 'manage-outgoing-integrations', + 'manage-own-incoming-integrations', + 'manage-own-outgoing-integrations', + ], []), + ); const context = useRouteParameter('context'); diff --git a/client/admin/sidebar/AdminSidebar.js b/client/admin/sidebar/AdminSidebar.js index 2c4ca26dbc3..00ec2796634 100644 --- a/client/admin/sidebar/AdminSidebar.js +++ b/client/admin/sidebar/AdminSidebar.js @@ -74,7 +74,7 @@ const SidebarItemsAssembler = React.memo(({ items, currentPath }) => { }); const AdminSidebarPages = React.memo(({ currentPath }) => { - const items = useReactiveValue(() => sidebarItems.get()); + const items = useReactiveValue(useCallback(() => sidebarItems.get(), [])); return @@ -163,7 +163,13 @@ const AdminSidebarSettings = ({ currentPath }) => { export default React.memo(function AdminSidebar() { const t = useTranslation(); - const canViewSettings = useAtLeastOnePermission(['view-privileged-setting', 'edit-privileged-setting', 'manage-selected-settings']); + const canViewSettings = useAtLeastOnePermission( + useMemo(() => [ + 'view-privileged-setting', + 'edit-privileged-setting', + 'manage-selected-settings', + ], []), + ); const closeAdminFlex = useCallback(() => { if (Layout.isEmbedded()) { diff --git a/client/components/RoomForeword.js b/client/components/RoomForeword.js index ca95f1a569f..a3fa2be3867 100644 --- a/client/components/RoomForeword.js +++ b/client/components/RoomForeword.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { Avatar, Margins, Flex, Box, Tag } from '@rocket.chat/fuselage'; import { Rooms, Users } from '../../app/models/client'; @@ -11,7 +11,7 @@ const RoomForeword = ({ _id: rid }) => { const t = useTranslation(); const user = useUser(); - const room = useReactiveValue(() => Rooms.findOne({ _id: rid })); + const room = useReactiveValue(useCallback(() => Rooms.findOne({ _id: rid }), [rid])); if (room.t !== 'd') { return t('Start_of_conversation'); diff --git a/client/contexts/AuthorizationContext.js b/client/contexts/AuthorizationContext.js deleted file mode 100644 index 93485325cc4..00000000000 --- a/client/contexts/AuthorizationContext.js +++ /dev/null @@ -1,34 +0,0 @@ -import { createContext, useCallback, useContext } from 'react'; - -import { useObservableValue } from '../hooks/useObservableValue'; - -export const AuthorizationContext = createContext({ - hasPermission: () => {}, - hasAtLeastOnePermission: () => {}, - hasAllPermissions: () => {}, - hasRole: () => {}, -}); - -export const usePermission = (permission, scope) => { - const { hasPermission } = useContext(AuthorizationContext); - return useObservableValue(useCallback((listener) => - hasPermission(permission, scope, listener), [hasPermission, permission, scope])); -}; - -export const useAtLeastOnePermission = (permissions, scope) => { - const { hasAtLeastOnePermission } = useContext(AuthorizationContext); - return useObservableValue(useCallback((listener) => - hasAtLeastOnePermission(permissions, scope, listener), [hasAtLeastOnePermission, permissions, scope])); -}; - -export const useAllPermissions = (permissions, scope) => { - const { hasAllPermissions } = useContext(AuthorizationContext); - return useObservableValue(useCallback((listener) => - hasAllPermissions(permissions, scope, listener), [hasAllPermissions, permissions, scope])); -}; - -export const useRole = (role) => { - const { hasRole } = useContext(AuthorizationContext); - return useObservableValue(useCallback((listener) => - hasRole(role, listener), [hasRole, role])); -}; diff --git a/client/contexts/AuthorizationContext.ts b/client/contexts/AuthorizationContext.ts new file mode 100644 index 00000000000..e9aa28cdeb4 --- /dev/null +++ b/client/contexts/AuthorizationContext.ts @@ -0,0 +1,79 @@ +import { createContext, useContext, useMemo } from 'react'; +import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; + +export type AuthorizationContextValue = { + queryPermission( + permission: string | Mongo.ObjectID, + scope?: string | Mongo.ObjectID + ): Subscription; + queryAtLeastOnePermission( + permission: (string | Mongo.ObjectID)[], + scope?: string | Mongo.ObjectID + ): Subscription; + queryAllPermissions( + permission: (string | Mongo.ObjectID)[], + scope?: string | Mongo.ObjectID + ): Subscription; + queryRole(role: string | Mongo.ObjectID): Subscription; +}; + +export const AuthorizationContext = createContext({ + queryPermission: () => ({ + getCurrentValue: (): boolean => false, + subscribe: (): Unsubscribe => (): void => undefined, + }), + queryAtLeastOnePermission: () => ({ + getCurrentValue: (): boolean => false, + subscribe: (): Unsubscribe => (): void => undefined, + }), + queryAllPermissions: () => ({ + getCurrentValue: (): boolean => false, + subscribe: (): Unsubscribe => (): void => undefined, + }), + queryRole: () => ({ + getCurrentValue: (): boolean => false, + subscribe: (): Unsubscribe => (): void => undefined, + }), +}); + +export const usePermission = ( + permission: string | Mongo.ObjectID, + scope?: string | Mongo.ObjectID, +): boolean => { + const { queryPermission } = useContext(AuthorizationContext); + const subscription = useMemo( + () => queryPermission(permission, scope), + [queryPermission, permission, scope], + ); + return useSubscription(subscription); +}; + +export const useAtLeastOnePermission = ( + permissions: (string | Mongo.ObjectID)[], + scope?: string | Mongo.ObjectID, +): boolean => { + const { queryAtLeastOnePermission } = useContext(AuthorizationContext); + const subscription = useMemo( + () => queryAtLeastOnePermission(permissions, scope), + [queryAtLeastOnePermission, permissions, scope], + ); + return useSubscription(subscription); +}; + +export const useAllPermissions = ( + permissions: (string | Mongo.ObjectID)[], + scope?: string | Mongo.ObjectID, +): boolean => { + const { queryAllPermissions } = useContext(AuthorizationContext); + const subscription = useMemo( + () => queryAllPermissions(permissions, scope), + [queryAllPermissions, permissions, scope], + ); + return useSubscription(subscription); +}; + +export const useRole = (role: string | Mongo.ObjectID): boolean => { + const { queryRole } = useContext(AuthorizationContext); + const subscription = useMemo(() => queryRole(role), [queryRole, role]); + return useSubscription(subscription); +}; diff --git a/client/contexts/SessionContext.js b/client/contexts/SessionContext.js deleted file mode 100644 index 1ac91740e4c..00000000000 --- a/client/contexts/SessionContext.js +++ /dev/null @@ -1,18 +0,0 @@ -import { createContext, useCallback, useContext } from 'react'; - -import { useObservableValue } from '../hooks/useObservableValue'; - -export const SessionContext = createContext({ - get: () => {}, - set: () => {}, -}); - -export const useSession = (name) => { - const { get } = useContext(SessionContext); - return useObservableValue((listener) => get(name, listener)); -}; - -export const useSessionDispatch = (name) => { - const { set } = useContext(SessionContext); - return useCallback((value) => set(name, value), [set, name]); -}; diff --git a/client/contexts/SessionContext.ts b/client/contexts/SessionContext.ts new file mode 100644 index 00000000000..f80e976cfc7 --- /dev/null +++ b/client/contexts/SessionContext.ts @@ -0,0 +1,26 @@ +import { createContext, useCallback, useContext, useMemo } from 'react'; +import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; + +type SessionContextValue = { + query: (name: string) => Subscription; + dispatch: (name: string, value: unknown) => void; +}; + +export const SessionContext = createContext({ + query: () => ({ + getCurrentValue: (): undefined => undefined, + subscribe: (): Unsubscribe => (): void => undefined, + }), + dispatch: (): void => undefined, +}); + +export const useSession = (name: string): unknown => { + const { query } = useContext(SessionContext); + const subscription = useMemo(() => query(name), [query, name]); + return useSubscription(subscription); +}; + +export const useSessionDispatch = (name: string): ((name: string, value: unknown) => void) => { + const { dispatch } = useContext(SessionContext); + return useCallback((value) => dispatch(name, value), [dispatch, name]); +}; diff --git a/client/contexts/UserContext.js b/client/contexts/UserContext.js deleted file mode 100644 index 4f7777d09e5..00000000000 --- a/client/contexts/UserContext.js +++ /dev/null @@ -1,19 +0,0 @@ -import { createContext, useContext, useCallback } from 'react'; - -import { useObservableValue } from '../hooks/useObservableValue'; - -export const UserContext = createContext({ - userId: null, - user: null, - loginWithPassword: async () => {}, - getPreference: () => {}, -}); - -export const useUserId = () => useContext(UserContext).userId; -export const useUser = () => useContext(UserContext).user; -export const useLoginWithPassword = () => useContext(UserContext).loginWithPassword; -export const useUserPreference = (key, defaultValue = undefined) => { - const { getPreference } = useContext(UserContext); - return useObservableValue(useCallback((listener) => - getPreference(key, defaultValue, listener), [getPreference, key, defaultValue])); -}; diff --git a/client/contexts/UserContext.ts b/client/contexts/UserContext.ts new file mode 100644 index 00000000000..1237d70a82a --- /dev/null +++ b/client/contexts/UserContext.ts @@ -0,0 +1,34 @@ +import { createContext, useContext, useMemo } from 'react'; +import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; + +type UserContextValue = { + userId: string | null; + user: Meteor.User | null; + loginWithPassword: (user: string | object, password: string) => Promise; + queryPreference: (key: string | Mongo.ObjectID, defaultValue?: T) => Subscription; +}; + +export const UserContext = createContext({ + userId: null, + user: null, + loginWithPassword: async () => undefined, + queryPreference: () => ({ + getCurrentValue: (): undefined => undefined, + subscribe: (): Unsubscribe => (): void => undefined, + }), +}); + +export const useUserId = (): string | Mongo.ObjectID | null => + useContext(UserContext).userId; + +export const useUser = (): Meteor.User | null => + useContext(UserContext).user; + +export const useLoginWithPassword = (): ((user: string | object, password: string) => Promise) => + useContext(UserContext).loginWithPassword; + +export const useUserPreference = (key: string | Mongo.ObjectID, defaultValue?: T): T | undefined => { + const { queryPreference } = useContext(UserContext); + const subscription = useMemo(() => queryPreference(key, defaultValue), [queryPreference, key, defaultValue]); + return useSubscription(subscription); +}; diff --git a/client/hooks/useForm.js b/client/hooks/useForm.js deleted file mode 100644 index 0da0149920d..00000000000 --- a/client/hooks/useForm.js +++ /dev/null @@ -1,31 +0,0 @@ -/* eslint-disable react-hooks/rules-of-hooks */ -import { useState, useCallback } from 'react'; - -import { capitalize } from '../helpers/capitalize'; - -const getValue = (e) => (e.currentTarget ? e.currentTarget.value : e); - -export const useForm = (obj) => { - const resetCallbacks = []; - const hasUnsavedChanges = []; - // TODO: use useReducer hook as we can't assure that obj will have the same structure on each render - const ret = Object.keys(obj).sort().reduce((ret, key) => { - const value = obj[key]; - const [data, setData] = useState(value); - - ret.values = { ...ret.values, [key]: data }; - ret.handlers = { ...ret.handlers, [`handle${ capitalize(key) }`]: useCallback(typeof value !== 'boolean' ? (e) => setData(getValue(e)) : () => setData(!data), [data]) }; - hasUnsavedChanges.push(JSON.stringify(value) !== JSON.stringify(data)); - resetCallbacks.push(() => setData(value)); - - return ret; - }, {}); - - ret.reset = () => { - resetCallbacks.forEach((reset) => reset()); - }; - - ret.hasUnsavedChanges = hasUnsavedChanges.filter(Boolean).length > 0; - - return ret; -}; diff --git a/client/hooks/useForm.ts b/client/hooks/useForm.ts new file mode 100644 index 00000000000..32e14f58140 --- /dev/null +++ b/client/hooks/useForm.ts @@ -0,0 +1,179 @@ +import { useCallback, useReducer, useMemo, ChangeEvent } from 'react'; + +import { capitalize } from '../helpers/capitalize'; + +type Field = { + name: string; + currentValue: unknown; + initialValue: unknown; + changed: boolean; +}; + +type FormState = { + fields: Field[]; + values: Record; + hasUnsavedChanges: boolean; +}; + +type FormAction = { + (prevState: FormState): FormState; +}; + +type UseFormReturnType = { + values: Record; + handlers: Record void>; + hasUnsavedChanges: boolean; + commit: () => void; + reset: () => void; +}; + +const reduceForm = (state: FormState, action: FormAction): FormState => { + console.time('reduceForm'); + const newState = action(state); + console.timeEnd('reduceForm'); + return newState; +}; + +const initForm = (initialValues: Record): FormState => { + const fields = []; + + for (const [fieldName, initialValue] of Object.entries(initialValues)) { + fields.push({ + name: fieldName, + currentValue: initialValue, + initialValue, + changed: false, + }); + } + + return { + fields, + values: { ...initialValues }, + hasUnsavedChanges: false, + }; +}; + +const valueChanged = (fieldName: string, newValue: unknown): FormAction => + (state: FormState): FormState => { + let { fields } = state; + const field = fields.find(({ name }) => name === fieldName); + + if (!field || field.currentValue === newValue) { + return state; + } + + const newField = { + ...field, + currentValue: newValue, + changed: JSON.stringify(newValue) !== JSON.stringify(field.initialValue), + }; + + fields = state.fields.map((field) => { + if (field.name === fieldName) { + return newField; + } + + return field; + }); + + return { + ...state, + fields, + values: { + ...state.values, + [newField.name]: newField.currentValue, + }, + hasUnsavedChanges: newField.changed || fields.some((field) => field.changed), + }; + }; + +const formCommitted = (): FormAction => + (state: FormState): FormState => ({ + ...state, + fields: state.fields.map((field) => ({ + ...field, + initialValue: field.currentValue, + changed: false, + })), + hasUnsavedChanges: false, + }); + +const formReset = (): FormAction => + (state: FormState): FormState => ({ + ...state, + fields: state.fields.map((field) => ({ + ...field, + currentValue: field.initialValue, + changed: false, + })), + values: state.fields.reduce((values, field) => ({ + ...values, + [field.name]: field.initialValue, + }), {}), + hasUnsavedChanges: false, + }); + +const isChangeEvent = (x: any): x is ChangeEvent => + (typeof x === 'object' || typeof x === 'function') && typeof x?.currentTarget !== 'undefined'; + +const getValue = (eventOrValue: ChangeEvent | unknown): unknown => { + if (!isChangeEvent(eventOrValue)) { + return eventOrValue; + } + + const target = eventOrValue.currentTarget; + + if (target instanceof HTMLTextAreaElement) { + return target.value; + } + + if (target instanceof HTMLSelectElement) { + return target.value; + } + + if (!(target instanceof HTMLInputElement)) { + return undefined; + } + + if (target.type === 'checkbox' || target.type === 'radio') { + return target.checked; + } + + return target.value; +}; + +export const useForm = ( + initialValues: Record, + onChange: ((...args: unknown[]) => void) = (): void => undefined, +): UseFormReturnType => { + const [state, dispatch] = useReducer(reduceForm, initialValues, initForm); + + const commit = useCallback(() => { + dispatch(formCommitted()); + }, []); + + const reset = useCallback(() => { + dispatch(formReset()); + }, []); + + const handlers = useMemo(() => state.fields.reduce((handlers, { name, initialValue }) => ({ + ...handlers, + [`handle${ capitalize(name) }`]: (eventOrValue: ChangeEvent | unknown): void => { + const newValue = getValue(eventOrValue); + dispatch(valueChanged(name, newValue)); + onChange({ + initialValue, + value: newValue, + key: name, + }); + }, + }), {}), [onChange, state.fields]); + + return { + handlers, + values: state.values, + hasUnsavedChanges: state.hasUnsavedChanges, + commit, + reset, + }; +}; diff --git a/client/hooks/useObservableValue.js b/client/hooks/useObservableValue.js deleted file mode 100644 index 835eeaabdf2..00000000000 --- a/client/hooks/useObservableValue.js +++ /dev/null @@ -1,22 +0,0 @@ -import { useEffect, useState } from 'react'; - -export const useObservableValue = (getValue) => { - const [value, setValue] = useState(() => getValue()); - - useEffect(() => { - let mounted = true; - - const unsubscribe = getValue((newValue) => { - if (mounted) { - setValue(newValue); - } - }); - - return () => { - mounted = false; - typeof unsubscribe === 'function' && unsubscribe(); - }; - }, [getValue]); - - return value; -}; diff --git a/client/hooks/useQuery.ts b/client/hooks/useQuery.ts deleted file mode 100644 index 7fb48742650..00000000000 --- a/client/hooks/useQuery.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Tracker } from 'meteor/tracker'; -import { useCallback, useMemo, useRef } from 'react'; -import { useSubscription } from 'use-subscription'; -import { Mongo } from 'meteor/mongo'; - -const allQuery = {}; - -export const useQuery = (collection: Mongo.Collection, query: object = allQuery, options?: object): T[] => { - const queryHandle = useMemo(() => collection.find(query, options), [collection, query, options]); - const resultRef = useRef([]); - resultRef.current = Tracker.nonreactive(() => queryHandle.fetch()) as unknown as T[]; - - const subscribe = useCallback((cb) => { - const computation = Tracker.autorun(() => { - resultRef.current = queryHandle.fetch(); - cb(resultRef.current); - }); - - return (): void => { - computation.stop(); - }; - }, [queryHandle]); - - const subscription = useMemo(() => ({ - getCurrentValue: (): T[] => resultRef.current ?? [], - subscribe, - }), [subscribe]); - - return useSubscription(subscription); -}; diff --git a/client/hooks/useReactiveSubscriptionFactory.ts b/client/hooks/useReactiveSubscriptionFactory.ts deleted file mode 100644 index ac9021186d0..00000000000 --- a/client/hooks/useReactiveSubscriptionFactory.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Tracker } from 'meteor/tracker'; -import { useCallback } from 'react'; -import { Subscription, Unsubscribe } from 'use-subscription'; - -interface ISubscriptionFactory { - (...args: any[]): Subscription; -} - -export const useReactiveSubscriptionFactory = (fn: (...args: any[]) => T): ISubscriptionFactory => - useCallback>((...args: any[]) => { - const fnWithArgs = (): T => fn(...args); - - return { - getCurrentValue: (): T => Tracker.nonreactive(fnWithArgs) as unknown as T, - subscribe: (callback): Unsubscribe => { - const computation = Tracker.autorun((c) => { - fnWithArgs(); - if (!c.firstRun) { - callback(); - } - }); - - return (): void => { - computation.stop(); - }; - }, - }; - }, [fn]); diff --git a/client/hooks/useReactiveValue.js b/client/hooks/useReactiveValue.js deleted file mode 100644 index b42d2660611..00000000000 --- a/client/hooks/useReactiveValue.js +++ /dev/null @@ -1,19 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Tracker } from 'meteor/tracker'; - -export const useReactiveValue = (getValue, deps = []) => { - const [value, setValue] = useState(() => Tracker.nonreactive(getValue)); - - useEffect(() => { - const computation = Tracker.autorun(() => { - const newValue = getValue(); - setValue(() => newValue); - }); - - return () => { - computation.stop(); - }; - }, deps); - - return value; -}; diff --git a/client/hooks/useReactiveValue.ts b/client/hooks/useReactiveValue.ts new file mode 100644 index 00000000000..1ffb609429d --- /dev/null +++ b/client/hooks/useReactiveValue.ts @@ -0,0 +1,35 @@ +import { Tracker } from 'meteor/tracker'; +import { useMemo } from 'react'; +import { Subscription, Unsubscribe, useSubscription } from 'use-subscription'; + +export const useReactiveValue = (computeCurrentValue: () => T): T => { + const subscription: Subscription = useMemo(() => { + const callbacks = new Set(); + + let currentValue: T; + + const computation = Tracker.autorun(() => { + currentValue = computeCurrentValue(); + callbacks.forEach((callback) => { + callback(); + }); + }); + + return { + getCurrentValue: (): T => currentValue, + subscribe: (callback): Unsubscribe => { + callbacks.add(callback); + + return (): void => { + callbacks.delete(callback); + + if (callbacks.size === 0) { + computation.stop(); + } + }; + }, + }; + }, [computeCurrentValue]); + + return useSubscription(subscription); +}; diff --git a/client/providers/AuthorizationProvider.js b/client/providers/AuthorizationProvider.js deleted file mode 100644 index 96eba34ccaa..00000000000 --- a/client/providers/AuthorizationProvider.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { Meteor } from 'meteor/meteor'; - -import { - hasPermission, - hasAtLeastOnePermission, - hasAllPermission, -} from '../../app/authorization/client/hasPermission'; -import { AuthorizationContext } from '../contexts/AuthorizationContext'; -import { hasRole } from '../../app/authorization/client'; -import { createObservableFromReactive } from './createObservableFromReactive'; - -const contextValue = { - hasPermission: createObservableFromReactive(hasPermission), - hasAtLeastOnePermission: createObservableFromReactive(hasAtLeastOnePermission), - hasAllPermission: createObservableFromReactive(hasAllPermission), - hasRole: createObservableFromReactive((role) => hasRole(Meteor.userId(), role)), -}; - -export function AuthorizationProvider({ children }) { - return ; -} diff --git a/client/providers/AuthorizationProvider.tsx b/client/providers/AuthorizationProvider.tsx new file mode 100644 index 00000000000..a934bd69af1 --- /dev/null +++ b/client/providers/AuthorizationProvider.tsx @@ -0,0 +1,31 @@ +import React, { FC } from 'react'; +import { Meteor } from 'meteor/meteor'; + +import { + hasPermission, + hasAtLeastOnePermission, + hasAllPermission, + hasRole, +} from '../../app/authorization/client'; +import { AuthorizationContext } from '../contexts/AuthorizationContext'; +import { createReactiveSubscriptionFactory } from './createReactiveSubscriptionFactory'; + +const contextValue = { + queryPermission: createReactiveSubscriptionFactory( + (permission, scope) => hasPermission(permission, scope), + ), + queryAtLeastOnePermission: createReactiveSubscriptionFactory( + (permissions, scope) => hasAtLeastOnePermission(permissions, scope), + ), + queryAllPermissions: createReactiveSubscriptionFactory( + (permissions, scope) => hasAllPermission(permissions, scope), + ), + queryRole: createReactiveSubscriptionFactory( + (role) => hasRole(Meteor.userId(), role), + ), +}; + +const AuthorizationProvider: FC = ({ children }) => + ; + +export default AuthorizationProvider; diff --git a/client/providers/ConnectionStatusProvider.js b/client/providers/ConnectionStatusProvider.js index 6f553e7cae2..ed820da5cdf 100644 --- a/client/providers/ConnectionStatusProvider.js +++ b/client/providers/ConnectionStatusProvider.js @@ -4,11 +4,13 @@ import React from 'react'; import { ConnectionStatusContext } from '../contexts/ConnectionStatusContext'; import { useReactiveValue } from '../hooks/useReactiveValue'; +const getStatus = () => ({ + ...Meteor.status(), + reconnect: Meteor.reconnect, +}); + export function ConnectionStatusProvider({ children }) { - const status = useReactiveValue(() => ({ - ...Meteor.status(), - reconnect: Meteor.reconnect, - }), []); + const status = useReactiveValue(getStatus); return ; } diff --git a/client/providers/EditableSettingsProvider.tsx b/client/providers/EditableSettingsProvider.tsx index 6ac9af67a6a..9965b97e791 100644 --- a/client/providers/EditableSettingsProvider.tsx +++ b/client/providers/EditableSettingsProvider.tsx @@ -1,12 +1,12 @@ import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { Mongo } from 'meteor/mongo'; import { Tracker } from 'meteor/tracker'; -import React, { useEffect, useMemo, FunctionComponent, useCallback, useRef, MutableRefObject } from 'react'; +import React, { useEffect, useMemo, FunctionComponent, useRef, MutableRefObject } from 'react'; import { SettingId, GroupId } from '../../definition/ISetting'; import { EditableSettingsContext, IEditableSetting, EditableSettingsContextValue } from '../contexts/EditableSettingsContext'; import { useSettings, SettingsContextQuery } from '../contexts/SettingsContext'; -import { useReactiveSubscriptionFactory } from '../hooks/useReactiveSubscriptionFactory'; +import { createReactiveSubscriptionFactory } from './createReactiveSubscriptionFactory'; const defaultQuery: SettingsContextQuery = {}; @@ -34,12 +34,12 @@ const EditableSettingsProvider: FunctionComponent settingsCollection.remove({ _id: { $nin: persistedSettings.map(({ _id }) => _id) } }); for (const { _id, ...fields } of persistedSettings) { - settingsCollection.upsert(_id, { $set: { ...fields } }); + settingsCollection.upsert(_id, { $set: { ...fields }, $unset: { changed: true } }); } }, [getSettingsCollection, persistedSettings]); - const queryEditableSetting = useReactiveSubscriptionFactory( - useCallback( + const queryEditableSetting = useMemo( + () => createReactiveSubscriptionFactory( (_id: SettingId): IEditableSetting | undefined => { const settingsCollection = getSettingsCollection(); @@ -65,12 +65,12 @@ const EditableSettingsProvider: FunctionComponent disabled: !queries.every((query) => settingsCollection.find(query).count() > 0), }; }, - [getSettingsCollection], ), + [getSettingsCollection], ); - const queryEditableSettings = useReactiveSubscriptionFactory( - useCallback( + const queryEditableSettings = useMemo( + () => createReactiveSubscriptionFactory( (query = {}) => getSettingsCollection().find({ ...('_id' in query) && { _id: { $in: query._id } }, ...('group' in query) && { group: query.group }, @@ -92,12 +92,12 @@ const EditableSettingsProvider: FunctionComponent i18nLabel: 1, }, }).fetch(), - [getSettingsCollection], ), + [getSettingsCollection], ); - const queryGroupSections = useReactiveSubscriptionFactory( - useCallback( + const queryGroupSections = useMemo( + () => createReactiveSubscriptionFactory( (_id: GroupId) => Array.from(new Set( getSettingsCollection().find({ group: _id, @@ -112,8 +112,8 @@ const EditableSettingsProvider: FunctionComponent }, }).fetch().map(({ section }) => section || ''), )), - [getSettingsCollection], ), + [getSettingsCollection], ); const dispatch = useMutableCallback((changes: Partial[]): void => { diff --git a/client/providers/MeteorProvider.js b/client/providers/MeteorProvider.js index 55c0e3dd7c0..2f2f2cbfa25 100644 --- a/client/providers/MeteorProvider.js +++ b/client/providers/MeteorProvider.js @@ -1,15 +1,15 @@ import React from 'react'; -import { AuthorizationProvider } from './AuthorizationProvider'; +import AuthorizationProvider from './AuthorizationProvider'; import { ConnectionStatusProvider } from './ConnectionStatusProvider'; import { RouterProvider } from './RouterProvider'; -import { SessionProvider } from './SessionProvider'; +import SessionProvider from './SessionProvider'; import SettingsProvider from './SettingsProvider'; import { ServerProvider } from './ServerProvider'; import { SidebarProvider } from './SidebarProvider'; import { TranslationProvider } from './TranslationProvider'; import { ToastMessagesProvider } from './ToastMessagesProvider'; -import { UserProvider } from './UserProvider'; +import UserProvider from './UserProvider'; import { AvatarUrlProvider } from './AvatarUrlProvider'; import { CustomSoundProvider } from './CustomSoundProvides'; import ModalProvider from './ModalProvider'; diff --git a/client/providers/SessionProvider.js b/client/providers/SessionProvider.js deleted file mode 100644 index fb4dbc2ce48..00000000000 --- a/client/providers/SessionProvider.js +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { Session } from 'meteor/session'; - -import { SessionContext } from '../contexts/SessionContext'; -import { createObservableFromReactive } from './createObservableFromReactive'; - -const contextValue = { - get: createObservableFromReactive((name) => Session.get(name)), - set: (name, value) => { - Session.set(name, value); - }, -}; - -export function SessionProvider({ children }) { - return ; -} diff --git a/client/providers/SessionProvider.tsx b/client/providers/SessionProvider.tsx new file mode 100644 index 00000000000..e316c79f182 --- /dev/null +++ b/client/providers/SessionProvider.tsx @@ -0,0 +1,17 @@ +import React, { FC } from 'react'; +import { Session } from 'meteor/session'; + +import { SessionContext } from '../contexts/SessionContext'; +import { createReactiveSubscriptionFactory } from './createReactiveSubscriptionFactory'; + +const contextValue = { + query: createReactiveSubscriptionFactory((name) => Session.get(name)), + dispatch: (name: string, value: unknown): void => { + Session.set(name, value); + }, +}; + +const SessionProvider: FC = ({ children }) => + ; + +export default SessionProvider; diff --git a/client/providers/SettingsProvider.tsx b/client/providers/SettingsProvider.tsx index 15d7cb0f5fc..b57feb97af8 100644 --- a/client/providers/SettingsProvider.tsx +++ b/client/providers/SettingsProvider.tsx @@ -3,10 +3,10 @@ import React, { useCallback, useEffect, useMemo, useState, FunctionComponent } f import { useMethod } from '../contexts/ServerContext'; import { SettingsContext, SettingsContextValue } from '../contexts/SettingsContext'; -import { useReactiveSubscriptionFactory } from '../hooks/useReactiveSubscriptionFactory'; import { PrivateSettingsCachedCollection } from '../lib/settings/PrivateSettingsCachedCollection'; import { PublicSettingsCachedCollection } from '../lib/settings/PublicSettingsCachedCollection'; import { useAtLeastOnePermission } from '../contexts/AuthorizationContext'; +import { createReactiveSubscriptionFactory } from './createReactiveSubscriptionFactory'; type SettingsProviderProps = { readonly privileged?: boolean; @@ -16,11 +16,13 @@ const SettingsProvider: FunctionComponent = ({ children, privileged = false, }) => { - const hasPrivilegedPermission = useAtLeastOnePermission([ - 'view-privileged-setting', - 'edit-privileged-setting', - 'manage-selected-settings', - ]); + const hasPrivilegedPermission = useAtLeastOnePermission( + useMemo(() => [ + 'view-privileged-setting', + 'edit-privileged-setting', + 'manage-selected-settings', + ], []), + ); const hasPrivateAccess = privileged && hasPrivilegedPermission; @@ -54,15 +56,15 @@ const SettingsProvider: FunctionComponent = ({ }; }, [cachedCollection]); - const querySetting = useReactiveSubscriptionFactory( - useCallback( + const querySetting = useMemo( + () => createReactiveSubscriptionFactory( (_id) => ({ ...cachedCollection.collection.findOne(_id) }), - [cachedCollection], ), + [cachedCollection], ); - const querySettings = useReactiveSubscriptionFactory( - useCallback( + const querySettings = useMemo( + () => createReactiveSubscriptionFactory( (query = {}) => cachedCollection.collection.find({ ...('_id' in query) && { _id: { $in: query._id } }, ...('group' in query) && { group: query.group }, @@ -83,8 +85,8 @@ const SettingsProvider: FunctionComponent = ({ i18nLabel: 1, }, }).fetch(), - [cachedCollection], ), + [cachedCollection], ); const saveSettings = useMethod('saveSettings'); diff --git a/client/providers/SidebarProvider.js b/client/providers/SidebarProvider.js index e6049d6f08d..0cc8296591f 100644 --- a/client/providers/SidebarProvider.js +++ b/client/providers/SidebarProvider.js @@ -9,7 +9,7 @@ const getOpen = () => menu.isOpen(); const setOpen = (open) => (open ? menu.open() : menu.close()); export function SidebarProvider({ children }) { - const contextValue = [useReactiveValue(getOpen, []), setOpen]; + const contextValue = [useReactiveValue(getOpen), setOpen]; return ; } diff --git a/client/providers/TranslationProvider.js b/client/providers/TranslationProvider.js index f736475b880..aad4d3e2e23 100644 --- a/client/providers/TranslationProvider.js +++ b/client/providers/TranslationProvider.js @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo } from 'react'; import { TAPi18n, TAPi18next } from 'meteor/rocketchat:tap-i18n'; import { TranslationContext } from '../contexts/TranslationContext'; @@ -33,22 +33,27 @@ const createTranslateFunction = (language) => { return translate; }; -export function TranslationProvider({ children }) { - const languages = useReactiveValue(() => { - const result = Object.entries(TAPi18n.getLanguages()) - .map(([key, language]) => ({ ...language, key: key.toLowerCase() })) - .sort((a, b) => a.key - b.key); - - result.unshift({ - name: 'Default', - en: 'Default', - key: '', - }); +const getLanguages = () => { + const result = Object.entries(TAPi18n.getLanguages()) + .map(([key, language]) => ({ ...language, key: key.toLowerCase() })) + .sort((a, b) => a.key - b.key); + + result.unshift({ + name: 'Default', + en: 'Default', + key: '', + }); + + return result; +}; + +const getLanguage = () => TAPi18n.getLanguage(); - return result; - }, []); - const language = useReactiveValue(() => TAPi18n.getLanguage()); - const loadLanguage = useCallback((language) => TAPi18n._loadLanguage(language), []); +const loadLanguage = (language) => TAPi18n._loadLanguage(language); + +export function TranslationProvider({ children }) { + const languages = useReactiveValue(getLanguages); + const language = useReactiveValue(getLanguage); const translate = useMemo(() => createTranslateFunction(language), [language]); return - getUserPreference(Meteor.userId(), key, defaultValue)); - -export function UserProvider({ children }) { - const userId = useReactiveValue(() => Meteor.userId(), []); - const user = useReactiveValue(() => Meteor.user(), []); - const loginWithPassword = useCallback((user, password) => new Promise((resolve, reject) => { - Meteor.loginWithPassword(user, password, (error, result) => { - if (error) { - reject(error); - return; - } - - resolve(result); - }); - }), []); - - const contextValue = useReactiveValue(() => ({ - userId, - user, - loginWithPassword, - getPreference, - }), [userId, user, loginWithPassword]); - - return ; -} diff --git a/client/providers/UserProvider.tsx b/client/providers/UserProvider.tsx new file mode 100644 index 00000000000..4559b8bfb20 --- /dev/null +++ b/client/providers/UserProvider.tsx @@ -0,0 +1,41 @@ +import { Meteor } from 'meteor/meteor'; +import React, { useMemo, FC } from 'react'; + +import { getUserPreference } from '../../app/utils/client'; +import { UserContext } from '../contexts/UserContext'; +import { useReactiveValue } from '../hooks/useReactiveValue'; +import { createReactiveSubscriptionFactory } from './createReactiveSubscriptionFactory'; + +const getUserId = (): string | null => Meteor.userId(); + +const getUser = (): Meteor.User | null => Meteor.user(); + +const loginWithPassword = (user: string | object, password: string): Promise => + new Promise((resolve, reject) => { + Meteor.loginWithPassword(user, password, (error: Error | Meteor.Error | Meteor.TypedError | undefined) => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + }); + +const UserProvider: FC = ({ children }) => { + const userId = useReactiveValue(getUserId); + const user = useReactiveValue(getUser); + + const contextValue = useMemo(() => ({ + userId, + user, + loginWithPassword, + queryPreference: createReactiveSubscriptionFactory( + (key, defaultValue) => getUserPreference(userId, key, defaultValue), + ), + }), [userId, user]); + + return ; +}; + +export default UserProvider; diff --git a/client/providers/createObservableFromReactive.js b/client/providers/createObservableFromReactive.js deleted file mode 100644 index a1c5e11e549..00000000000 --- a/client/providers/createObservableFromReactive.js +++ /dev/null @@ -1,19 +0,0 @@ -import { Tracker } from 'meteor/tracker'; - -export const createObservableFromReactive = (fn) => (...fnArgs) => { - const args = fnArgs.slice(0, -1); - const listener = fnArgs.pop(); - - if (!listener) { - return Tracker.nonreactive(() => fn(...args)); - } - - const computation = Tracker.autorun(() => { - const value = fn(...args); - listener(value); - }); - - return () => { - computation.stop(); - }; -}; diff --git a/client/providers/createReactiveSubscriptionFactory.ts b/client/providers/createReactiveSubscriptionFactory.ts new file mode 100644 index 00000000000..6c6b2373908 --- /dev/null +++ b/client/providers/createReactiveSubscriptionFactory.ts @@ -0,0 +1,39 @@ +import { Tracker } from 'meteor/tracker'; +import { Subscription, Unsubscribe } from 'use-subscription'; + +interface ISubscriptionFactory { + (...args: any[]): Subscription; +} + +export const createReactiveSubscriptionFactory = ( + computeCurrentValueWith: (...args: any[]) => T, +): ISubscriptionFactory => + (...args: any[]): Subscription => { + const computeCurrentValue = (): T => computeCurrentValueWith(...args); + + const callbacks = new Set(); + + let currentValue: T; + + const computation = Tracker.autorun(() => { + currentValue = computeCurrentValue(); + callbacks.forEach((callback) => { + callback(); + }); + }); + + return { + getCurrentValue: (): T => currentValue, + subscribe: (callback): Unsubscribe => { + callbacks.add(callback); + + return (): void => { + callbacks.delete(callback); + + if (callbacks.size === 0) { + computation.stop(); + } + }; + }, + }; + };