diff --git a/lib/shared/version-utils.js b/lib/shared/version-utils.js index c466c9233..1cb425600 100644 --- a/lib/shared/version-utils.js +++ b/lib/shared/version-utils.js @@ -1,66 +1,88 @@ // @flow -import { type PlatformDetails, isWebPlatform } from '../types/device-types.js'; +import { + type PlatformDetails, + isWebPlatform, + isDesktopPlatform, +} from '../types/device-types.js'; /** * A code version used for features that are waiting to be released * and we're not sure in which version they will be available. */ const FUTURE_CODE_VERSION = 1000000; /** * A code version used for features that are waiting to be included * in the very next release */ const NEXT_CODE_VERSION = 1000000; function hasMinCodeVersion( platformDetails: ?PlatformDetails, - minCodeVersion: Partial<{ +native: number, +web: number }>, + minCodeVersion: Partial<{ + +native: number, + +web: number, + +majorDesktop: number, + }>, ): boolean { if (!platformDetails) { return true; } - const { codeVersion } = platformDetails; + + const { codeVersion, majorDesktopVersion } = platformDetails; const minVersion = isWebPlatform(platformDetails.platform) ? minCodeVersion.web : minCodeVersion.native; - if (!minVersion) { - return true; + const minMajorDesktopVersion = isDesktopPlatform(platformDetails.platform) + ? minCodeVersion.majorDesktop + : undefined; + + if (minVersion && (!codeVersion || codeVersion < minVersion)) { + return false; } - if (!codeVersion || codeVersion < minVersion) { + + if ( + minMajorDesktopVersion && + (!majorDesktopVersion || majorDesktopVersion < minMajorDesktopVersion) + ) { return false; } return true; } function hasMinStateVersion( platformDetails: ?PlatformDetails, minStateVersion: Partial<{ +native: number, +web: number }>, ): boolean { if (!platformDetails) { return true; } const { stateVersion } = platformDetails; const minVersion = isWebPlatform(platformDetails.platform) ? minStateVersion.web : minStateVersion.native; if (!minVersion) { return true; } if (!stateVersion || stateVersion < minVersion) { return false; } return true; } +function extractMajorDesktopVersion(desktopVersion: string): number { + return desktopVersion.split('.').map(Number)[0]; +} + export { FUTURE_CODE_VERSION, NEXT_CODE_VERSION, hasMinCodeVersion, hasMinStateVersion, + extractMajorDesktopVersion, }; diff --git a/lib/types/device-types.js b/lib/types/device-types.js index 2db70946e..cad5dc1fc 100644 --- a/lib/types/device-types.js +++ b/lib/types/device-types.js @@ -1,57 +1,62 @@ // @flow import invariant from 'invariant'; import { values } from '../utils/objects.js'; const deviceTypesObj = Object.freeze({ ios: 'ios', android: 'android', }); export type DeviceType = $Values; export type Platform = DeviceType | 'web' | 'windows' | 'macos'; export const deviceTypes: $ReadOnlyArray = values(deviceTypesObj); export function isDeviceType(platform: ?string): boolean { return platform === 'ios' || platform === 'android'; } export function assertDeviceType(deviceType: ?string): DeviceType { invariant( deviceType === 'ios' || deviceType === 'android', 'string is not DeviceType enum', ); return deviceType; } +export function isDesktopPlatform(platform: ?string): boolean { + return platform === 'macos' || platform === 'windows'; +} + export function isWebPlatform(platform: ?string): boolean { - return platform === 'web' || platform === 'windows' || platform === 'macos'; + return platform === 'web' || isDesktopPlatform(platform); } export type DeviceTokenUpdateRequest = { +deviceToken: ?string, +deviceType?: DeviceType, +platformDetails?: PlatformDetails, }; export type PlatformDetails = { +platform: Platform, +codeVersion?: ?number, +stateVersion?: ?number, + +majorDesktopVersion?: number, }; export type LastCommunicatedPlatformDetails = { +platformDetails: PlatformDetails, +keyserverID: string, }; export type VersionResponse = { +codeVersion: number, +ownerUsername: ?string, +ownerID: ?string, }; export type GetVersionActionPayload = { +versionResponses: { +[keyserverId: string]: VersionResponse }, }; diff --git a/lib/utils/validation-utils.js b/lib/utils/validation-utils.js index ce99c2b36..a7532f7fb 100644 --- a/lib/utils/validation-utils.js +++ b/lib/utils/validation-utils.js @@ -1,144 +1,145 @@ // @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 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 idSchemaRegex = '(?:[0-9]+\\|)?[0-9]+'; const pendingThreadIDRegex = `pending/(type[0-9]+/[0-9]+(\\+[0-9]+)*|sidebar/${idSchemaRegex})`; 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, tMediaMessagePhoto, tMediaMessageVideo, tMediaMessageMedia, assertWithValidator, ashoatKeyserverID, idSchemaRegex, pendingThreadIDRegex, validChatNameRegex, validChatNameRegexString, chatNameMaxLength, }; diff --git a/web/app.react.js b/web/app.react.js index dd716f706..be27180b8 100644 --- a/web/app.react.js +++ b/web/app.react.js @@ -1,402 +1,407 @@ // @flow import 'basscss/css/basscss.min.css'; import './theme.css'; import { config as faConfig } from '@fortawesome/fontawesome-svg-core'; import classnames from 'classnames'; import _isEqual from 'lodash/fp/isEqual.js'; import * as React from 'react'; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { WagmiConfig } from 'wagmi'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, } from 'lib/actions/entry-actions.js'; import { ChatMentionContextProvider } from 'lib/components/chat-mention-provider.react.js'; import { EditUserAvatarProvider } from 'lib/components/edit-user-avatar-provider.react.js'; import { ModalProvider, useModalContext, } from 'lib/components/modal-provider.react.js'; import { createLoadingStatusSelector, combineLoadingStatuses, } from 'lib/selectors/loading-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; +import { extractMajorDesktopVersion } from 'lib/shared/version-utils.js'; import { TunnelbrokerProvider } from 'lib/tunnelbroker/tunnelbroker-context.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { registerConfig } from 'lib/utils/config.js'; import { useDispatch } from 'lib/utils/redux-utils.js'; import { infoFromURL } from 'lib/utils/url-utils.js'; import { AlchemyENSCacheProvider, wagmiConfig } from 'lib/utils/wagmi-utils.js'; import QrCodeLogin from './account/qr-code-login.react.js'; import AppThemeWrapper from './app-theme-wrapper.react.js'; import WebEditThreadAvatarProvider from './avatars/web-edit-thread-avatar-provider.react.js'; import Calendar from './calendar/calendar.react.js'; import Chat from './chat/chat.react.js'; import { EditModalProvider } from './chat/edit-message-provider.js'; import { TooltipProvider } from './chat/tooltip-provider.js'; import NavigationArrows from './components/navigation-arrows.react.js'; import { initOpaque } from './crypto/opaque-utils.js'; import electron from './electron.js'; import InputStateContainer from './input/input-state-container.react.js'; import InviteLinkHandler from './invite-links/invite-link-handler.react.js'; import InviteLinksRefresher from './invite-links/invite-links-refresher.react.js'; import LoadingIndicator from './loading-indicator.react.js'; import { MenuProvider } from './menu-provider.react.js'; import UpdateModalHandler from './modals/update-modal.react.js'; import SettingsSwitcher from './navigation-panels/settings-switcher.react.js'; import Topbar from './navigation-panels/topbar.react.js'; import useBadgeHandler from './push-notif/badge-handler.react.js'; import { PushNotificationsHandler } from './push-notif/push-notifs-handler.js'; import { updateNavInfoActionType } from './redux/action-types.js'; import DisconnectedBarVisibilityHandler from './redux/disconnected-bar-visibility-handler.js'; import DisconnectedBar from './redux/disconnected-bar.js'; import FocusHandler from './redux/focus-handler.react.js'; import { persistConfig } from './redux/persist.js'; import PolicyAcknowledgmentHandler from './redux/policy-acknowledgment-handler.js'; import { useSelector } from './redux/redux-utils.js'; import VisibilityHandler from './redux/visibility-handler.react.js'; import history from './router-history.js'; import { MessageSearchStateProvider } from './search/message-search-state-provider.react.js'; import { createTunnelbrokerInitMessage } from './selectors/tunnelbroker-selectors.js'; import AccountSettings from './settings/account-settings.react.js'; import DangerZone from './settings/danger-zone.react.js'; import KeyserverSelectionList from './settings/keyserver-selection-list.react.js'; import CommunityPicker from './sidebar/community-picker.react.js'; import Splash from './splash/splash.react.js'; import './typography.css'; import css from './style.css'; import { type NavInfo } from './types/nav-types.js'; import { canonicalURLFromReduxState, navInfoFromURL } from './url-utils.js'; initOpaque(); // We want Webpack's css-loader and style-loader to handle the Fontawesome CSS, // so we disable the autoAddCss logic and import the CSS file. Otherwise every // icon flashes huge for a second before the CSS is loaded. import '@fortawesome/fontawesome-svg-core/styles.css'; faConfig.autoAddCss = false; +const desktopDetails = electron?.version + ? { majorDesktopVersion: extractMajorDesktopVersion(electron?.version) } + : null; registerConfig({ // We can't securely cache credentials on web, so we have no way to recover // from a cookie invalidation resolveInvalidatedCookie: null, setSessionIDOnRequest: true, // Never reset the calendar range calendarRangeInactivityLimit: null, platformDetails: { platform: electron?.platform ?? 'web', codeVersion: 45, stateVersion: persistConfig.version, + ...desktopDetails, }, }); type BaseProps = { +location: { +pathname: string, ... }, }; type Props = { ...BaseProps, // Redux state +navInfo: NavInfo, +entriesLoadingStatus: LoadingStatus, +loggedIn: boolean, +activeThreadCurrentlyUnread: boolean, // Redux dispatch functions +dispatch: Dispatch, +modals: $ReadOnlyArray, }; class App extends React.PureComponent { componentDidMount() { const { navInfo, location: { pathname }, loggedIn, } = this.props; const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (pathname !== newURL) { history.replace(newURL); } } componentDidUpdate(prevProps: Props) { const { navInfo, location: { pathname }, loggedIn, } = this.props; if (!_isEqual(navInfo)(prevProps.navInfo)) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.push(newURL); } } else if (pathname !== prevProps.location.pathname) { const urlInfo = infoFromURL(pathname); const newNavInfo = navInfoFromURL(urlInfo, { navInfo }); if (!_isEqual(newNavInfo)(navInfo)) { this.props.dispatch({ type: updateNavInfoActionType, payload: newNavInfo, }); } } else if (loggedIn !== prevProps.loggedIn) { const newURL = canonicalURLFromReduxState(navInfo, pathname, loggedIn); if (newURL !== pathname) { history.replace(newURL); } } if (loggedIn !== prevProps.loggedIn) { electron?.clearHistory(); } } onWordmarkClicked = () => { this.props.dispatch({ type: updateNavInfoActionType, payload: { tab: 'chat' }, }); }; render(): React.Node { let content; if (this.props.loggedIn) { content = ( <> {this.renderMainContent()} {this.props.modals} ); } else { content = ( <> {this.renderLoginPage()} {this.props.modals} ); } return ( {content} ); } onHeaderDoubleClick = (): void => electron?.doubleClickTopBar(); stopDoubleClickPropagation: ?(SyntheticEvent) => void = electron ? e => e.stopPropagation() : null; renderLoginPage(): React.Node { const { loginMethod } = this.props.navInfo; if (loginMethod === 'qr-code') { return ; } return ; } renderMainContent(): React.Node { const mainContent = this.getMainContentWithSwitcher(); let navigationArrows = null; if (electron) { navigationArrows = ; } const headerClasses = classnames({ [css.header]: true, [css['electron-draggable']]: electron, }); const wordmarkClasses = classnames({ [css.wordmark]: true, [css['electron-non-draggable']]: electron, [css['wordmark-macos']]: electron?.platform === 'macos', }); return (

Comm

{navigationArrows}
{mainContent}
); } getMainContentWithSwitcher(): React.Node { const { tab, settingsSection } = this.props.navInfo; let mainContent: React.Node; if (tab === 'settings') { if (settingsSection === 'account') { mainContent = ; } else if (settingsSection === 'keyservers') { mainContent = ; } else if (settingsSection === 'danger-zone') { mainContent = ; } return (
{mainContent}
); } if (tab === 'calendar') { mainContent = ; } else if (tab === 'chat') { mainContent = ; } const mainContentClass = classnames( css['main-content-container'], css['main-content-container-column'], ); return (
{mainContent}
); } } const fetchEntriesLoadingStatusSelector = createLoadingStatusSelector( fetchEntriesActionTypes, ); const updateCalendarQueryLoadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const ConnectedApp: React.ComponentType = React.memo( function ConnectedApp(props) { const activeChatThreadID = useSelector( state => state.navInfo.activeChatThreadID, ); const navInfo = useSelector(state => state.navInfo); const fetchEntriesLoadingStatus = useSelector( fetchEntriesLoadingStatusSelector, ); const updateCalendarQueryLoadingStatus = useSelector( updateCalendarQueryLoadingStatusSelector, ); const entriesLoadingStatus = combineLoadingStatuses( fetchEntriesLoadingStatus, updateCalendarQueryLoadingStatus, ); const loggedIn = useSelector(isLoggedIn); const activeThreadCurrentlyUnread = useSelector( state => !activeChatThreadID || !!state.threadStore.threadInfos[activeChatThreadID]?.currentUser.unread, ); useBadgeHandler(); const dispatch = useDispatch(); const modalContext = useModalContext(); const modals = React.useMemo( () => modalContext.modals.map(([modal, key]) => ( {modal} )), [modalContext.modals], ); const tunnelbrokerInitMessage = useSelector(createTunnelbrokerInitMessage); return ( ); }, ); function AppWithProvider(props: BaseProps): React.Node { return ( ); } export default AppWithProvider;