From cbb309e422070b761093b65f5d651f3820927020 Mon Sep 17 00:00:00 2001 From: VityaSchel <59040542+VityaSchel@users.noreply.github.com> Date: Sat, 27 Apr 2024 20:01:49 +0400 Subject: [PATCH] feat: trusted websites list and trust hostname btn This commit adds the trusted websites list via TrustedWebsitesController and "Trust [hostname]" button to OpenExternalLinkDialog. Need help with resolving issue with persisting it to db. See #2662. --- _locales/en/messages.json | 7 +- ts/components/TrustedWebsiteListItem.tsx | 82 +++++++++ ts/components/basic/SessionButton.tsx | 1 + .../message/message-content/MessageBody.tsx | 4 +- .../message-content/MessageLinkPreview.tsx | 4 +- ts/components/dialog/ModalContainer.tsx | 4 +- .../dialog/OpenExternalLinkDialog.tsx | 108 ++++++++--- ts/components/settings/BlockedList.tsx | 4 +- .../settings/TrustedWebsitesList.tsx | 167 ++++++++++++++++++ .../settings/section/CategoryPrivacy.tsx | 3 + ts/types/LocalizerKeys.ts | 5 + ts/util/index.ts | 1 + ts/util/trustedWebsitesController.ts | 67 +++++++ 13 files changed, 421 insertions(+), 36 deletions(-) create mode 100644 ts/components/TrustedWebsiteListItem.tsx create mode 100644 ts/components/settings/TrustedWebsitesList.tsx create mode 100644 ts/util/trustedWebsitesController.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7324c04d2d..b406ff7f55 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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." } diff --git a/ts/components/TrustedWebsiteListItem.tsx b/ts/components/TrustedWebsiteListItem.tsx new file mode 100644 index 0000000000..632a949b55 --- /dev/null +++ b/ts/components/TrustedWebsiteListItem.tsx @@ -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 ( + { + if (isSelected) { + onUnselect?.(hostname); + } else { + onSelect?.(hostname); + } + }} + selected={isSelected} + > + + {hostname} + + + + + + + ); +}; diff --git a/ts/components/basic/SessionButton.tsx b/ts/components/basic/SessionButton.tsx index ca2ae7992b..cfa4e2258c 100644 --- a/ts/components/basic/SessionButton.tsx +++ b/ts/components/basic/SessionButton.tsx @@ -23,6 +23,7 @@ export enum SessionButtonColor { Orange = 'orange', Red = 'red', White = 'white', + Grey = 'grey', Primary = 'primary', Danger = 'danger', None = 'transparent', diff --git a/ts/components/conversation/message/message-content/MessageBody.tsx b/ts/components/conversation/message/message-content/MessageBody.tsx index 27ba2fef44..6fc2c74c7f 100644 --- a/ts/components/conversation/message/message-content/MessageBody.tsx +++ b/ts/components/conversation/message/message-content/MessageBody.tsx @@ -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/OpenExternalLinkDialog'; +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} diff --git a/ts/components/conversation/message/message-content/MessageLinkPreview.tsx b/ts/components/conversation/message/message-content/MessageLinkPreview.tsx index f3b2cfe9ba..b52b57fcea 100644 --- a/ts/components/conversation/message/message-content/MessageLinkPreview.tsx +++ b/ts/components/conversation/message/message-content/MessageLinkPreview.tsx @@ -9,7 +9,7 @@ import { } from '../../../../state/selectors'; import { useIsMessageSelectionMode } from '../../../../state/selectors/selectedConversation'; import { isImageAttachment } from '../../../../types/Attachment'; -import { showLinkVisitWarningDialog } from '../../../dialog/OpenExternalLinkDialog'; +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); } } diff --git a/ts/components/dialog/ModalContainer.tsx b/ts/components/dialog/ModalContainer.tsx index 904cd0b1b7..f9a1e59449 100644 --- a/ts/components/dialog/ModalContainer.tsx +++ b/ts/components/dialog/ModalContainer.tsx @@ -77,7 +77,9 @@ export const ModalContainer = () => { {sessionPasswordModalState && } {deleteAccountModalState && } {confirmModalState && } - {openExternalLinkModalState && } + {openExternalLinkModalState && ( + + )} {reactListModalState && } {reactClearAllModalState && } {editProfilePictureModalState && ( diff --git a/ts/components/dialog/OpenExternalLinkDialog.tsx b/ts/components/dialog/OpenExternalLinkDialog.tsx index 14cba80ac0..c71a699660 100644 --- a/ts/components/dialog/OpenExternalLinkDialog.tsx +++ b/ts/components/dialog/OpenExternalLinkDialog.tsx @@ -10,8 +10,9 @@ 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 }>` +const StyledSubText = styled(SessionHtmlRenderer)<{ textLength: number }>` font-size: var(--font-size-md); line-height: 1.5; margin-bottom: var(--margins-lg); @@ -29,19 +30,30 @@ const StyledExternalLinkContainer = styled.div` 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 + urlToOpen: string; } -export const SessionOpenExternalLinkDialog = ({ urlToOpen }: SessionOpenExternalLinkDialogProps) => { +export const SessionOpenExternalLinkDialog = ({ + urlToOpen, +}: SessionOpenExternalLinkDialogProps) => { const dispatch = useDispatch(); useKey('Enter', () => { @@ -52,8 +64,22 @@ export const SessionOpenExternalLinkDialog = ({ urlToOpen }: SessionOpenExternal 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 = () => { @@ -61,12 +87,13 @@ export const SessionOpenExternalLinkDialog = ({ urlToOpen }: SessionOpenExternal }; const handleClose = () => { - dispatch(setOpenExternalLinkModal(null)) - } + dispatch(setOpenExternalLinkModal(null)); + }; - // TODO: replace translations to remove $url$ dynamic varialbe, - // instead put this variable below in the readonly input - const message = window.i18n('linkVisitWarningMessage', ['URL']); + const handleTrust = () => { + void TrustedWebsitesController.addToTrusted(hostname!); + handleOpen(); + }; return ( -
- - -
+ +
+ + +
+ {hostname && ( + + )} +
); }; -export const showLinkVisitWarningDialog = (urlToOpen: string, dispatch: Dispatch) => { - dispatch( - setOpenExternalLinkModal({ - urlToOpen, - }) - ); +export const promptToOpenExternalLink = (urlToOpen: string, dispatch: Dispatch) => { + 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, + }) + ); + } }; diff --git a/ts/components/settings/BlockedList.tsx b/ts/components/settings/BlockedList.tsx index 10b622b53a..2a91bdb714 100644 --- a/ts/components/settings/BlockedList.tsx +++ b/ts/components/settings/BlockedList.tsx @@ -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" /> diff --git a/ts/components/settings/TrustedWebsitesList.tsx b/ts/components/settings/TrustedWebsitesList.tsx new file mode 100644 index 0000000000..76d0bda95a --- /dev/null +++ b/ts/components/settings/TrustedWebsitesList.tsx @@ -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; + selectedHostnames: Array; + addToSelected: (id: string) => void; + removeFromSelected: (id: string) => void; +}) => { + const { addToSelected, trustedHostnames, removeFromSelected, selectedHostnames } = props; + return ( + + + {trustedHostnames.map(trustedEntry => { + return ( + + ); + })} + + + ); +}; + +export const TrustedWebsitesList = () => { + const [expanded, setExpanded] = useState(false); + const { + uniqueValues: selectedHostnames, + addTo: addToSelected, + removeFrom: removeFromSelected, + empty: emptySelected, + } = useSet([]); + + 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 ( + + + + + {noTrustedWebsites ? ( + + ) : ( + + {hasAtLeastOneSelected && expanded ? ( + + ) : null} + + + + )} + + + {expanded && !noTrustedWebsites ? ( + <> + + + + ) : null} + + ); +}; + +const NoTrustedWebsites = () => { + return
{window.i18n('noTrustedWebsitesEntries')}
; +}; diff --git a/ts/components/settings/section/CategoryPrivacy.tsx b/ts/components/settings/section/CategoryPrivacy.tsx index 4c48f54267..0400810705 100644 --- a/ts/components/settings/section/CategoryPrivacy.tsx +++ b/ts/components/settings/section/CategoryPrivacy.tsx @@ -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: { /> )} + + ); }; diff --git a/ts/types/LocalizerKeys.ts b/ts/types/LocalizerKeys.ts index 14aa1aba96..a895d4c3cf 100644 --- a/ts/types/LocalizerKeys.ts +++ b/ts/types/LocalizerKeys.ts @@ -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' diff --git a/ts/util/index.ts b/ts/util/index.ts index 83d746404a..7778a8b22e 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -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 }; diff --git a/ts/util/trustedWebsitesController.ts b/ts/util/trustedWebsitesController.ts new file mode 100644 index 0000000000..ee3082639b --- /dev/null +++ b/ts/util/trustedWebsitesController.ts @@ -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 = new Set(); + + public static isTrusted(hostname: string): boolean { + return this.trustedWebsites.has(hostname); + } + + public static async addToTrusted(hostname: string): Promise { + 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): Promise { + 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 { + 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> { + 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): Promise { + await Storage.put(id, [...hostnames]); + } +}