diff --git a/lib/utils/validation-utils.js b/lib/utils/validation-utils.js index 14fe4755d..17c21602b 100644 --- a/lib/utils/validation-utils.js +++ b/lib/utils/validation-utils.js @@ -1,152 +1,154 @@ // @flow import invariant from 'invariant'; import t from 'tcomb'; import type { TStructProps, TIrreducible, TRefinement, TEnums, TInterface, TUnion, TType, } from 'tcomb'; import { validEmailRegex, oldValidUsernameRegex, validHexColorRegex, } from '../shared/account-utils.js'; import type { PlatformDetails } from '../types/device-types'; import type { MediaMessageServerDBContent, PhotoMessageServerDBContent, VideoMessageServerDBContent, } from '../types/messages/media'; function tBool(value: boolean): TIrreducible { return t.irreducible(value.toString(), x => x === value); } function tString(value: string): TIrreducible { return t.irreducible(`'${value}'`, x => x === value); } function tNumber(value: number): TIrreducible { return t.irreducible(value.toString(), x => x === value); } function tShape(spec: TStructProps): TInterface { return t.interface(spec, { strict: true }); } export type TRegex = TRefinement; function tRegex(regex: RegExp): TRegex { return t.refinement(t.String, val => regex.test(val)); } function tNumEnum(nums: $ReadOnlyArray): TRefinement { return t.refinement(t.Number, (input: number) => { for (const num of nums) { if (input === num) { return true; } } return false; }); } const tNull: TIrreducible = t.irreducible('null', x => x === null); const tDate: TRegex = tRegex(/^[0-9]{4}-[0-1][0-9]-[0-3][0-9]$/); const tColor: TRegex = tRegex(validHexColorRegex); // we don't include # char const tPlatform: TEnums = t.enums.of([ 'ios', 'android', 'web', 'windows', 'macos', ]); const tDeviceType: TEnums = t.enums.of(['ios', 'android']); const tPlatformDetails: TInterface = tShape({ platform: tPlatform, codeVersion: t.maybe(t.Number), stateVersion: t.maybe(t.Number), majorDesktopVersion: t.maybe(t.Number), }); const tPassword: TRefinement = t.refinement( t.String, (password: string) => !!password, ); const tCookie: TRegex = tRegex(/^(user|anonymous)=[0-9]+:[0-9a-f]+$/); const tEmail: TRegex = tRegex(validEmailRegex); const tOldValidUsername: TRegex = tRegex(oldValidUsernameRegex); const tID: TRefinement = t.refinement(t.String, (id: string) => !!id); const tUserID: TRefinement = t.refinement( t.String, (id: string) => !!id, ); const tMediaMessagePhoto: TInterface = tShape({ type: tString('photo'), uploadID: tID, }); const tMediaMessageVideo: TInterface = tShape({ type: tString('video'), uploadID: tID, thumbnailUploadID: tID, }); const tMediaMessageMedia: TUnion = t.union([ tMediaMessagePhoto, tMediaMessageVideo, ]); function assertWithValidator(data: mixed, validator: TType): T { invariant(validator.is(data), "data isn't of type T"); return (data: any); } const ashoatKeyserverID = '256'; const uuidRegex = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'; const idSchemaRegex = `(?:(?:[0-9]+|${uuidRegex})\\|)?(?:[0-9]+|${uuidRegex})`; const pendingThreadIDRegex = `pending/(type[0-9]+/[0-9]+(\\+[0-9]+)*|sidebar/${idSchemaRegex})`; +const thickThreadIDRegex: RegExp = new RegExp(`^${uuidRegex}$`); const chatNameMaxLength = 191; const chatNameMinLength = 0; const secondCharRange = `{${chatNameMinLength},${chatNameMaxLength}}`; const validChatNameRegexString = `.${secondCharRange}`; const validChatNameRegex: RegExp = new RegExp(`^${validChatNameRegexString}$`); export { tBool, tString, tNumber, tShape, tRegex, tNumEnum, tNull, tDate, tColor, tPlatform, tDeviceType, tPlatformDetails, tPassword, tCookie, tEmail, tOldValidUsername, tID, tUserID, tMediaMessagePhoto, tMediaMessageVideo, tMediaMessageMedia, assertWithValidator, ashoatKeyserverID, idSchemaRegex, pendingThreadIDRegex, + thickThreadIDRegex, validChatNameRegex, validChatNameRegexString, chatNameMaxLength, }; diff --git a/web/redux/action-types.js b/web/redux/action-types.js index 5eb6babf3..5dcd1ad67 100644 --- a/web/redux/action-types.js +++ b/web/redux/action-types.js @@ -1,170 +1,172 @@ // @flow -import { extractKeyserverIDFromID } from 'lib/keyserver-conn/keyserver-call-utils.js'; +import { extractKeyserverIDFromIDOptional } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { useKeyserverCall } from 'lib/keyserver-conn/keyserver-call.js'; import type { CallKeyserverEndpoint } from 'lib/keyserver-conn/keyserver-conn-types.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import type { WebInitialKeyserverInfo, ClientWebInitialReduxStateResponse, } from 'lib/types/redux-types.js'; import type { URLInfo } from 'lib/utils/url-utils.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import type { ExcludedData, InitialReduxState, InitialReduxStateRequest, } from '../types/redux-types.js'; export const updateNavInfoActionType = 'UPDATE_NAV_INFO'; export const updateWindowDimensionsActionType = 'UPDATE_WINDOW_DIMENSIONS'; export const updateWindowActiveActionType = 'UPDATE_WINDOW_ACTIVE'; export const setInitialReduxState = 'SET_INITIAL_REDUX_STATE'; const getInitialReduxStateCallSingleKeyserverEndpointOptions = { timeout: 300000, }; type GetInitialReduxStateInput = { +urlInfo: URLInfo, +excludedData: ExcludedData, +allUpdatesCurrentAsOf: { +[keyserverID: string]: number, }, }; const getInitialReduxState = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): ((input: GetInitialReduxStateInput) => Promise) => async input => { const requests: { [string]: InitialReduxStateRequest } = {}; const { urlInfo, excludedData, allUpdatesCurrentAsOf } = input; const { thread, inviteSecret, ...rest } = urlInfo; - const threadKeyserverID = thread ? extractKeyserverIDFromID(thread) : null; + const threadKeyserverID = thread + ? extractKeyserverIDFromIDOptional(thread) + : null; for (const keyserverID of allKeyserverIDs) { const clientUpdatesCurrentAsOf = allUpdatesCurrentAsOf[keyserverID]; const keyserverExcludedData: ExcludedData = clientUpdatesCurrentAsOf ? excludedData : {}; if (keyserverID === threadKeyserverID) { requests[keyserverID] = { urlInfo, excludedData: keyserverExcludedData, clientUpdatesCurrentAsOf, }; } else { requests[keyserverID] = { urlInfo: rest, excludedData: keyserverExcludedData, clientUpdatesCurrentAsOf, }; } } const responses: { +[string]: ClientWebInitialReduxStateResponse } = await callKeyserverEndpoint( 'get_initial_redux_state', requests, getInitialReduxStateCallSingleKeyserverEndpointOptions, ); const { currentUserInfo, userInfos, pushApiPublicKey, navInfo } = responses[authoritativeKeyserverID]; const dataLoaded = currentUserInfo && !currentUserInfo.anonymous; const actualizedCalendarQuery = { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: defaultCalendarFilters, }; const entryStore = { daysToEntries: {}, entryInfos: {}, lastUserInteractionCalendar: 0, }; const threadStore = { threadInfos: {}, }; const messageStore = { currentAsOf: {}, local: {}, messages: {}, threads: {}, }; const inviteLinksStore = { links: {}, }; let keyserverInfos: { [keyserverID: string]: WebInitialKeyserverInfo } = {}; for (const keyserverID in responses) { entryStore.daysToEntries = { ...entryStore.daysToEntries, ...responses[keyserverID].entryStore.daysToEntries, }; entryStore.entryInfos = { ...entryStore.entryInfos, ...responses[keyserverID].entryStore.entryInfos, }; entryStore.lastUserInteractionCalendar = Math.max( entryStore.lastUserInteractionCalendar, responses[keyserverID].entryStore.lastUserInteractionCalendar, ); threadStore.threadInfos = { ...threadStore.threadInfos, ...responses[keyserverID].threadStore.threadInfos, }; messageStore.currentAsOf = { ...messageStore.currentAsOf, ...responses[keyserverID].messageStore.currentAsOf, }; messageStore.messages = { ...messageStore.messages, ...responses[keyserverID].messageStore.messages, }; messageStore.threads = { ...messageStore.threads, ...responses[keyserverID].messageStore.threads, }; inviteLinksStore.links = { ...inviteLinksStore.links, ...responses[keyserverID].inviteLinksStore.links, }; keyserverInfos = { ...keyserverInfos, [keyserverID]: responses[keyserverID].keyserverInfo, }; } return { navInfo: { ...navInfo, inviteSecret, }, currentUserInfo, entryStore, threadStore, userInfos, actualizedCalendarQuery, messageStore, dataLoaded, pushApiPublicKey, inviteLinksStore, keyserverInfos, }; }; function useGetInitialReduxState(): ( input: GetInitialReduxStateInput, ) => Promise { return useKeyserverCall(getInitialReduxState); } export { useGetInitialReduxState }; diff --git a/web/redux/initial-state-gate.js b/web/redux/initial-state-gate.js index 1c0a2e959..a0cbd7b00 100644 --- a/web/redux/initial-state-gate.js +++ b/web/redux/initial-state-gate.js @@ -1,213 +1,220 @@ // @flow import * as React from 'react'; import { PersistGate } from 'redux-persist/es/integration/react.js'; import type { Persistor } from 'redux-persist/es/types'; import { setClientDBStoreActionType } from 'lib/actions/client-db-store-actions.js'; import type { EntryStoreOperation } from 'lib/ops/entries-store-ops.js'; import type { MessageStoreOperation } from 'lib/ops/message-store-ops.js'; import type { ThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; import type { UserStoreOperation } from 'lib/ops/user-store-ops.js'; import { getMessageSearchStoreOps } from 'lib/reducers/db-ops-reducer.js'; import { allUpdatesCurrentAsOfSelector } from 'lib/selectors/keyserver-selectors.js'; import type { RawThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { getConfig } from 'lib/utils/config.js'; import { convertIDToNewSchema } from 'lib/utils/migration-utils.js'; import { entries, values } from 'lib/utils/objects.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; +import { thickThreadIDRegex } from 'lib/utils/validation-utils.js'; import { setInitialReduxState, useGetInitialReduxState, } from './action-types.js'; import { useSelector } from './redux-utils.js'; import { authoritativeKeyserverID } from '../authoritative-keyserver.js'; import Loading from '../loading.react.js'; import { getClientDBStore } from '../shared-worker/utils/store.js'; import type { InitialReduxStateActionPayload } from '../types/redux-types.js'; type Props = { +persistor: Persistor, +children: React.Node, }; function InitialReduxStateGate(props: Props): React.Node { const { children, persistor } = props; const callGetInitialReduxState = useGetInitialReduxState(); const dispatch = useDispatch(); const [initError, setInitError] = React.useState(null); React.useEffect(() => { if (initError) { throw initError; } }, [initError]); const isRehydrated = useSelector(state => !!state._persist?.rehydrated); const allUpdatesCurrentAsOf = useSelector(allUpdatesCurrentAsOfSelector); const prevIsRehydrated = React.useRef(false); React.useEffect(() => { if (prevIsRehydrated.current || !isRehydrated) { return; } prevIsRehydrated.current = isRehydrated; void (async () => { try { let urlInfo = infoFromURL(decodeURI(window.location.href)); + const isThickThreadOpen = + urlInfo.thread && thickThreadIDRegex.test(urlInfo.thread); // Handle older links - if (urlInfo.thread) { + if (urlInfo.thread && !isThickThreadOpen) { urlInfo = { ...urlInfo, thread: convertIDToNewSchema( urlInfo.thread, authoritativeKeyserverID, ), }; } const clientDBStore = await getClientDBStore(); dispatch({ type: setClientDBStoreActionType, payload: clientDBStore, }); const payload = await callGetInitialReduxState({ urlInfo, excludedData: { threadStore: !!clientDBStore.threadStore, messageStore: !!clientDBStore.messages, userStore: !!clientDBStore.users, entryStore: !!clientDBStore.entries, }, allUpdatesCurrentAsOf, }); + if (isThickThreadOpen) { + payload.navInfo.activeChatThreadID = urlInfo.thread; + } + const currentLoggedInUserID = payload.currentUserInfo?.anonymous ? null : payload.currentUserInfo?.id; if (!currentLoggedInUserID) { dispatch({ type: setInitialReduxState, payload }); return; } let initialReduxState: InitialReduxStateActionPayload = payload; let threadStoreOperations: ThreadStoreOperation[] = []; if (clientDBStore.threadStore) { const { threadStore, ...rest } = initialReduxState; initialReduxState = rest; } else { // When there is no data in the DB, it's necessary to migrate data // from the keyserver payload to the DB threadStoreOperations = entries(payload.threadStore.threadInfos).map( ([id, threadInfo]: [string, RawThreadInfo]) => ({ type: 'replace', payload: { id, threadInfo, }, }), ); } let userStoreOperations: UserStoreOperation[] = []; if (clientDBStore.users) { const { userInfos, ...rest } = initialReduxState; initialReduxState = rest; } else { userStoreOperations = values(payload.userInfos).map(userInfo => ({ type: 'replace_user', payload: userInfo, })); } let messageStoreOperations: MessageStoreOperation[] = []; if (clientDBStore.messages) { const { messageStore, ...rest } = initialReduxState; initialReduxState = rest; } else { const { messages, threads } = payload.messageStore; messageStoreOperations = [ ...entries(messages).map(([id, messageInfo]) => ({ type: 'replace', payload: { id, messageInfo }, })), { type: 'replace_threads', payload: { threads }, }, ]; } let entryStoreOperations: Array = []; if (clientDBStore.entries) { const { entryStore, ...rest } = initialReduxState; initialReduxState = rest; } else { entryStoreOperations = entries(payload.entryStore.entryInfos).map( ([id, entry]) => ({ type: 'replace_entry', payload: { id, entry }, }), ); } if ( threadStoreOperations.length > 0 || userStoreOperations.length > 0 || messageStoreOperations.length > 0 || entryStoreOperations.length > 0 ) { const messageSearchStoreOperations = getMessageSearchStoreOps( messageStoreOperations, ); const { sqliteAPI } = getConfig(); await sqliteAPI.processDBStoreOperations({ threadStoreOperations, draftStoreOperations: [], messageStoreOperations, reportStoreOperations: [], userStoreOperations, keyserverStoreOperations: [], communityStoreOperations: [], integrityStoreOperations: [], syncedMetadataStoreOperations: [], auxUserStoreOperations: [], threadActivityStoreOperations: [], entryStoreOperations, messageSearchStoreOperations, }); } dispatch({ type: setInitialReduxState, payload: initialReduxState, }); } catch (err) { setInitError(err); } })(); }, [callGetInitialReduxState, dispatch, isRehydrated, allUpdatesCurrentAsOf]); const initialStateLoaded = useSelector(state => state.initialStateLoaded); const childFunction = React.useCallback( // This argument is passed from `PersistGate`. It means that the state is // rehydrated and we can start fetching the initial info. (bootstrapped: boolean) => { if (bootstrapped && initialStateLoaded) { return children; } else { return ; } }, [children, initialStateLoaded], ); return {childFunction}; } export default InitialReduxStateGate;