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]);
+ }
+}