pull/3088/merge
Viktor Shchelochkov 4 weeks ago committed by GitHub
commit 776a565007
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -289,6 +289,7 @@
"block": "Block",
"unblock": "Unblock",
"unblocked": "Unblocked",
"removed": "Removed",
"blocked": "Blocked",
"blockedSettingsTitle": "Blocked Contacts",
"conversationsSettingsTitle": "Conversations",
@ -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."
}

@ -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>
);
};

@ -23,6 +23,7 @@ export enum SessionButtonColor {
Orange = 'orange',
Red = 'red',
White = 'white',
Grey = 'grey',
Primary = 'primary',
Danger = 'danger',
None = 'transparent',

@ -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';
@ -128,7 +128,7 @@ const Linkify = (props: LinkifyProps): JSX.Element => {
onClick={e => {
e.preventDefault();
e.stopPropagation();
showLinkVisitWarningDialog(url, dispatch);
promptToOpenExternalLink(url, dispatch);
}}
>
{originalText}

@ -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';
@ -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);
}
}

@ -18,6 +18,7 @@ import {
getUpdateGroupMembersModal,
getUpdateGroupNameModal,
getUserDetailsModal,
getOpenExternalLinkModalState,
} from '../../state/selectors/modal';
import { InviteContactsDialog } from './InviteContactsDialog';
import { DeleteAccountModal } from './DeleteAccountModal';
@ -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);
@ -55,6 +57,7 @@ export const ModalContainer = () => {
const reactListModalState = useSelector(getReactListDialog);
const reactClearAllModalState = useSelector(getReactClearAllDialog);
const editProfilePictureModalState = useSelector(getEditProfilePictureModalState);
const openExternalLinkModalState = useSelector(getOpenExternalLinkModalState);
return (
<>
@ -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 && (

@ -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,
})
);
}
};

@ -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,
@ -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);
},
})
);
};

@ -28,6 +28,8 @@ const BlockedEntriesRoundedContainer = styled.div`
`;
const BlockedContactsSection = styled.div`
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 80px;
@ -142,7 +144,7 @@ export const BlockedContactsList = () => {
iconSize={'large'}
iconType={'chevron'}
onClick={toggleUnblockList}
iconRotation={expanded ? 0 : 180}
iconRotation={expanded ? 180 : 0}
dataTestId="reveal-blocked-user-settings"
/>
</BlockedContactListTitleButtons>

@ -0,0 +1,167 @@
import React, { useState } from 'react';
import useUpdate from 'react-use/lib/useUpdate';
import styled from 'styled-components';
import { useSet } from '../../hooks/useSet';
import { ToastUtils } from '../../session/utils';
import { TrustedWebsitesController } from '../../util';
import { SessionButton, SessionButtonColor } from '../basic/SessionButton';
import { SpacerLG } from '../basic/Text';
import { SessionIconButton } from '../icon';
import { SettingsTitleAndDescription } from './SessionSettingListItem';
import { TrustedWebsiteListItem } from '../TrustedWebsiteListItem';
const TrustedEntriesContainer = styled.div`
flex-shrink: 1;
overflow: auto;
min-height: 40px;
max-height: 100%;
`;
const TrustedEntriesRoundedContainer = styled.div`
overflow: hidden;
background: var(--background-secondary-color);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: var(--margins-lg);
margin: 0 var(--margins-lg);
`;
const TrustedWebsitesSection = styled.div`
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 80px;
background: var(--settings-tab-background-color);
color: var(--settings-tab-text-color);
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
margin-bottom: var(--margins-lg);
`;
const TrustedWebsitesListTitle = styled.div`
display: flex;
justify-content: space-between;
min-height: 45px;
align-items: center;
`;
const TrustedWebsitesListTitleButtons = styled.div`
display: flex;
align-items: center;
`;
export const StyledTrustedSettingItem = styled.div<{ clickable: boolean }>`
font-size: var(--font-size-md);
padding: var(--margins-lg);
cursor: ${props => (props.clickable ? 'pointer' : 'unset')};
`;
const TrustedEntries = (props: {
trustedHostnames: Array<string>;
selectedHostnames: Array<string>;
addToSelected: (id: string) => void;
removeFromSelected: (id: string) => void;
}) => {
const { addToSelected, trustedHostnames, removeFromSelected, selectedHostnames } = props;
return (
<TrustedEntriesRoundedContainer>
<TrustedEntriesContainer>
{trustedHostnames.map(trustedEntry => {
return (
<TrustedWebsiteListItem
hostname={trustedEntry}
isSelected={selectedHostnames.includes(trustedEntry)}
key={trustedEntry}
onSelect={addToSelected}
onUnselect={removeFromSelected}
/>
);
})}
</TrustedEntriesContainer>
</TrustedEntriesRoundedContainer>
);
};
export const TrustedWebsitesList = () => {
const [expanded, setExpanded] = useState(false);
const {
uniqueValues: selectedHostnames,
addTo: addToSelected,
removeFrom: removeFromSelected,
empty: emptySelected,
} = useSet<string>([]);
const forceUpdate = useUpdate();
const hasAtLeastOneSelected = Boolean(selectedHostnames.length);
const trustedWebsites = TrustedWebsitesController.getTrustedWebsites();
const noTrustedWebsites = !trustedWebsites.length;
function toggleTrustedWebsitesList() {
if (trustedWebsites.length) {
setExpanded(!expanded);
}
}
async function removeTrustedWebsites() {
if (selectedHostnames.length) {
await TrustedWebsitesController.removeFromTrusted(selectedHostnames);
emptySelected();
ToastUtils.pushToastSuccess('removed', window.i18n('removed'));
forceUpdate();
}
}
return (
<TrustedWebsitesSection>
<StyledTrustedSettingItem clickable={!noTrustedWebsites}>
<TrustedWebsitesListTitle onClick={toggleTrustedWebsitesList}>
<SettingsTitleAndDescription
title={window.i18n('trustedWebsites')}
description={window.i18n('trustedWebsitesDescription')}
/>
{noTrustedWebsites ? (
<NoTrustedWebsites />
) : (
<TrustedWebsitesListTitleButtons>
{hasAtLeastOneSelected && expanded ? (
<SessionButton
buttonColor={SessionButtonColor.Danger}
text={window.i18n('remove')}
onClick={removeTrustedWebsites}
/>
) : null}
<SpacerLG />
<SessionIconButton
iconSize={'large'}
iconType={'chevron'}
onClick={toggleTrustedWebsitesList}
iconRotation={expanded ? 180 : 0}
/>
</TrustedWebsitesListTitleButtons>
)}
</TrustedWebsitesListTitle>
</StyledTrustedSettingItem>
{expanded && !noTrustedWebsites ? (
<>
<TrustedEntries
trustedHostnames={trustedWebsites}
selectedHostnames={selectedHostnames}
addToSelected={addToSelected}
removeFromSelected={removeFromSelected}
/>
<SpacerLG />
</>
) : null}
</TrustedWebsitesSection>
);
};
const NoTrustedWebsites = () => {
return <div>{window.i18n('noTrustedWebsitesEntries')}</div>;
};

@ -19,6 +19,7 @@ import {
import { Storage } from '../../../util/storage';
import { SessionSettingButtonItem, SessionToggleWithDescription } from '../SessionSettingListItem';
import { displayPasswordModal } from '../SessionSettings';
import { TrustedWebsitesList } from '../TrustedWebsitesList';
async function toggleLinkPreviews(isToggleOn: boolean, forceUpdate: () => void) {
if (!isToggleOn) {
@ -142,6 +143,8 @@ export const SettingsCategoryPrivacy = (props: {
/>
</>
)}
<TrustedWebsitesList />
</>
);
};

@ -20,6 +20,7 @@ export type EditProfileModalState = object | null;
export type OnionPathModalState = EditProfileModalState;
export type RecoveryPhraseModalState = EditProfileModalState;
export type DeleteAccountModalState = EditProfileModalState;
export type OpenExternalLinkModalState = { urlToOpen: string } | null;
export type SessionPasswordModalState = { passwordAction: PasswordAction; onOk: () => void } | null;
@ -54,6 +55,7 @@ export type ModalState = {
reactListModalState: ReactModalsState;
reactClearAllModalState: ReactModalsState;
editProfilePictureModalState: EditProfilePictureModalState;
openExternalLinkModalState: OpenExternalLinkModalState;
};
export const initialModalState: ModalState = {
@ -74,6 +76,7 @@ export const initialModalState: ModalState = {
reactListModalState: null,
reactClearAllModalState: null,
editProfilePictureModalState: null,
openExternalLinkModalState: null,
};
const ModalSlice = createSlice({
@ -131,6 +134,9 @@ const ModalSlice = createSlice({
updateEditProfilePictureModel(state, action: PayloadAction<EditProfilePictureModalState>) {
return { ...state, editProfilePictureModalState: action.payload };
},
setOpenExternalLinkModal(state, action: PayloadAction<OpenExternalLinkModalState>) {
return { ...state, openExternalLinkModalState: action.payload };
},
},
});
@ -153,5 +159,6 @@ export const {
updateReactListModal,
updateReactClearAllModal,
updateEditProfilePictureModel,
setOpenExternalLinkModal,
} = actions;
export const modalReducer = reducer;

@ -12,6 +12,7 @@ import {
InviteContactModalState,
ModalState,
OnionPathModalState,
OpenExternalLinkModalState,
ReactModalsState,
RecoveryPhraseModalState,
RemoveModeratorsModalState,
@ -109,3 +110,8 @@ export const getEditProfilePictureModalState = createSelector(
getModal,
(state: ModalState): EditProfilePictureModalState => state.editProfilePictureModalState
);
export const getOpenExternalLinkModalState = createSelector(
getModal,
(state: ModalState): OpenExternalLinkModalState => state.openExternalLinkModalState
);

@ -337,6 +337,7 @@ export type LocalizerKeys =
| 'noModeratorsToRemove'
| 'noNameOrMessage'
| 'noSearchResults'
| 'noTrustedWebsitesEntries'
| 'notApplicable'
| 'noteToSelf'
| 'notificationForConvo'
@ -425,6 +426,7 @@ export type LocalizerKeys =
| 'removePasswordTitle'
| 'removePasswordToastDescription'
| 'removeResidueMembers'
| 'removed'
| 'replyToMessage'
| 'replyingToMessage'
| 'reportIssue'
@ -527,8 +529,11 @@ export type LocalizerKeys =
| 'trimDatabase'
| 'trimDatabaseConfirmationBody'
| 'trimDatabaseDescription'
| 'trustHostname'
| 'trustThisContactDialogDescription'
| 'trustThisContactDialogTitle'
| 'trustedWebsites'
| 'trustedWebsitesDescription'
| 'tryAgain'
| 'typeInOldPassword'
| 'typingAlt'

@ -5,5 +5,6 @@ import * as AttachmentUtil from './attachmentsUtil';
import * as LinkPreviewUtil from './linkPreviewFetch';
export * from './blockedNumberController';
export * from './trustedWebsitesController';
export { arrayBufferToObjectURL, GoogleChrome, missingCaseError, AttachmentUtil, LinkPreviewUtil };

@ -0,0 +1,67 @@
import { Data } from '../data/data';
import { Storage } from './storage';
const TRUSTED_WEBSITES_ID = 'trusted-websites';
export class TrustedWebsitesController {
private static loaded: boolean = false;
private static trustedWebsites: Set<string> = new Set();
public static isTrusted(hostname: string): boolean {
return this.trustedWebsites.has(hostname);
}
public static async addToTrusted(hostname: string): Promise<void> {
await this.load();
if (!this.trustedWebsites.has(hostname)) {
this.trustedWebsites.add(hostname);
await this.saveToDB(TRUSTED_WEBSITES_ID, this.trustedWebsites);
}
}
public static async removeFromTrusted(hostnames: Array<string>): Promise<void> {
await this.load();
let changes = false;
hostnames.forEach(hostname => {
if (this.trustedWebsites.has(hostname)) {
this.trustedWebsites.delete(hostname);
changes = true;
}
});
if (changes) {
await this.saveToDB(TRUSTED_WEBSITES_ID, this.trustedWebsites);
}
}
public static getTrustedWebsites(): Array<string> {
return [...this.trustedWebsites];
}
// ---- DB
public static async load() {
if (!this.loaded) {
this.trustedWebsites = await this.getTrustedWebsitesFromDB(TRUSTED_WEBSITES_ID);
this.loaded = true;
}
}
public static reset() {
this.loaded = false;
this.trustedWebsites = new Set();
}
private static async getTrustedWebsitesFromDB(id: string): Promise<Set<string>> {
const data = await Data.getItemById(id);
if (!data || !data.value) {
return new Set();
}
return new Set(data.value);
}
private static async saveToDB(id: string, hostnames: Set<string>): Promise<void> {
await Storage.put(id, [...hostnames]);
}
}
Loading…
Cancel
Save