Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add "trust this website" button in external link popup when clicking on a link #3088

Open
wants to merge 2 commits into
base: unstable
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion _locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@
"block": "Block",
"unblock": "Unblock",
"unblocked": "Unblocked",
"removed": "Removed",
"blocked": "Blocked",
"blockedSettingsTitle": "Blocked Contacts",
"conversationsSettingsTitle": "Conversations",
Expand Down Expand Up @@ -579,5 +580,9 @@
"duration": "Duration",
"notApplicable": "N/A",
"unknownError": "Unknown Error",
"displayNameErrorNew": "We were unable to load your display name. Please enter a new display name to continue."
"displayNameErrorNew": "We were unable to load your display name. Please enter a new display name to continue.",
"trustHostname": "Trust $hostname$",
"trustedWebsites": "Trusted websites",
"trustedWebsitesDescription": "Clicking on a trusted website will open it in your browser.",
"noTrustedWebsitesEntries": "You have no trusted websites."
}
82 changes: 82 additions & 0 deletions ts/components/TrustedWebsiteListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';
import styled from 'styled-components';

import { SessionRadio } from './basic/SessionRadio';

const StyledTrustedWebsiteItem = styled.button<{
inMentions?: boolean;
zombie?: boolean;
selected?: boolean;
disableBg?: boolean;
}>`
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
flex-grow: 1;
font-family: var(--font-default);
padding: 0px var(--margins-sm);
height: ${props => (props.inMentions ? '40px' : '50px')};
width: 100%;
transition: var(--default-duration);
opacity: ${props => (props.zombie ? 0.5 : 1)};
background-color: ${props =>
!props.disableBg && props.selected
? 'var(--conversation-tab-background-selected-color) !important'
: null};

:not(:last-child) {
border-bottom: 1px solid var(--border-color);
}
`;

const StyledInfo = styled.div`
display: flex;
align-items: center;
min-width: 0;
`;

const StyledName = styled.span`
font-weight: bold;
margin-inline-start: var(--margins-md);
margin-inline-end: var(--margins-md);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;

const StyledCheckContainer = styled.div`
display: flex;
align-items: center;
`;

export const TrustedWebsiteListItem = (props: {
hostname: string;
isSelected: boolean;
onSelect?: (pubkey: string) => void;
onUnselect?: (pubkey: string) => void;
}) => {
const { hostname, isSelected, onSelect, onUnselect } = props;

return (
<StyledTrustedWebsiteItem
onClick={() => {
if (isSelected) {
onUnselect?.(hostname);
} else {
onSelect?.(hostname);
}
}}
selected={isSelected}
>
<StyledInfo>
<StyledName>{hostname}</StyledName>
</StyledInfo>

<StyledCheckContainer>
<SessionRadio active={isSelected} value={hostname} inputName={hostname} label="" />
</StyledCheckContainer>
</StyledTrustedWebsiteItem>
);
};
1 change: 1 addition & 0 deletions ts/components/basic/SessionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export enum SessionButtonColor {
Orange = 'orange',
Red = 'red',
White = 'white',
Grey = 'grey',
Primary = 'primary',
Danger = 'danger',
None = 'transparent',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import styled from 'styled-components';
import { RenderTextCallbackType } from '../../../../types/Util';
import { getEmojiSizeClass, SizeClassType } from '../../../../util/emoji';
import { LinkPreviews } from '../../../../util/linkPreviews';
import { showLinkVisitWarningDialog } from '../../../dialog/SessionConfirm';
import { promptToOpenExternalLink } from '../../../dialog/OpenExternalLinkDialog';
import { AddMentions } from '../../AddMentions';
import { AddNewLines } from '../../AddNewLines';
import { Emojify } from '../../Emojify';
Expand Down Expand Up @@ -128,7 +128,7 @@ const Linkify = (props: LinkifyProps): JSX.Element => {
onClick={e => {
e.preventDefault();
e.stopPropagation();
showLinkVisitWarningDialog(url, dispatch);
promptToOpenExternalLink(url, dispatch);
}}
>
{originalText}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from '../../../../state/selectors';
import { useIsMessageSelectionMode } from '../../../../state/selectors/selectedConversation';
import { isImageAttachment } from '../../../../types/Attachment';
import { showLinkVisitWarningDialog } from '../../../dialog/SessionConfirm';
import { promptToOpenExternalLink } from '../../../dialog/OpenExternalLinkDialog';
import { SessionIcon } from '../../../icon';
import { Image } from '../../Image';

Expand Down Expand Up @@ -57,7 +57,7 @@ export const MessageLinkPreview = (props: Props) => {
return;
}
if (previews?.length && previews[0].url) {
showLinkVisitWarningDialog(previews[0].url, dispatch);
promptToOpenExternalLink(previews[0].url, dispatch);
}
}

Expand Down
6 changes: 6 additions & 0 deletions ts/components/dialog/ModalContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getUpdateGroupMembersModal,
getUpdateGroupNameModal,
getUserDetailsModal,
getOpenExternalLinkModalState,
} from '../../state/selectors/modal';
import { InviteContactsDialog } from './InviteContactsDialog';
import { DeleteAccountModal } from './DeleteAccountModal';
Expand All @@ -36,6 +37,7 @@ import { BanOrUnBanUserDialog } from './BanOrUnbanUserDialog';
import { ReactListModal } from './ReactListModal';
import { ReactClearAllModal } from './ReactClearAllModal';
import { EditProfilePictureModal } from './EditProfilePictureModal';
import { SessionOpenExternalLinkDialog } from './OpenExternalLinkDialog';

export const ModalContainer = () => {
const confirmModalState = useSelector(getConfirmModal);
Expand All @@ -55,6 +57,7 @@ export const ModalContainer = () => {
const reactListModalState = useSelector(getReactListDialog);
const reactClearAllModalState = useSelector(getReactClearAllDialog);
const editProfilePictureModalState = useSelector(getEditProfilePictureModalState);
const openExternalLinkModalState = useSelector(getOpenExternalLinkModalState);

return (
<>
Expand All @@ -74,6 +77,9 @@ export const ModalContainer = () => {
{sessionPasswordModalState && <SessionPasswordDialog {...sessionPasswordModalState} />}
{deleteAccountModalState && <DeleteAccountModal {...deleteAccountModalState} />}
{confirmModalState && <SessionConfirm {...confirmModalState} />}
{openExternalLinkModalState && (
<SessionOpenExternalLinkDialog {...openExternalLinkModalState} />
)}
{reactListModalState && <ReactListModal {...reactListModalState} />}
{reactClearAllModalState && <ReactClearAllModal {...reactClearAllModalState} />}
{editProfilePictureModalState && (
Expand Down
168 changes: 168 additions & 0 deletions ts/components/dialog/OpenExternalLinkDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { shell } from 'electron';
import React, { Dispatch } from 'react';
import useKey from 'react-use/lib/useKey';
import styled from 'styled-components';
import { useDispatch } from 'react-redux';
import { MessageInteraction } from '../../interactions';
import { SessionWrapperModal } from '../SessionWrapperModal';
import { SessionButton, SessionButtonColor, SessionButtonType } from '../basic/SessionButton';
import { SessionHtmlRenderer } from '../basic/SessionHTMLRenderer';
import { SpacerLG } from '../basic/Text';
import { setOpenExternalLinkModal } from '../../state/ducks/modalDialog';
import { SessionIconButton } from '../icon';
import { TrustedWebsitesController } from '../../util';

const StyledSubText = styled(SessionHtmlRenderer)<{ textLength: number }>`
font-size: var(--font-size-md);
line-height: 1.5;
margin-bottom: var(--margins-lg);

max-width: ${props =>
props.textLength > 90
? '60ch'
: '33ch'}; // this is ugly, but we want the dialog description to have multiple lines when a short text is displayed
`;

const StyledExternalLinkContainer = styled.div`
display: flex;
align-items: center;
border: 1px solid var(--input-border-color);
border-radius: 6px;
transition: var(--default-duration);
width: 100%;
`;

const StyledExternalLinkInput = styled.input`
font: inherit;
border: none !important;
flex: 1;
`;

const StyledActionButtons = styled.div`
display: flex;
flex-direction: column;

& > button {
font-weight: 400;
}
`;

interface SessionOpenExternalLinkDialogProps {
urlToOpen: string;
}

export const SessionOpenExternalLinkDialog = ({
urlToOpen,
}: SessionOpenExternalLinkDialogProps) => {
const dispatch = useDispatch();

useKey('Enter', () => {
handleOpen();
});

useKey('Escape', () => {
handleClose();
});

// TODO: replace translations to remove $url$ dynamic varialbe,
// instead put this variable below in the readonly input
const message = window.i18n('linkVisitWarningMessage', ['URL']);

const hostname: string | null = React.useMemo(() => {
try {
const url = new URL(urlToOpen);
return url.hostname;
} catch (e) {
return null;
}
}, [urlToOpen]);

const handleOpen = () => {
void shell.openExternal(urlToOpen);
handleClose();
};

const handleCopy = () => {
MessageInteraction.copyBodyToClipboard(urlToOpen);
};

const handleClose = () => {
dispatch(setOpenExternalLinkModal(null));
};

const handleTrust = () => {
void TrustedWebsitesController.addToTrusted(hostname!);
handleOpen();
};

return (
<SessionWrapperModal
title={window.i18n('linkVisitWarningTitle')}
onClose={() => 0}
showExitIcon={false}
showHeader
>
<SpacerLG />

<div className="session-modal__centered">
<StyledSubText tag="span" textLength={message.length} html={message} />
<StyledExternalLinkContainer>
<StyledExternalLinkInput readOnly value={urlToOpen} />
<SessionIconButton
aria-label={window.i18n('editMenuCopy')}
iconType="copy"
iconSize="small"
onClick={handleCopy}
/>
</StyledExternalLinkContainer>
</div>

<SpacerLG />

<StyledActionButtons>
<div className="session-modal__button-group">
<SessionButton
text={window.i18n('cancel')}
buttonType={SessionButtonType.Simple}
onClick={handleClose}
/>
<SessionButton
text={window.i18n('open')}
buttonColor={SessionButtonColor.Primary}
buttonType={SessionButtonType.Simple}
onClick={handleOpen}
/>
</div>
{hostname && (
<SessionButton
text={window.i18n('trustHostname', [hostname])}
buttonColor={SessionButtonColor.Grey}
buttonType={SessionButtonType.Simple}
onClick={handleTrust}
/>
)}
</StyledActionButtons>
</SessionWrapperModal>
);
};

export const promptToOpenExternalLink = (urlToOpen: string, dispatch: Dispatch<any>) => {
let hostname: string | null;

try {
const url = new URL(urlToOpen);
hostname = url.hostname;
} catch (e) {
hostname = null;
}

if (hostname && TrustedWebsitesController.isTrusted(hostname)) {
void shell.openExternal(urlToOpen);
} else {
dispatch(
setOpenExternalLinkModal({
urlToOpen,
})
);
}
};
28 changes: 1 addition & 27 deletions ts/components/dialog/SessionConfirm.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { shell } from 'electron';
import React, { Dispatch, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import useKey from 'react-use/lib/useKey';
import styled from 'styled-components';
import { useLastMessage } from '../../hooks/useParamSelector';
import { MessageInteraction } from '../../interactions';
import {
ConversationInteractionStatus,
updateConversationInteractionState,
Expand Down Expand Up @@ -218,27 +216,3 @@ export const SessionConfirm = (props: SessionConfirmDialogProps) => {
</SessionWrapperModal>
);
};

export const showLinkVisitWarningDialog = (urlToOpen: string, dispatch: Dispatch<any>) => {
function onClickOk() {
void shell.openExternal(urlToOpen);
}

dispatch(
updateConfirmModal({
title: window.i18n('linkVisitWarningTitle'),
message: window.i18n('linkVisitWarningMessage', [urlToOpen]),
okText: window.i18n('open'),
okTheme: SessionButtonColor.Primary,
cancelText: window.i18n('editMenuCopy'),
showExitIcon: true,
onClickOk,
onClickClose: () => {
dispatch(updateConfirmModal(null));
},
onClickCancel: () => {
MessageInteraction.copyBodyToClipboard(urlToOpen);
},
})
);
};
Loading
Loading