diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e358968e0..5e9dbf34a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -263,9 +263,9 @@ "leaveGroupConfirmationAdmin": "As you are the admin of this group, if you leave it it will be removed for every current members. Are you sure you want to leave this group?", "cannotRemoveCreatorFromGroup": "Cannot remove this user", "cannotRemoveCreatorFromGroupDesc": "You cannot remove this user as they are the creator of the group.", - "userNeedsToHaveJoined": "User needs to have joined", - "userNeedsToHaveJoinedDesc": "An error happened. The user needs to have already joined the server for this ADD to work.", "noContactsForGroup": "You don't have any contacts yet", + "failedToAddAsModerator": "Failed to add user as moderator", + "failedToRemoveFromModerator": "Failed to remove user from the moderator list", "copyMessage": "Copy message text", "selectMessage": "Select message", "editGroup": "Edit group", @@ -381,8 +381,6 @@ "noBlockedContacts": "No blocked contacts", "userAddedToModerators": "User added to moderator list", "userRemovedFromModerators": "User removed from moderator list", - "errorHappenedWhileRemovingModerator": "An error happened", - "errorHappenedWhileRemovingModeratorDesc": "An error happened while removing this user from the moderator list.", "orJoinOneOfThese": "Or join one of these...", "helpUsTranslateSession": "Help us Translate Session", "translation": "Translation", diff --git a/images/session/brand.svg b/images/session/brand.svg index beb9de0e1..9dec9e8ea 100644 --- a/images/session/brand.svg +++ b/images/session/brand.svg @@ -1,31 +1,2 @@ -image/svg+xml \ No newline at end of file + \ No newline at end of file diff --git a/images/session/session_chat_icon.png b/images/session/session_chat_icon.png deleted file mode 100644 index cb4072089..000000000 Binary files a/images/session/session_chat_icon.png and /dev/null differ diff --git a/images/session/session_icon.svg b/images/session/session_icon.svg new file mode 100644 index 000000000..60cfeb751 --- /dev/null +++ b/images/session/session_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/session/session_icon_1024.png b/images/session/session_icon_1024.png index 5fe3a21d3..39228f4c5 100644 Binary files a/images/session/session_icon_1024.png and b/images/session/session_icon_1024.png differ diff --git a/images/session/session_icon_128.png b/images/session/session_icon_128.png deleted file mode 100644 index e3a410547..000000000 Binary files a/images/session/session_icon_128.png and /dev/null differ diff --git a/images/session/session_icon_256.png b/images/session/session_icon_256.png deleted file mode 100644 index 027e60116..000000000 Binary files a/images/session/session_icon_256.png and /dev/null differ diff --git a/images/session/session_icon_64.png b/images/session/session_icon_64.png deleted file mode 100644 index fedcd0d8d..000000000 Binary files a/images/session/session_icon_64.png and /dev/null differ diff --git a/js/background.js b/js/background.js index fc18b5435..dcbf28b3f 100644 --- a/js/background.js +++ b/js/background.js @@ -340,11 +340,6 @@ window.libsession.Utils.ToastUtils.pushSpellCheckDirty(); }; - window.toggleLinkPreview = () => { - const newValue = !window.getSettingValue('link-preview-setting'); - window.setSettingValue('link-preview-setting', newValue); - }; - window.toggleMediaPermissions = () => { const value = window.getMediaPermissions(); window.setMediaPermissions(!value); @@ -450,11 +445,6 @@ }, window.CONSTANTS.NOTIFICATION_ENABLE_TIMEOUT_SECONDS * 1000); window.NewReceiver.queueAllCached(); - window - .getSwarmPollingInstance() - .addPubkey(window.libsession.Utils.UserUtils.getOurPubKeyStrFromCache()); - - window.getSwarmPollingInstance().start(); window.libsession.Utils.AttachmentDownloads.start({ logger: window.log, }); diff --git a/package.json b/package.json index 72a9eb215..2af38bea6 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "session-desktop", "productName": "Session", "description": "Private messaging from your desktop", - "version": "1.6.6", + "version": "1.6.7", "license": "GPL-3.0", "author": { "name": "Loki Project", @@ -40,7 +40,7 @@ "format-full": "prettier --list-different --write \"*.{css,js,json,scss,ts,tsx}\" \"./**/*.{css,js,json,scss,ts,tsx}\"", "transpile": "tsc --incremental", "transpile:watch": "tsc -w", - "clean-transpile": "rimraf ts/**/*.js ts/*.js ts/*.js.map ts/**/*.js.map && rimraf tsconfig.tsbuildinfo;", + "clean-transpile": "rimraf 'ts/**/*.js ts/*.js' 'ts/*.js.map' 'ts/**/*.js.map' && rimraf tsconfig.tsbuildinfo;", "ready": "yarn clean-transpile; yarn grunt && yarn lint-full && yarn test" }, "dependencies": { diff --git a/session-file-server b/session-file-server deleted file mode 160000 index 5173163fe..000000000 --- a/session-file-server +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5173163fe18ac575676020e2f8621cf7a2956df3 diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 4b5e4cfb0..da5a16129 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -7,7 +7,6 @@ import { openConversationExternal } from '../state/ducks/conversations'; import { LeftPaneContactSection } from './session/LeftPaneContactSection'; import { LeftPaneSettingSection } from './session/LeftPaneSettingSection'; import { SessionTheme } from '../state/ducks/SessionTheme'; -import { SessionOffline } from './session/network/SessionOffline'; import { SessionExpiredWarning } from './session/network/SessionExpiredWarning'; import { getFocusedSection } from '../state/selectors/section'; import { useDispatch, useSelector } from 'react-redux'; @@ -44,7 +43,6 @@ const InnerLeftPaneMessageSection = (props: { isExpired: boolean }) => { return ( <> - {props.isExpired && } { return ( <> - dispatch(openConversationExternal(id, messageId)) diff --git a/ts/components/OnionStatusPathDialog.tsx b/ts/components/OnionStatusPathDialog.tsx index 72c386aed..a7099318e 100644 --- a/ts/components/OnionStatusPathDialog.tsx +++ b/ts/components/OnionStatusPathDialog.tsx @@ -20,11 +20,10 @@ import { onionPathModal } from '../state/ducks/modalDialog'; import { getFirstOnionPath, getFirstOnionPathLength, + getIsOnline, getOnionPathsCount, } from '../state/selectors/onions'; -// tslint:disable-next-line: no-submodule-imports -import useNetworkState from 'react-use/lib/useNetworkState'; import { SessionSpinner } from './session/SessionSpinner'; import { Flex } from './basic/Flex'; @@ -36,9 +35,10 @@ export type StatusLightType = { const OnionPathModalInner = () => { const onionPath = useSelector(getFirstOnionPath); + const isOnline = useSelector(getIsOnline); // including the device and destination in calculation const glowDuration = onionPath.length + 2; - if (!onionPath || onionPath.length === 0) { + if (!isOnline || !onionPath || onionPath.length === 0) { return ; } @@ -144,7 +144,7 @@ export const ActionPanelOnionStatusLight = (props: { const theme = useTheme(); const onionPathsCount = useSelector(getOnionPathsCount); const firstPathLength = useSelector(getFirstOnionPathLength); - const isOnline = useNetworkState().online; + const isOnline = useSelector(getIsOnline); // Set icon color based on result const red = theme.colors.destructive; @@ -164,6 +164,9 @@ export const ActionPanelOnionStatusLight = (props: { iconType={SessionIconType.Circle} iconColor={iconColor} onClick={handleClick} + glowDuration={10} + glowStartDelay={0} + noScale={true} isSelected={isSelected} theme={theme} /> diff --git a/ts/components/conversation/ModeratorsAddDialog.tsx b/ts/components/conversation/ModeratorsAddDialog.tsx index dab5f1e31..5a4649d94 100644 --- a/ts/components/conversation/ModeratorsAddDialog.tsx +++ b/ts/components/conversation/ModeratorsAddDialog.tsx @@ -47,7 +47,7 @@ export const AddModeratorsDialog = (props: Props) => { if (!isAdded) { window?.log?.warn('failed to add moderators:', isAdded); - ToastUtils.pushUserNeedsToHaveJoined(); + ToastUtils.pushFailedToAddAsModerator(); } else { window?.log?.info(`${pubkey.key} added as moderator...`); ToastUtils.pushUserAddedToModerators(); diff --git a/ts/components/conversation/ModeratorsRemoveDialog.tsx b/ts/components/conversation/ModeratorsRemoveDialog.tsx index b3c1eb7fb..30d6d10d3 100644 --- a/ts/components/conversation/ModeratorsRemoveDialog.tsx +++ b/ts/components/conversation/ModeratorsRemoveDialog.tsx @@ -205,7 +205,7 @@ export class RemoveModeratorsDialog extends React.Component { if (!res) { window?.log?.warn('failed to remove moderators:', res); - ToastUtils.pushUserNeedsToHaveJoined(); + ToastUtils.pushFailedToRemoveFromModerator(); } else { window?.log?.info(`${removedMods} removed from moderators...`); ToastUtils.pushUserRemovedFromModerators(); diff --git a/ts/components/conversation/message/ClickToTrustSender.tsx b/ts/components/conversation/message/ClickToTrustSender.tsx index 43eb71736..257755185 100644 --- a/ts/components/conversation/message/ClickToTrustSender.tsx +++ b/ts/components/conversation/message/ClickToTrustSender.tsx @@ -58,6 +58,9 @@ export const ClickToTrustSender = (props: { messageId: string }) => { const downloadedAttachments = await Promise.all( msgAttachments.map(async (attachment: any, index: any) => { + if (attachment.path) { + return { ...attachment, pending: false }; + } return AttachmentDownloads.addJob(attachment, { messageId: message.id, type: 'attachment', diff --git a/ts/components/session/ActionsPanel.tsx b/ts/components/session/ActionsPanel.tsx index 5b3d34982..6c32f113d 100644 --- a/ts/components/session/ActionsPanel.tsx +++ b/ts/components/session/ActionsPanel.tsx @@ -245,9 +245,7 @@ const doAppStartUp = () => { debounce(triggerAvatarReUploadIfNeeded, 200); // TODO: Investigate the case where we reconnect - const ourKey = UserUtils.getOurPubKeyStrFromCache(); - getSwarmPollingInstance().addPubkey(ourKey); - getSwarmPollingInstance().start(); + void getSwarmPollingInstance().start(); }; /** diff --git a/ts/components/session/SessionConfirm.tsx b/ts/components/session/SessionConfirm.tsx index 27b40bb93..190c11632 100644 --- a/ts/components/session/SessionConfirm.tsx +++ b/ts/components/session/SessionConfirm.tsx @@ -17,7 +17,7 @@ export interface SessionConfirmDialogProps { title?: string; onOk?: any; onClose?: any; - onClickOk?: () => any; + onClickOk?: () => Promise | void; onClickClose?: () => any; okText?: string; cancelText?: string; diff --git a/ts/components/session/SessionToggle.tsx b/ts/components/session/SessionToggle.tsx index eb7ed2de4..378b18a46 100644 --- a/ts/components/session/SessionToggle.tsx +++ b/ts/components/session/SessionToggle.tsx @@ -1,84 +1,63 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; import { updateConfirmModal } from '../../state/ducks/modalDialog'; +import { useDispatch } from 'react-redux'; -interface Props { +type Props = { active: boolean; - onClick: any; + onClick: () => void; confirmationDialogParams?: any | undefined; +}; - updateConfirmModal?: any; -} +export const SessionToggle = (props: Props) => { + const [active, setActive] = useState(false); -interface State { - active: boolean; -} - -export class SessionToggle extends React.PureComponent { - public static defaultProps = { - onClick: () => null, - }; - - constructor(props: any) { - super(props); - this.clickHandler = this.clickHandler.bind(this); - - const { active } = this.props; + const dispatch = useDispatch(); - this.state = { - active: active, - }; - } - - public render() { - return ( -
-
-
- ); - } + useEffect(() => { + setActive(props.active); + }, []); - private clickHandler(event: any) { + const clickHandler = (event: any) => { const stateManager = (e: any) => { - this.setState({ - active: !this.state.active, - }); - - if (this.props.onClick) { - e.stopPropagation(); - this.props.onClick(); - } + setActive(!active); + e.stopPropagation(); + props.onClick(); }; - if ( - this.props.confirmationDialogParams && - this.props.updateConfirmModal && - this.props.confirmationDialogParams.shouldShowConfirm() - ) { + if (props.confirmationDialogParams && props.confirmationDialogParams.shouldShowConfirm()) { // If item needs a confirmation dialog to turn ON, render it const closeConfirmModal = () => { - this.props.updateConfirmModal(null); + dispatch(updateConfirmModal(null)); }; - this.props.updateConfirmModal({ - onClickOk: () => { - stateManager(event); - closeConfirmModal(); - }, - onClickClose: () => { - this.props.updateConfirmModal(null); - }, - ...this.props.confirmationDialogParams, - updateConfirmModal, - }); + dispatch( + updateConfirmModal({ + onClickOk: () => { + stateManager(event); + closeConfirmModal(); + }, + onClickClose: () => { + updateConfirmModal(null); + }, + ...props.confirmationDialogParams, + updateConfirmModal, + }) + ); return; } stateManager(event); - } -} + }; + + return ( +
+
+
+ ); +}; diff --git a/ts/components/session/conversation/SessionCompositionBox.tsx b/ts/components/session/conversation/SessionCompositionBox.tsx index 086c345fd..bbc505ef2 100644 --- a/ts/components/session/conversation/SessionCompositionBox.tsx +++ b/ts/components/session/conversation/SessionCompositionBox.tsx @@ -37,6 +37,11 @@ import { getMentionsInput } from '../../../state/selectors/mentionsInput'; import { updateConfirmModal } from '../../../state/ducks/modalDialog'; import { SessionButtonColor } from '../SessionButton'; import { SessionConfirmDialogProps } from '../SessionConfirm'; +import { + createOrUpdateItem, + getItemById, + hasLinkPreviewPopupBeenDisplayed, +} from '../../../data/data'; export interface ReplyingToMessageProps { convoId: string; @@ -218,7 +223,7 @@ export class SessionCompositionBox extends React.Component { imgBlob = item.getAsFile(); break; case 'text': - this.showLinkSharingConfirmationModalDialog(e); + void this.showLinkSharingConfirmationModalDialog(e); break; default: } @@ -237,18 +242,24 @@ export class SessionCompositionBox extends React.Component { * Check if what is pasted is a URL and prompt confirmation for a setting change * @param e paste event */ - private showLinkSharingConfirmationModalDialog(e: any) { + private async showLinkSharingConfirmationModalDialog(e: any) { const pastedText = e.clipboardData.getData('text'); if (this.isURL(pastedText)) { + const alreadyDisplayedPopup = + (await getItemById(hasLinkPreviewPopupBeenDisplayed))?.value || false; window.inboxStore?.dispatch( updateConfirmModal({ - shouldShowConfirm: () => !window.getSettingValue('link-preview-setting'), + shouldShowConfirm: () => + !window.getSettingValue('link-preview-setting') && !alreadyDisplayedPopup, title: window.i18n('linkPreviewsTitle'), message: window.i18n('linkPreviewsConfirmMessage'), okTheme: SessionButtonColor.Danger, onClickOk: () => { window.setSettingValue('link-preview-setting', true); }, + onClickClose: async () => { + await createOrUpdateItem({ id: hasLinkPreviewPopupBeenDisplayed, value: true }); + }, }) ); } @@ -802,12 +813,6 @@ export class SessionCompositionBox extends React.Component { const { isBlocked, isPrivate, left, isKickedFromGroup } = this.props; - // deny sending of message if our app version is expired - if (window.extension.expiredStatus() === true) { - ToastUtils.pushToastError('expiredWarning', window.i18n('expiredWarning')); - return; - } - if (isBlocked && isPrivate) { ToastUtils.pushUnblockToSend(); return; diff --git a/ts/components/session/icon/SessionIcon.tsx b/ts/components/session/icon/SessionIcon.tsx index 1818cf674..8ecaadf9d 100644 --- a/ts/components/session/icon/SessionIcon.tsx +++ b/ts/components/session/icon/SessionIcon.tsx @@ -12,6 +12,7 @@ export type SessionIconProps = { glowDuration?: number; borderRadius?: number; glowStartDelay?: number; + noScale?: boolean; theme?: DefaultTheme; }; @@ -46,6 +47,7 @@ type StyledSvgProps = { borderRadius?: number; glowDuration?: number; glowStartDelay?: number; + noScale?: boolean; iconColor?: string; }; @@ -91,16 +93,22 @@ const animation = (props: { glowDuration?: number; glowStartDelay?: number; iconColor?: string; + noScale?: boolean; }) => { if (props.rotateDuration) { return css` ${rotate} ${props.rotateDuration}s infinite linear; `; - } else if ( - props.glowDuration !== undefined && - props.glowStartDelay !== undefined && - props.iconColor - ) { + } + if (props.noScale) { + return css``; + } + + if (props.glowDuration === 10) { + console.warn('scake', props); + } + + if (props.glowDuration !== undefined && props.glowStartDelay !== undefined && props.iconColor) { return css` ${glow( props.iconColor, @@ -108,9 +116,9 @@ const animation = (props: { props.glowStartDelay )} ${props.glowDuration}s ease infinite; `; - } else { - return; } + + return; }; //tslint:disable no-unnecessary-callback-wrapper @@ -119,6 +127,7 @@ const Svg = styled.svg` transform: ${props => `rotate(${props.iconRotation}deg)`}; animation: ${props => animation(props)}; border-radius: ${props => props.borderRadius}; + filter: ${props => (props.noScale ? `drop-shadow(0px 0px 4px ${props.iconColor})` : '')}; `; //tslint:enable no-unnecessary-callback-wrapper @@ -132,6 +141,7 @@ const SessionSvg = (props: { rotateDuration?: number; glowDuration?: number; glowStartDelay?: number; + noScale?: boolean; borderRadius?: number; theme: DefaultTheme; }) => { @@ -146,6 +156,7 @@ const SessionSvg = (props: { glowDuration: props.glowDuration, glowStartDelay: props.glowStartDelay, iconColor: props.iconColor, + noScale: props.noScale, }; return ( @@ -166,6 +177,7 @@ export const SessionIcon = (props: SessionIconProps) => { glowDuration, borderRadius, glowStartDelay, + noScale, } = props; let { iconSize, iconRotation } = props; iconSize = iconSize || SessionIconSize.Medium; @@ -189,6 +201,7 @@ export const SessionIcon = (props: SessionIconProps) => { rotateDuration={rotateDuration} glowDuration={glowDuration} glowStartDelay={glowStartDelay} + noScale={noScale} borderRadius={borderRadius} iconRotation={iconRotation} iconColor={iconColor} diff --git a/ts/components/session/icon/SessionIconButton.tsx b/ts/components/session/icon/SessionIconButton.tsx index fe4c410e8..495cdce2e 100644 --- a/ts/components/session/icon/SessionIconButton.tsx +++ b/ts/components/session/icon/SessionIconButton.tsx @@ -20,6 +20,9 @@ export const SessionIconButton = (props: SProps) => { isSelected, notificationCount, theme, + glowDuration, + glowStartDelay, + noScale, } = props; const clickHandler = (e: any) => { if (props.onClick) { @@ -42,6 +45,9 @@ export const SessionIconButton = (props: SProps) => { iconColor={iconColor} iconRotation={iconRotation} theme={themeToUSe} + glowDuration={glowDuration} + glowStartDelay={glowStartDelay} + noScale={noScale} /> {Boolean(notificationCount) && }
diff --git a/ts/components/session/menu/Menu.tsx b/ts/components/session/menu/Menu.tsx index 39b741a86..e7c0bc1b5 100644 --- a/ts/components/session/menu/Menu.tsx +++ b/ts/components/session/menu/Menu.tsx @@ -25,6 +25,7 @@ import { showUpdateGroupNameByConvoId, unblockConvoById, } from '../../../interactions/conversationInteractions'; +import { SessionButtonColor } from '../SessionButton'; function showTimerOptions( isPublic: boolean, @@ -162,9 +163,9 @@ export function getDeleteContactMenuItem( ? window.i18n('leaveGroupConfirmation') : window.i18n('deleteContactConfirmation'), onClickClose, - onClickOk: () => { - void getConversationController().deleteContact(conversationId); - onClickClose(); + okTheme: SessionButtonColor.Danger, + onClickOk: async () => { + await getConversationController().deleteContact(conversationId); }, }) ); diff --git a/ts/components/session/network/SessionOffline.tsx b/ts/components/session/network/SessionOffline.tsx deleted file mode 100644 index 3dbb4a6b4..000000000 --- a/ts/components/session/network/SessionOffline.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; - -// tslint:disable-next-line: no-submodule-imports -import useNetworkState from 'react-use/lib/useNetworkState'; -import styled from 'styled-components'; - -type ContainerProps = { - show: boolean; -}; - -const OfflineContainer = styled.div` - background: ${props => props.theme.colors.accent}; - color: ${props => props.theme.colors.textColor}; - padding: ${props => (props.show ? props.theme.common.margins.sm : '0px')}; - margin: ${props => (props.show ? props.theme.common.margins.xs : '0px')}; - height: ${props => (props.show ? 'auto' : '0px')}; - overflow: hidden; - transition: ${props => props.theme.common.animations.defaultDuration}; -`; - -const OfflineTitle = styled.h3` - padding-top: 0px; - margin-top: 0px; -`; - -const OfflineMessage = styled.div``; - -export const SessionOffline = () => { - const isOnline = useNetworkState().online; - return ( - - {window.i18n('offline')} - {window.i18n('checkNetworkConnection')} - - ); -}; diff --git a/ts/components/session/settings/SessionSettingListItem.tsx b/ts/components/session/settings/SessionSettingListItem.tsx index f205fd487..fe04793e1 100644 --- a/ts/components/session/settings/SessionSettingListItem.tsx +++ b/ts/components/session/settings/SessionSettingListItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import Slider from 'rc-slider'; @@ -9,7 +9,7 @@ import { SessionSettingType } from './SessionSettings'; import { SessionRadioGroup } from '../SessionRadioGroup'; import { SessionConfirmDialogProps } from '../SessionConfirm'; -interface Props { +type Props = { title?: string; description?: string; type: SessionSettingType | undefined; @@ -19,112 +19,79 @@ interface Props { onSliderChange?: any; content: any; confirmationDialogParams?: SessionConfirmDialogProps; +}; - // for updating modal in redux - updateConfirmModal?: any; -} - -interface State { - sliderValue: number | null; -} +export const SessionSettingListItem = (props: Props) => { + const handleSlider = (valueToForward: any) => { + if (props.onSliderChange) { + props.onSliderChange(valueToForward); + } -export class SessionSettingListItem extends React.Component { - public static defaultProps = { - inline: true, + setSliderValue(valueToForward); }; - public constructor(props: Props) { - super(props); - this.state = { - sliderValue: null, - }; - - this.handleClick = this.handleClick.bind(this); - } - - public render(): JSX.Element { - const { title, description, type, value, content } = this.props; - const inline = - !!type && ![SessionSettingType.Options, SessionSettingType.Slider].includes(type); + const [sliderValue, setSliderValue] = useState(null); - const currentSliderValue = - type === SessionSettingType.Slider && (this.state.sliderValue || value); + const { title, description, type, value, content } = props; + const inline = !!type && ![SessionSettingType.Options, SessionSettingType.Slider].includes(type); - return ( -
-
-
{title}
+ const currentSliderValue = type === SessionSettingType.Slider && (sliderValue || value); - {description &&
{description}
} -
+ return ( +
+
+
{title}
-
- {type === SessionSettingType.Toggle && ( -
- -
- )} + {description &&
{description}
} +
- {type === SessionSettingType.Button && ( - + {type === SessionSettingType.Toggle && ( +
+ props.onClick?.()} + confirmationDialogParams={props.confirmationDialogParams} /> - )} - - {type === SessionSettingType.Options && ( - { - this.props.onClick(selectedRadioValue); - }} +
+ )} + + {type === SessionSettingType.Button && ( + props.onClick?.()} + /> + )} + + {type === SessionSettingType.Options && ( + { + props.onClick(selectedRadioValue); + }} + /> + )} + + {type === SessionSettingType.Slider && ( +
+ - )} - {type === SessionSettingType.Slider && ( -
- { - this.handleSlider(sliderValue); - }} - /> - -
-

{content.info(currentSliderValue)}

-
+
+

{content.info(currentSliderValue)}

- )} -
+
+ )}
- ); - } - - private handleClick() { - if (this.props.onClick) { - this.props.onClick(); - } - } - - private handleSlider(value: any) { - if (this.props.onSliderChange) { - this.props.onSliderChange(value); - } - - this.setState({ - sliderValue: value, - }); - } -} +
+ ); +}; diff --git a/ts/components/session/settings/SessionSettings.tsx b/ts/components/session/settings/SessionSettings.tsx index d03453754..2c86f3e8a 100644 --- a/ts/components/session/settings/SessionSettings.tsx +++ b/ts/components/session/settings/SessionSettings.tsx @@ -9,7 +9,11 @@ import { StateType } from '../../../state/reducer'; import { getConversationController } from '../../../session/conversations'; import { getConversationLookup } from '../../../state/selectors/conversations'; import { connect, useSelector } from 'react-redux'; -import { getPasswordHash } from '../../../../ts/data/data'; +import { + createOrUpdateItem, + getPasswordHash, + hasLinkPreviewPopupBeenDisplayed, +} from '../../../../ts/data/data'; import { SpacerLG, SpacerXS } from '../../basic/Text'; import { shell } from 'electron'; import { SessionConfirmDialogProps } from '../SessionConfirm'; @@ -40,7 +44,6 @@ export interface SettingsViewProps { // pass the conversation as props, so our render is called everytime they change. // we have to do this to make the list refresh on unblock() conversations?: ConversationLookupType; - updateConfirmModal?: any; } interface State { @@ -156,7 +159,6 @@ class SettingsViewInner extends React.Component { onSliderChange={sliderFn} content={content} confirmationDialogParams={setting.confirmationDialogParams} - updateConfirmModal={this.props.updateConfirmModal} /> )}
@@ -341,7 +343,13 @@ class SettingsViewInner extends React.Component { hidden: false, type: SessionSettingType.Toggle, category: SessionSettingCategory.Appearance, - setFn: window.toggleLinkPreview, + setFn: async () => { + const newValue = !window.getSettingValue('link-preview-setting'); + window.setSettingValue('link-preview-setting', newValue); + if (!newValue) { + await createOrUpdateItem({ id: hasLinkPreviewPopupBeenDisplayed, value: false }); + } + }, content: undefined, comparisonValue: undefined, onClick: undefined, diff --git a/ts/data/data.ts b/ts/data/data.ts index cb2826eea..77ca3175a 100644 --- a/ts/data/data.ts +++ b/ts/data/data.ts @@ -1,7 +1,7 @@ import Electron from 'electron'; const { ipcRenderer } = Electron; -// tslint:disable: function-name no-require-imports no-var-requires one-variable-per-declaration no-void-expression +// tslint:disable: no-require-imports no-var-requires one-variable-per-declaration no-void-expression import _ from 'lodash'; import { ConversationCollection, ConversationModel } from '../models/conversation'; @@ -64,6 +64,7 @@ export type ServerToken = { export const hasSyncedInitialConfigurationItem = 'hasSyncedInitialConfigurationItem'; export const lastAvatarUploadTimestamp = 'lastAvatarUploadTimestamp'; +export const hasLinkPreviewPopupBeenDisplayed = 'hasLinkPreviewPopupBeenDisplayed'; const channelsToMake = { shutdown, diff --git a/ts/interactions/conversationInteractions.ts b/ts/interactions/conversationInteractions.ts index ce9b35e1f..2aa01feef 100644 --- a/ts/interactions/conversationInteractions.ts +++ b/ts/interactions/conversationInteractions.ts @@ -36,6 +36,7 @@ import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsMana import { IMAGE_JPEG } from '../types/MIME'; import { FSv2 } from '../fileserver'; import { fromBase64ToArray, toHex } from '../session/utils/String'; +import { SessionButtonColor } from '../components/session/SessionButton'; export const getCompleteUrlForV2ConvoId = async (convoId: string) => { if (convoId.match(openGroupV2ConversationIdRegex)) { @@ -219,8 +220,8 @@ export function showLeaveGroupByConvoId(conversationId: string) { updateConfirmModal({ title, message, - onClickOk: () => { - void conversation.leaveClosedGroup(); + onClickOk: async () => { + await conversation.leaveClosedGroup(); onClickClose(); }, onClickClose, @@ -302,8 +303,8 @@ export function deleteMessagesByConvoIdWithConfirmation(conversationId: string) window?.inboxStore?.dispatch(updateConfirmModal(null)); }; - const onClickOk = () => { - void deleteMessagesByConvoIdNoConfirmation(conversationId); + const onClickOk = async () => { + await deleteMessagesByConvoIdNoConfirmation(conversationId); onClickClose(); }; @@ -312,6 +313,7 @@ export function deleteMessagesByConvoIdWithConfirmation(conversationId: string) title: window.i18n('deleteMessages'), message: window.i18n('deleteConversationConfirmation'), onClickOk, + okTheme: SessionButtonColor.Danger, onClickClose, }) ); diff --git a/ts/interactions/messageInteractions.ts b/ts/interactions/messageInteractions.ts index 8199e427a..bfc21fbd0 100644 --- a/ts/interactions/messageInteractions.ts +++ b/ts/interactions/messageInteractions.ts @@ -134,7 +134,7 @@ export async function removeSenderFromModerator(sender: string, convoId: string) if (!res) { window?.log?.warn('failed to remove moderator:', res); - ToastUtils.pushErrorHappenedWhileRemovingModerator(); + ToastUtils.pushFailedToRemoveFromModerator(); } else { window?.log?.info(`${pubKeyToRemove.key} removed from moderators...`); ToastUtils.pushUserRemovedFromModerators(); @@ -154,7 +154,7 @@ export async function addSenderAsModerator(sender: string, convoId: string) { if (!res) { window?.log?.warn('failed to add moderator:', res); - ToastUtils.pushUserNeedsToHaveJoined(); + ToastUtils.pushFailedToAddAsModerator(); } else { window?.log?.info(`${pubKeyToAdd.key} added to moderators...`); ToastUtils.pushUserAddedToModerators(); @@ -173,7 +173,9 @@ const acceptOpenGroupInvitationV2 = (completeUrl: string, roomName?: string) => updateConfirmModal({ title: window.i18n('joinOpenGroupAfterInvitationConfirmationTitle', roomName), message: window.i18n('joinOpenGroupAfterInvitationConfirmationDesc', roomName), - onClickOk: () => joinOpenGroupV2WithUIEvents(completeUrl, true, false), + onClickOk: async () => { + await joinOpenGroupV2WithUIEvents(completeUrl, true, false); + }, onClickClose, }) diff --git a/ts/models/conversation.ts b/ts/models/conversation.ts index bae1f445a..c8c13fac0 100644 --- a/ts/models/conversation.ts +++ b/ts/models/conversation.ts @@ -44,6 +44,7 @@ import { NotificationForConvoOption } from '../components/conversation/Conversat import { useDispatch } from 'react-redux'; import { updateConfirmModal } from '../state/ducks/modalDialog'; import { createTaskWithTimeout } from '../session/utils/TaskWithTimeout'; +import { DURATION, SWARM_POLLING_TIMEOUT } from '../session/constants'; export enum ConversationTypeEnum { GROUP = 'group', diff --git a/ts/models/message.ts b/ts/models/message.ts index d86455acb..339058372 100644 --- a/ts/models/message.ts +++ b/ts/models/message.ts @@ -846,7 +846,7 @@ export class MessageModel extends Backbone.Model { const chatParams = { identifier: this.id, body, - timestamp: this.get('sent_at') || Date.now(), + timestamp: Date.now(), // force a new timestamp to handle user fixed his clock expireTimer: this.get('expireTimer'), attachments, preview, diff --git a/ts/receiver/queuedJob.ts b/ts/receiver/queuedJob.ts index 70096487a..cc951424f 100644 --- a/ts/receiver/queuedJob.ts +++ b/ts/receiver/queuedJob.ts @@ -318,7 +318,6 @@ async function handleRegularMessage( if (existingExpireTimer) { message.set({ expireTimer: existingExpireTimer }); - message.set({ expirationStartTimestamp: now }); } // Expire timer updates are now explicit. diff --git a/ts/session/constants.ts b/ts/session/constants.ts index eebeb4120..df97e93d2 100644 --- a/ts/session/constants.ts +++ b/ts/session/constants.ts @@ -17,6 +17,12 @@ export const TTL_DEFAULT = { TTL_MAX: 14 * DURATION.DAYS, }; +export const SWARM_POLLING_TIMEOUT = { + ACTIVE: DURATION.SECONDS * 5, + MEDIUM_ACTIVE: DURATION.SECONDS * 60, + INACTIVE: DURATION.MINUTES * 60, +}; + export const PROTOCOLS = { // tslint:disable-next-line: no-http-string HTTP: 'http:', diff --git a/ts/session/conversations/ConversationController.ts b/ts/session/conversations/ConversationController.ts index fdf7faed1..5d249c1b4 100644 --- a/ts/session/conversations/ConversationController.ts +++ b/ts/session/conversations/ConversationController.ts @@ -116,7 +116,7 @@ export class ConversationController { conversation.initialPromise = create(); conversation.initialPromise.then(async () => { - if (window.inboxStore) { + if (window?.inboxStore) { window.inboxStore?.dispatch( conversationActions.conversationAdded(conversation.id, conversation.getProps()) ); @@ -242,7 +242,7 @@ export class ConversationController { window.log.info(`deleteContact !isPrivate, convo removed from DB: ${id}`); this.conversations.remove(conversation); - if (window.inboxStore) { + if (window?.inboxStore) { window.inboxStore?.dispatch(conversationActions.conversationRemoved(conversation.id)); window.inboxStore?.dispatch( conversationActions.conversationChanged(conversation.id, conversation.getProps()) @@ -310,7 +310,7 @@ export class ConversationController { public reset() { this._initialPromise = Promise.resolve(); this._initialFetchComplete = false; - if (window.inboxStore) { + if (window?.inboxStore) { window.inboxStore?.dispatch(conversationActions.removeAllConversations()); } this.conversations.reset([]); diff --git a/ts/session/messages/MessageController.ts b/ts/session/messages/MessageController.ts index aaa7d37a1..c3e276abe 100644 --- a/ts/session/messages/MessageController.ts +++ b/ts/session/messages/MessageController.ts @@ -69,7 +69,6 @@ export class MessageController { }); } - // tslint:disable-next-line: function-name public get(identifier: string) { return this.messageLookup.get(identifier); } diff --git a/ts/session/onions/onionSend.ts b/ts/session/onions/onionSend.ts index 3af4cd57d..934a18731 100644 --- a/ts/session/onions/onionSend.ts +++ b/ts/session/onions/onionSend.ts @@ -179,7 +179,7 @@ export const sendViaOnion = async ( { retries: 9, // each path can fail 3 times before being dropped, we have 3 paths at most factor: 2, - minTimeout: 1000, + minTimeout: 100, maxTimeout: 4000, onFailedAttempt: e => { window?.log?.warn( diff --git a/ts/session/sending/LokiMessageApi.ts b/ts/session/sending/LokiMessageApi.ts index eb5d7863e..d2ecbb800 100644 --- a/ts/session/sending/LokiMessageApi.ts +++ b/ts/session/sending/LokiMessageApi.ts @@ -70,11 +70,11 @@ export async function sendMessage( ); throw e; } - if (!snode) { + if (!usedNodes || usedNodes.length === 0) { throw new window.textsecure.EmptySwarmError(pubKey, 'Ran out of swarm nodes to query'); - } else { - window?.log?.info( - `loki_message:::sendMessage - Successfully stored message to ${pubKey} via ${snode.ip}:${snode.port}` - ); } + + window?.log?.info( + `loki_message:::sendMessage - Successfully stored message to ${pubKey} via ${snode.ip}:${snode.port}` + ); } diff --git a/ts/session/snode_api/SNodeAPI.ts b/ts/session/snode_api/SNodeAPI.ts index 8df50f5db..911d4a8d3 100644 --- a/ts/session/snode_api/SNodeAPI.ts +++ b/ts/session/snode_api/SNodeAPI.ts @@ -24,11 +24,14 @@ import { toHex, } from '../utils/String'; import { Snode } from '../../data/data'; +import { updateIsOnline } from '../../state/ducks/onion'; // ONS name can have [a-zA-Z0-9_-] except that - is not allowed as start or end // do not define a regex but rather create it on the fly to avoid https://stackoverflow.com/questions/3891641/regex-test-only-works-every-other-time export const onsNameRegex = '^\\w([\\w-]*[\\w])?$'; +export const ERROR_CODE_NO_CONNECT = 'ENETUNREACH: No network connection.'; + const getSslAgentForSeedNode = (seedNodeHost: string, isSsl = false) => { let filePrefix = ''; let pubkey256 = ''; @@ -493,8 +496,8 @@ export async function storeOnNode(targetNode: Snode, params: SendParams): Promis e, `destination ${targetNode.ip}:${targetNode.port}` ); + throw e; } - return false; } /** */ @@ -527,9 +530,12 @@ export async function retrieveNextMessages( try { const json = JSON.parse(result.body); + window.inboxStore?.dispatch(updateIsOnline(true)); + return json.messages || []; } catch (e) { window?.log?.warn('exception while parsing json of nextMessage:', e); + window.inboxStore?.dispatch(updateIsOnline(true)); return []; } @@ -538,6 +544,11 @@ export async function retrieveNextMessages( 'Got an error while retrieving next messages. Not retrying as we trigger fetch often:', e ); + if (e.message === ERROR_CODE_NO_CONNECT) { + window.inboxStore?.dispatch(updateIsOnline(false)); + } else { + window.inboxStore?.dispatch(updateIsOnline(true)); + } return []; } } diff --git a/ts/session/snode_api/onions.ts b/ts/session/snode_api/onions.ts index f8c4b7124..a4e81ed25 100644 --- a/ts/session/snode_api/onions.ts +++ b/ts/session/snode_api/onions.ts @@ -13,6 +13,7 @@ import { hrefPnServerDev, hrefPnServerProd } from '../../pushnotification/PnServ let snodeFailureCount: Record = {}; import { Snode } from '../../data/data'; +import { ERROR_CODE_NO_CONNECT } from './SNodeAPI'; // tslint:disable-next-line: variable-name export const TEST_resetSnodeFailureCount = () => { @@ -37,6 +38,9 @@ export interface SnodeResponse { export const NEXT_NODE_NOT_FOUND_PREFIX = 'Next node not found: '; +export const CLOCK_OUT_OF_SYNC_MESSAGE_ERROR = + 'Your clock is out of sync with the network. Check your clock.'; + // Returns the actual ciphertext, symmetric key that will be used // for decryption, and an ephemeral_key to send to the next hop async function encryptForPubKey(pubKeyX25519hex: string, reqObj: any): Promise { @@ -195,9 +199,8 @@ async function buildOnionGuardNodePayload( function process406Error(statusCode: number) { if (statusCode === 406) { // clock out of sync - console.warn('clock out of sync todo'); // this will make the pRetry stop - throw new pRetry.AbortError('You clock is out of sync with the network. Check your clock.'); + throw new pRetry.AbortError(CLOCK_OUT_OF_SYNC_MESSAGE_ERROR); } } @@ -783,6 +786,7 @@ const sendOnionRequest = async ({ // we are talking to a snode... agent: snodeHttpsAgent, abortSignal, + timeout: 5000, }; const guardUrl = `https://${guardNode.ip}:${guardNode.port}/onion_req/v2`; @@ -859,7 +863,7 @@ export async function lokiOnionFetch( return onionFetchRetryable(targetNode, body, associatedWith); }, { - retries: 9, + retries: 4, factor: 1, minTimeout: 1000, maxTimeout: 2000, @@ -875,6 +879,14 @@ export async function lokiOnionFetch( } catch (e) { window?.log?.warn('onionFetchRetryable failed ', e); // console.warn('error to show to user'); + if (e?.errno === 'ENETUNREACH') { + // better handle the no connection state + throw new Error(ERROR_CODE_NO_CONNECT); + } + if (e?.message === CLOCK_OUT_OF_SYNC_MESSAGE_ERROR) { + window?.log?.warn('Its an clock out of sync error '); + throw new pRetry.AbortError(CLOCK_OUT_OF_SYNC_MESSAGE_ERROR); + } throw e; } } diff --git a/ts/session/snode_api/swarmPolling.ts b/ts/session/snode_api/swarmPolling.ts index ea2512e45..40a45c194 100644 --- a/ts/session/snode_api/swarmPolling.ts +++ b/ts/session/snode_api/swarmPolling.ts @@ -1,5 +1,5 @@ import { PubKey } from '../types'; -import { getSwarmFor } from './snodePool'; +import * as snodePool from './snodePool'; import { retrieveNextMessages } from './SNodeAPI'; import { SignalService } from '../../protobuf'; import * as Receiver from '../../receiver/receiver'; @@ -12,9 +12,10 @@ import { updateLastHash, } from '../../../ts/data/data'; -import { StringUtils } from '../../session/utils'; -import { getConversationController } from '../conversations'; +import { StringUtils, UserUtils } from '../../session/utils'; import { ConversationModel } from '../../models/conversation'; +import { DURATION, SWARM_POLLING_TIMEOUT } from '../constants'; +import { getConversationController } from '../conversations'; type PubkeyToHash = { [key: string]: string }; @@ -50,49 +51,125 @@ export const getSwarmPollingInstance = () => { }; export class SwarmPolling { - private pubkeys: Array; - private groupPubkeys: Array; + private groupPolling: Array<{ pubkey: PubKey; lastPolledTimestamp: number }>; private readonly lastHashes: { [key: string]: PubkeyToHash }; constructor() { - this.pubkeys = []; - this.groupPubkeys = []; + this.groupPolling = []; this.lastHashes = {}; } - public start(): void { + public async start(waitForFirstPoll = false): Promise { this.loadGroupIds(); - void this.pollForAllKeys(); + if (waitForFirstPoll) { + await this.TEST_pollForAllKeys(); + } else { + void this.TEST_pollForAllKeys(); + } } - public addGroupId(pubkey: PubKey) { - if (this.groupPubkeys.findIndex(m => m.key === pubkey.key) === -1) { - window?.log?.info('Swarm addGroupId: adding pubkey to polling', pubkey.key); - this.groupPubkeys.push(pubkey); - } + /** + * Used fo testing only + */ + public TEST_reset() { + this.groupPolling = []; } - public addPubkey(pk: PubKey | string) { - const pubkey = PubKey.cast(pk); - if (this.pubkeys.findIndex(m => m.key === pubkey.key) === -1) { - this.pubkeys.push(pubkey); + public addGroupId(pubkey: PubKey) { + if (this.groupPolling.findIndex(m => m.pubkey.key === pubkey.key) === -1) { + window?.log?.info('Swarm addGroupId: adding pubkey to polling', pubkey.key); + this.groupPolling.push({ pubkey, lastPolledTimestamp: 0 }); } } public removePubkey(pk: PubKey | string) { const pubkey = PubKey.cast(pk); window?.log?.info('Swarm removePubkey: removing pubkey from polling', pubkey.key); + this.groupPolling = this.groupPolling.filter(group => !pubkey.isEqual(group.pubkey)); + } + + /** + * Only public for testing + * As of today, we pull closed group pubkeys as follow: + * if activeAt is not set, poll only once per hour + * if activeAt is less than an hour old, poll every 5 seconds or so + * if activeAt is less than a day old, poll every minutes only. + * If activeAt is more than a day old, poll only once per hour + */ + public TEST_getPollingTimeout(convoId: PubKey) { + const convo = getConversationController().get(convoId.key); + if (!convo) { + return SWARM_POLLING_TIMEOUT.INACTIVE; + } + const activeAt = convo.get('active_at'); + if (!activeAt) { + return SWARM_POLLING_TIMEOUT.INACTIVE; + } + + const currentTimestamp = Date.now(); - this.pubkeys = this.pubkeys.filter(key => !pubkey.isEqual(key)); - this.groupPubkeys = this.groupPubkeys.filter(key => !pubkey.isEqual(key)); + // consider that this is an active group if activeAt is less than an hour old + if (currentTimestamp - activeAt <= DURATION.HOURS * 1) { + return SWARM_POLLING_TIMEOUT.ACTIVE; + } + + if (currentTimestamp - activeAt <= DURATION.DAYS * 1) { + return SWARM_POLLING_TIMEOUT.MEDIUM_ACTIVE; + } + + return SWARM_POLLING_TIMEOUT.INACTIVE; } - protected async pollOnceForKey(pubkey: PubKey, isGroup: boolean) { + /** + * Only public for testing + */ + public async TEST_pollForAllKeys() { + // we always poll as often as possible for our pubkey + const ourPubkey = UserUtils.getOurPubKeyFromCache(); + const directPromise = this.TEST_pollOnceForKey(ourPubkey, false); + + const now = Date.now(); + const groupPromises = this.groupPolling.map(async group => { + const convoPollingTimeout = this.TEST_getPollingTimeout(group.pubkey); + + const diff = now - group.lastPolledTimestamp; + + const loggingId = + getConversationController() + .get(group.pubkey.key) + ?.idForLogging() || group.pubkey.key; + + if (diff >= convoPollingTimeout) { + (window?.log?.info || console.warn)( + `Polling for ${loggingId}; timeout: ${convoPollingTimeout} ; diff: ${diff}` + ); + return this.TEST_pollOnceForKey(group.pubkey, true); + } + (window?.log?.info || console.warn)( + `Not polling for ${loggingId}; timeout: ${convoPollingTimeout} ; diff: ${diff}` + ); + + return Promise.resolve(); + }); + try { + await Promise.all(_.concat(directPromise, groupPromises)); + } catch (e) { + (window?.log?.info || console.warn)('pollForAllKeys swallowing exception: ', e); + throw e; + } finally { + setTimeout(this.TEST_pollForAllKeys.bind(this), SWARM_POLLING_TIMEOUT.ACTIVE); + } + } + + /** + * Only exposed as public for testing + */ + public async TEST_pollOnceForKey(pubkey: PubKey, isGroup: boolean) { // NOTE: sometimes pubkey is string, sometimes it is object, so // accept both until this is fixed: const pkStr = pubkey.key; - const snodes = await getSwarmFor(pkStr); + const snodes = await snodePool.getSwarmFor(pkStr); // Select nodes for which we already have lastHashes const alreadyPolled = snodes.filter((n: Snode) => this.lastHashes[n.pubkey_ed25519]); @@ -123,6 +200,19 @@ export class SwarmPolling { // Merge results into one list of unique messages const messages = _.uniqBy(_.flatten(results), (x: any) => x.hash); + if (isGroup) { + // update the last fetched timestamp + this.groupPolling = this.groupPolling.map(group => { + if (PubKey.isEqual(pubkey, group.pubkey)) { + return { + ...group, + lastPolledTimestamp: Date.now(), + }; + } + return group; + }); + } + const newMessages = await this.handleSeenMessages(messages); newMessages.forEach((m: Message) => { @@ -133,7 +223,7 @@ export class SwarmPolling { // Fetches messages for `pubkey` from `node` potentially updating // the lash hash record - protected async pollNodeForKey(node: Snode, pubkey: PubKey): Promise> { + private async pollNodeForKey(node: Snode, pubkey: PubKey): Promise> { const edkey = node.pubkey_ed25519; const pkStr = pubkey.key; @@ -188,24 +278,6 @@ export class SwarmPolling { return newMessages; } - private async pollForAllKeys() { - const directPromises = this.pubkeys.map(async pk => { - return this.pollOnceForKey(pk, false); - }); - - const groupPromises = this.groupPubkeys.map(async pk => { - return this.pollOnceForKey(pk, true); - }); - try { - await Promise.all(_.concat(directPromises, groupPromises)); - } catch (e) { - window?.log?.warn('pollForAllKeys swallowing exception: ', e); - throw e; - } finally { - setTimeout(this.pollForAllKeys.bind(this), 2000); - } - } - private async updateLastHash( edkey: string, pubkey: PubKey, diff --git a/ts/session/types/PubKey.ts b/ts/session/types/PubKey.ts index 94ef6f686..63adcd9d9 100644 --- a/ts/session/types/PubKey.ts +++ b/ts/session/types/PubKey.ts @@ -152,6 +152,10 @@ export class PubKey { return key.replace(PubKey.PREFIX_GROUP_TEXTSECURE, ''); } + public static isEqual(comparator1: PubKey | string, comparator2: PubKey | string) { + return PubKey.cast(comparator1).isEqual(comparator2); + } + public isEqual(comparator: PubKey | string) { return comparator instanceof PubKey ? this.key === comparator.key diff --git a/ts/session/utils/AttachmentsDownload.ts b/ts/session/utils/AttachmentsDownload.ts index 6ae23e6f8..9df66264e 100644 --- a/ts/session/utils/AttachmentsDownload.ts +++ b/ts/session/utils/AttachmentsDownload.ts @@ -90,7 +90,6 @@ export async function addJob(attachment: any, job: any = {}) { }; } -// tslint:disable: function-name async function _tick() { await _maybeStartJob(); timeout = setTimeout(_tick, TICK_INTERVAL); diff --git a/ts/session/utils/Toast.tsx b/ts/session/utils/Toast.tsx index ba562a35b..c90fbb5bf 100644 --- a/ts/session/utils/Toast.tsx +++ b/ts/session/utils/Toast.tsx @@ -191,12 +191,12 @@ export function pushOnlyAdminCanRemove() { ); } -export function pushUserNeedsToHaveJoined() { - pushToastWarning( - 'userNeedsToHaveJoined', - window.i18n('userNeedsToHaveJoined'), - window.i18n('userNeedsToHaveJoinedDesc') - ); +export function pushFailedToAddAsModerator() { + pushToastWarning('failedToAddAsModerator', window.i18n('failedToAddAsModerator')); +} + +export function pushFailedToRemoveFromModerator() { + pushToastWarning('failedToRemoveFromModerator', window.i18n('failedToRemoveFromModerator')); } export function pushUserAddedToModerators() { @@ -210,11 +210,3 @@ export function pushUserRemovedFromModerators() { export function pushInvalidPubKey() { pushToastSuccess('invalidPubKey', window.i18n('invalidPubkeyFormat')); } - -export function pushErrorHappenedWhileRemovingModerator() { - pushToastError( - 'errorHappenedWhileRemovingModerator', - window.i18n('errorHappenedWhileRemovingModerator'), - window.i18n('errorHappenedWhileRemovingModeratorDesc') - ); -} diff --git a/ts/state/ducks/defaultRooms.tsx b/ts/state/ducks/defaultRooms.tsx index 40dd860e8..ab08894ad 100644 --- a/ts/state/ducks/defaultRooms.tsx +++ b/ts/state/ducks/defaultRooms.tsx @@ -33,7 +33,6 @@ const defaultRoomsSlice = createSlice({ }, updateDefaultRoomsInProgress(state, action) { const inProgress = action.payload as boolean; - window?.log?.info('fetching default rooms inProgress?', action.payload); return { ...state, inProgress }; }, updateDefaultBase64RoomData(state, action: PayloadAction) { diff --git a/ts/state/ducks/onion.tsx b/ts/state/ducks/onion.tsx index 2c607adf3..4ebedebf2 100644 --- a/ts/state/ducks/onion.tsx +++ b/ts/state/ducks/onion.tsx @@ -3,10 +3,12 @@ import { Snode } from '../../data/data'; export type OnionState = { snodePaths: Array>; + isOnline: boolean; }; export const initialOnionPathState = { snodePaths: new Array>(), + isOnline: false, }; /** @@ -17,12 +19,15 @@ const onionSlice = createSlice({ initialState: initialOnionPathState, reducers: { updateOnionPaths(state: OnionState, action: PayloadAction>>) { - return { snodePaths: action.payload }; + return { ...state, snodePaths: action.payload }; + }, + updateIsOnline(state: OnionState, action: PayloadAction) { + return { ...state, isOnline: action.payload }; }, }, }); // destructures const { actions, reducer } = onionSlice; -export const { updateOnionPaths } = actions; +export const { updateOnionPaths, updateIsOnline } = actions; export const defaultOnionReducer = reducer; diff --git a/ts/state/selectors/onions.ts b/ts/state/selectors/onions.ts index 1e3d95a0d..897e8b3ff 100644 --- a/ts/state/selectors/onions.ts +++ b/ts/state/selectors/onions.ts @@ -21,3 +21,8 @@ export const getFirstOnionPathLength = createSelector( getFirstOnionPath, (state: Array): number => state.length || 0 ); + +export const getIsOnline = createSelector( + getOnionPaths, + (state: OnionState): boolean => state.isOnline +); diff --git a/ts/test/session/unit/onion/OnionErrors_test.ts b/ts/test/session/unit/onion/OnionErrors_test.ts index ef52c2909..44a9a538c 100644 --- a/ts/test/session/unit/onion/OnionErrors_test.ts +++ b/ts/test/session/unit/onion/OnionErrors_test.ts @@ -216,7 +216,7 @@ describe('OnionPathsErrors', () => { throw new Error('Error expected'); } catch (e) { expect(e.message).to.equal( - 'You clock is out of sync with the network. Check your clock.' + 'Your clock is out of sync with the network. Check your clock.' ); // this makes sure that this call would not be retried expect(e.name).to.equal('AbortError'); @@ -237,7 +237,7 @@ describe('OnionPathsErrors', () => { throw new Error('Error expected'); } catch (e) { expect(e.message).to.equal( - 'You clock is out of sync with the network. Check your clock.' + 'Your clock is out of sync with the network. Check your clock.' ); // this makes sure that this call would not be retried expect(e.name).to.equal('AbortError'); diff --git a/ts/test/session/unit/swarm_polling/SwarmPolling_test.ts b/ts/test/session/unit/swarm_polling/SwarmPolling_test.ts new file mode 100644 index 000000000..5ff3eca58 --- /dev/null +++ b/ts/test/session/unit/swarm_polling/SwarmPolling_test.ts @@ -0,0 +1,307 @@ +// tslint:disable: no-implicit-dependencies max-func-body-length no-unused-expression + +import chai from 'chai'; +import Sinon, * as sinon from 'sinon'; +import _, { noop } from 'lodash'; +import { describe } from 'mocha'; + +import chaiAsPromised from 'chai-as-promised'; +import { TestUtils } from '../../../test-utils'; +import { UserUtils } from '../../../../session/utils'; +import { getConversationController } from '../../../../session/conversations'; +import * as Data from '../../../../../ts/data/data'; +import { getSwarmPollingInstance, SnodePool } from '../../../../session/snode_api'; +import { SwarmPolling } from '../../../../session/snode_api/swarmPolling'; +import { SWARM_POLLING_TIMEOUT } from '../../../../session/constants'; +import { + ConversationCollection, + ConversationModel, + ConversationTypeEnum, +} from '../../../../models/conversation'; +import { PubKey } from '../../../../session/types'; +// tslint:disable: chai-vague-errors + +chai.use(chaiAsPromised as any); +chai.should(); + +const { expect } = chai; + +// tslint:disable-next-line: max-func-body-length +describe('SwarmPolling', () => { + // Initialize new stubbed cache + const sandbox = sinon.createSandbox(); + const ourPubkey = TestUtils.generateFakePubKey(); + const ourNumber = ourPubkey.key; + + let pollOnceForKeySpy: Sinon.SinonSpy; + + let swarmPolling: SwarmPolling; + + let clock: Sinon.SinonFakeTimers; + beforeEach(async () => { + // Utils Stubs + sandbox.stub(UserUtils, 'getOurPubKeyStrFromCache').returns(ourNumber); + + sandbox.stub(Data, 'getAllConversations').resolves(new ConversationCollection()); + sandbox.stub(Data, 'getItemById').resolves(); + sandbox.stub(Data, 'saveConversation').resolves(); + sandbox.stub(Data, 'getSwarmNodesForPubkey').resolves(); + sandbox.stub(SnodePool, 'getSwarmFor').resolves([]); + TestUtils.stubWindow('profileImages', { removeImagesNotInArray: noop, hasImage: noop }); + TestUtils.stubWindow('inboxStore', undefined); + const convoController = getConversationController(); + await convoController.load(); + getConversationController().getOrCreate(ourPubkey.key, ConversationTypeEnum.PRIVATE); + + swarmPolling = getSwarmPollingInstance(); + swarmPolling.TEST_reset(); + pollOnceForKeySpy = sandbox.spy(swarmPolling, 'TEST_pollOnceForKey'); + + clock = sinon.useFakeTimers(Date.now()); + }); + + afterEach(() => { + TestUtils.restoreStubs(); + sandbox.restore(); + getConversationController().reset(); + clock.restore(); + }); + + describe('getPollingTimeout', () => { + it('returns INACTIVE for non existing convo', () => { + const fakeConvo = TestUtils.generateFakePubKey(); + + expect(swarmPolling.TEST_getPollingTimeout(fakeConvo)).to.eq(SWARM_POLLING_TIMEOUT.INACTIVE); + }); + + it('returns ACTIVE for convo with less than an hour old activeAt', () => { + const convo = getConversationController().getOrCreate( + TestUtils.generateFakePubKeyStr(), + ConversationTypeEnum.GROUP + ); + convo.set('active_at', Date.now() - 3555); + expect(swarmPolling.TEST_getPollingTimeout(PubKey.cast(convo.id))).to.eq( + SWARM_POLLING_TIMEOUT.ACTIVE + ); + }); + + it('returns INACTIVE for convo with undefined activeAt', () => { + const convo = getConversationController().getOrCreate( + TestUtils.generateFakePubKeyStr(), + ConversationTypeEnum.GROUP + ); + convo.set('active_at', undefined); + expect(swarmPolling.TEST_getPollingTimeout(PubKey.cast(convo.id))).to.eq( + SWARM_POLLING_TIMEOUT.INACTIVE + ); + }); + + it('returns MEDIUM_ACTIVE for convo with activeAt of less than a day', () => { + const convo = getConversationController().getOrCreate( + TestUtils.generateFakePubKeyStr(), + ConversationTypeEnum.GROUP + ); + convo.set('active_at', Date.now() - 1000 * 3600 * 23); + expect(swarmPolling.TEST_getPollingTimeout(PubKey.cast(convo.id))).to.eq( + SWARM_POLLING_TIMEOUT.MEDIUM_ACTIVE + ); + }); + + it('returns INACTIVE for convo with activeAt of more than a day', () => { + const convo = getConversationController().getOrCreate( + TestUtils.generateFakePubKeyStr(), + ConversationTypeEnum.GROUP + ); + convo.set('active_at', Date.now() - 1000 * 3600 * 25); + expect(swarmPolling.TEST_getPollingTimeout(PubKey.cast(convo.id))).to.eq( + SWARM_POLLING_TIMEOUT.INACTIVE + ); + }); + }); + + describe('pollForAllKeys', () => { + it('does run for our pubkey even if activeAt is really old ', async () => { + const convo = getConversationController().getOrCreate( + ourNumber, + ConversationTypeEnum.PRIVATE + ); + convo.set('active_at', Date.now() - 1000 * 3600 * 25); + await swarmPolling.start(true); + + expect(pollOnceForKeySpy.callCount).to.eq(1); + expect(pollOnceForKeySpy.firstCall.args).to.deep.eq([ourPubkey, false]); + }); + + it('does run for our pubkey even if activeAt is recent ', async () => { + const convo = getConversationController().getOrCreate( + ourNumber, + ConversationTypeEnum.PRIVATE + ); + convo.set('active_at', Date.now()); + await swarmPolling.start(true); + + expect(pollOnceForKeySpy.callCount).to.eq(1); + expect(pollOnceForKeySpy.firstCall.args).to.deep.eq([ourPubkey, false]); + }); + + it('does run for group pubkey on start no matter the recent timestamp ', async () => { + const convo = getConversationController().getOrCreate( + TestUtils.generateFakePubKeyStr(), + ConversationTypeEnum.GROUP + ); + convo.set('active_at', Date.now()); + const groupConvoPubkey = PubKey.cast(convo.id); + swarmPolling.addGroupId(groupConvoPubkey); + await swarmPolling.start(true); + + // our pubkey will be polled for, hence the 2 + expect(pollOnceForKeySpy.callCount).to.eq(2); + expect(pollOnceForKeySpy.firstCall.args).to.deep.eq([ourPubkey, false]); + expect(pollOnceForKeySpy.secondCall.args).to.deep.eq([groupConvoPubkey, true]); + }); + + it('does run for group pubkey on start no matter the old timestamp ', async () => { + const convo = getConversationController().getOrCreate( + TestUtils.generateFakePubKeyStr(), + ConversationTypeEnum.GROUP + ); + + convo.set('active_at', 1); + const groupConvoPubkey = PubKey.cast(convo.id); + swarmPolling.addGroupId(groupConvoPubkey); + await swarmPolling.start(true); + + // our pubkey will be polled for, hence the 2 + expect(pollOnceForKeySpy.callCount).to.eq(2); + expect(pollOnceForKeySpy.firstCall.args).to.deep.eq([ourPubkey, false]); + expect(pollOnceForKeySpy.secondCall.args).to.deep.eq([groupConvoPubkey, true]); + }); + + it('does run for group pubkey on start but not another time if activeAt is old ', async () => { + const convo = getConversationController().getOrCreate( + TestUtils.generateFakePubKeyStr(), + ConversationTypeEnum.GROUP + ); + + convo.set('active_at', 1); + const groupConvoPubkey = PubKey.cast(convo.id); + swarmPolling.addGroupId(groupConvoPubkey); + await swarmPolling.start(true); + + await swarmPolling.TEST_pollForAllKeys(); + + expect(pollOnceForKeySpy.callCount).to.eq(3); + expect(pollOnceForKeySpy.secondCall.args).to.deep.eq([groupConvoPubkey, true]); + expect(pollOnceForKeySpy.thirdCall.args).to.deep.eq([ourPubkey, false]); + }); + + it('does run twice if activeAt less than one hour ', async () => { + const convo = getConversationController().getOrCreate( + TestUtils.generateFakePubKeyStr(), + ConversationTypeEnum.GROUP + ); + + convo.set('active_at', Date.now()); + const groupConvoPubkey = PubKey.cast(convo.id); + swarmPolling.addGroupId(groupConvoPubkey); + await swarmPolling.start(true); + clock.tick(6000); + // no need to do that as the tick will trigger a call in all cases after 5 secs + // await swarmPolling.TEST_pollForAllKeys(); + + expect(pollOnceForKeySpy.callCount).to.eq(4); + expect(pollOnceForKeySpy.secondCall.args).to.deep.eq([groupConvoPubkey, true]); + expect(pollOnceForKeySpy.thirdCall.args).to.deep.eq([ourPubkey, false]); + expect(pollOnceForKeySpy.lastCall.args).to.deep.eq([groupConvoPubkey, true]); + }); + + it('does run once only if activeAt is more than one hour', async () => { + const convo = getConversationController().getOrCreate( + TestUtils.generateFakePubKeyStr(), + ConversationTypeEnum.GROUP + ); + + convo.set('active_at', Date.now()); + const groupConvoPubkey = PubKey.cast(convo.id); + swarmPolling.addGroupId(groupConvoPubkey); + await swarmPolling.start(true); + + // more than hour old, we should not tick after just 5 seconds + convo.set('active_at', Date.now() - 3605 * 1000); + + clock.tick(6000); + + expect(pollOnceForKeySpy.callCount).to.eq(3); + expect(pollOnceForKeySpy.secondCall.args).to.deep.eq([groupConvoPubkey, true]); + expect(pollOnceForKeySpy.thirdCall.args).to.deep.eq([ourPubkey, false]); + }); + + it('does run once if activeAt is more than 1 days old ', async () => { + const convo = getConversationController().getOrCreate( + TestUtils.generateFakePubKeyStr(), + ConversationTypeEnum.GROUP + ); + + convo.set('active_at', Date.now()); + const groupConvoPubkey = PubKey.cast(convo.id); + swarmPolling.addGroupId(groupConvoPubkey); + await swarmPolling.start(true); + + // more than hour old, we should not tick after just 5 seconds + convo.set('active_at', Date.now() - 25 * 3600 * 1000); + + clock.tick(6 * 1000); // active + + expect(pollOnceForKeySpy.callCount).to.eq(3); + expect(pollOnceForKeySpy.secondCall.args).to.deep.eq([groupConvoPubkey, true]); + expect(pollOnceForKeySpy.thirdCall.args).to.deep.eq([ourPubkey, false]); + }); + + describe('multiple runs', () => { + let convo: ConversationModel; + let groupConvoPubkey: PubKey; + + beforeEach(async () => { + convo = getConversationController().getOrCreate( + TestUtils.generateFakePubKeyStr(), + ConversationTypeEnum.GROUP + ); + + convo.set('active_at', Date.now()); + groupConvoPubkey = PubKey.cast(convo.id); + swarmPolling.addGroupId(groupConvoPubkey); + await swarmPolling.start(true); + }); + + it('does run twice if activeAt is more than 1 hour old and we tick more than one minute ', async () => { + pollOnceForKeySpy.resetHistory(); + // more than hour old but less than a day, we should tick after just 60 seconds + convo.set('active_at', Date.now() - 3605 * 1000); + + clock.tick(61 * 1000); // medium_active + + await swarmPolling.TEST_pollForAllKeys(); + expect(pollOnceForKeySpy.callCount).to.eq(3); + // first two calls are our pubkey + expect(pollOnceForKeySpy.firstCall.args).to.deep.eq([ourPubkey, false]); + expect(pollOnceForKeySpy.secondCall.args).to.deep.eq([ourPubkey, false]); + + expect(pollOnceForKeySpy.thirdCall.args).to.deep.eq([groupConvoPubkey, true]); + }); + + it('does run twice if activeAt is more than 1 day old and we tick more than one hour ', async () => { + pollOnceForKeySpy.resetHistory(); + convo.set('active_at', Date.now() - 25 * 3600 * 1000); + + clock.tick(3700 * 1000); // inactive + + await swarmPolling.TEST_pollForAllKeys(); + expect(pollOnceForKeySpy.callCount).to.eq(3); + // first two calls are our pubkey + expect(pollOnceForKeySpy.firstCall.args).to.deep.eq([ourPubkey, false]); + expect(pollOnceForKeySpy.secondCall.args).to.deep.eq([ourPubkey, false]); + expect(pollOnceForKeySpy.thirdCall.args).to.deep.eq([groupConvoPubkey, true]); + }); + }); + }); +}); diff --git a/ts/test/session/unit/utils/Password.ts b/ts/test/session/unit/utils/Password_test.ts similarity index 99% rename from ts/test/session/unit/utils/Password.ts rename to ts/test/session/unit/utils/Password_test.ts index a5cca5a39..b1747b0d2 100644 --- a/ts/test/session/unit/utils/Password.ts +++ b/ts/test/session/unit/utils/Password_test.ts @@ -40,6 +40,7 @@ describe('Password Util', () => { 'TiJf@lk^jsO^z8MUn%)[Sd~UPQ)ci9CGS@jb<^', '$u&%{r]apg#G@3dQdCkB_p8)gxhNFr=K&yfM_M8O&2Z.vQyvx', 'bf^OMnYku*iX;{Piw_0zvz', + '@@@@/???\\4545', '#'.repeat(50), ]; valid.forEach(pass => { diff --git a/ts/test/test-utils/utils/pubkey.ts b/ts/test/test-utils/utils/pubkey.ts index 04f17bfc6..b73ac6d43 100644 --- a/ts/test/test-utils/utils/pubkey.ts +++ b/ts/test/test-utils/utils/pubkey.ts @@ -11,6 +11,15 @@ export function generateFakePubKey(): PubKey { return new PubKey(pubkeyString); } +export function generateFakePubKeyStr(): string { + // Generates a mock pubkey for testing + const numBytes = PubKey.PUBKEY_LEN / 2 - 1; + const hexBuffer = crypto.randomBytes(numBytes).toString('hex'); + const pubkeyString = `05${hexBuffer}`; + + return pubkeyString; +} + export function generateFakeECKeyPair(): ECKeyPair { const pubkey = generateFakePubKey().toArray(); const privKey = new Uint8Array(crypto.randomBytes(64)); diff --git a/ts/util/blockedNumberController.ts b/ts/util/blockedNumberController.ts index abdfc4b06..d16399d5d 100644 --- a/ts/util/blockedNumberController.ts +++ b/ts/util/blockedNumberController.ts @@ -1,4 +1,4 @@ -import { createOrUpdateItem, getItemById } from '../../ts/data/data'; +import { createOrUpdateItem, getItemById } from '../data/data'; import { PubKey } from '../session/types'; import { UserUtils } from '../session/utils'; diff --git a/ts/util/passwordUtils.ts b/ts/util/passwordUtils.ts index d5f5d6f30..d6c12d1e0 100644 --- a/ts/util/passwordUtils.ts +++ b/ts/util/passwordUtils.ts @@ -33,7 +33,7 @@ export const validatePassword = (phrase: string) => { } // Restrict characters to letters, numbers and symbols - const characterRegex = /^[a-zA-Z0-9-!()._`~@#$%^&*+=[\]{}|<>,;: ]+$/; + const characterRegex = /^[a-zA-Z0-9-!?/\\()._`~@#$%^&*+=[\]{}|<>,;: ]+$/; if (!characterRegex.test(trimmed)) { return window?.i18n ? window?.i18n('passwordCharacterError') : ERRORS.CHARACTER; } diff --git a/ts/window.d.ts b/ts/window.d.ts index d8a86a540..a74ab6c7e 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -59,7 +59,6 @@ declare global { showResetSessionIdDialog: any; storage: any; textsecure: LibTextsecure; - toggleLinkPreview: any; toggleMediaPermissions: any; toggleMenuBar: any; toggleSpellCheck: any; diff --git a/tslint.json b/tslint.json index 4f804d015..79c9acaf1 100644 --- a/tslint.json +++ b/tslint.json @@ -65,10 +65,10 @@ "function-name": [ true, { - "function-regex": "^[a-z][\\w\\d]+$", - "method-regex": "^[a-z][\\w\\d]+$", - "private-method-regex": "^[a-z][\\w\\d]+$", - "protected-method-regex": "^[a-z][\\w\\d]+$", + "function-regex": "^(TEST_)?(_)?[a-z][\\w\\d]+$", + "method-regex": "^(TEST_)?(_)?[a-z][\\w\\d]+$", + "private-method-regex": "^(TEST_)?(_)?[a-z][\\w\\d]+$", + "protected-method-regex": "^(TEST_)?(_)?[a-z][\\w\\d]+$", "static-method-regex": "^[a-zA-Z][\\w\\d]+$" } ],