From a6ca601c1fc537fbfa9d64384ce8db2edb5a4e8a Mon Sep 17 00:00:00 2001 From: mkljczk Date: Sun, 9 Mar 2025 20:40:07 +0100 Subject: [PATCH] pl-fe: Suggest cleaning dirty URLs in compose form Signed-off-by: mkljczk --- packages/pl-fe/src/actions/compose.ts | 24 +++++++- .../components/clear-link-suggestion.tsx | 60 +++++++++++++++++++ .../compose/components/compose-form.tsx | 31 +++++++++- .../compose/editor/plugins/state-plugin.tsx | 56 ++++++++++++++++- .../pl-fe/src/features/url-privacy/index.tsx | 8 +-- packages/pl-fe/src/locales/en.json | 4 ++ packages/pl-fe/src/reducers/compose.ts | 26 +++++++- 7 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 packages/pl-fe/src/features/compose/components/clear-link-suggestion.tsx diff --git a/packages/pl-fe/src/actions/compose.ts b/packages/pl-fe/src/actions/compose.ts index c59d1a518..56353b9a8 100644 --- a/packages/pl-fe/src/actions/compose.ts +++ b/packages/pl-fe/src/actions/compose.ts @@ -25,6 +25,7 @@ import type { Emoji } from 'pl-fe/features/emoji'; import type { Policy, Rule, Scope } from 'pl-fe/features/interaction-policies'; import type { Account } from 'pl-fe/normalizers/account'; import type { Status } from 'pl-fe/normalizers/status'; +import type { ClearLinkSuggestion } from 'pl-fe/reducers/compose'; import type { AppDispatch, RootState } from 'pl-fe/store'; import type { History } from 'pl-fe/types/history'; @@ -95,6 +96,9 @@ const COMPOSE_ADD_SUGGESTED_LANGUAGE = 'COMPOSE_ADD_SUGGESTED_LANGUAGE' as const const COMPOSE_INTERACTION_POLICY_OPTION_CHANGE = 'COMPOSE_INTERACTION_POLICY_OPTION_CHANGE' as const; +const COMPOSE_CLEAR_LINK_SUGGESTION_CREATE = 'COMPOSE_CLEAR_LINK_SUGGESTION_CREATE' as const; +const COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE = 'COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE' as const; + const getAccount = makeGetAccount(); const messages = defineMessages({ @@ -942,6 +946,18 @@ const changeComposeInteractionPolicyOption = (composeId: string, policy: Policy, initial, }); +const suggestClearLink = (composeId: string, suggestion: ClearLinkSuggestion | null) => ({ + type: COMPOSE_CLEAR_LINK_SUGGESTION_CREATE, + composeId, + suggestion, +}); + +const ignoreClearLinkSuggestion = (composeId: string, key: string) => ({ + type: COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE, + composeId, + key, +}); + type ComposeAction = ComposeSetStatusAction | ReturnType @@ -994,7 +1010,9 @@ type ComposeAction = | ReturnType | ReturnType | ReturnType - | ReturnType; + | ReturnType + | ReturnType + | ReturnType; export { COMPOSE_CHANGE, @@ -1049,6 +1067,8 @@ export { COMPOSE_ADD_SUGGESTED_LANGUAGE, COMPOSE_FEDERATED_CHANGE, COMPOSE_INTERACTION_POLICY_OPTION_CHANGE, + COMPOSE_CLEAR_LINK_SUGGESTION_CREATE, + COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE, setComposeToStatus, replyCompose, cancelReplyCompose, @@ -1096,6 +1116,8 @@ export { addSuggestedLanguage, changeComposeFederated, changeComposeInteractionPolicyOption, + suggestClearLink, + ignoreClearLinkSuggestion, type ComposeReplyAction, type ComposeSuggestionSelectAction, type ComposeAction, diff --git a/packages/pl-fe/src/features/compose/components/clear-link-suggestion.tsx b/packages/pl-fe/src/features/compose/components/clear-link-suggestion.tsx new file mode 100644 index 000000000..9d570fa74 --- /dev/null +++ b/packages/pl-fe/src/features/compose/components/clear-link-suggestion.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { spring } from 'react-motion'; + +import Button from 'pl-fe/components/ui/button'; +import HStack from 'pl-fe/components/ui/hstack'; +import Stack from 'pl-fe/components/ui/stack'; +import OptionalMotion from 'pl-fe/features/ui/util/optional-motion'; +import { useCompose } from 'pl-fe/hooks/use-compose'; + +interface IClearLinkSuggestion { + composeId: string; + handleAccept: (key: string) => void; + handleReject: (key: string) => void; +} + +const ClearLinkSuggestion = ({ + composeId, + handleAccept, + handleReject, +}: IClearLinkSuggestion) => { + const compose = useCompose(composeId); + const suggestion = compose.clear_link_suggestion; + + if (!suggestion) return null; + + return ( + + {({ opacity, scaleX, scaleY }) => ( + + + {suggestion.originalUrl.length > 20 ? suggestion.originalUrl.slice(0, 20) + '…' : suggestion.originalUrl} }} + /> + + + + + + + )} + + ); +}; + +export { ClearLinkSuggestion as default }; diff --git a/packages/pl-fe/src/features/compose/components/compose-form.tsx b/packages/pl-fe/src/features/compose/components/compose-form.tsx index ca3e5c471..523b00f4c 100644 --- a/packages/pl-fe/src/features/compose/components/compose-form.tsx +++ b/packages/pl-fe/src/features/compose/components/compose-form.tsx @@ -1,5 +1,5 @@ import clsx from 'clsx'; -import { CLEAR_EDITOR_COMMAND, TextNode, type LexicalEditor } from 'lexical'; +import { $getNodeByKey, CLEAR_EDITOR_COMMAND, TextNode, type LexicalEditor } from 'lexical'; import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; @@ -11,6 +11,8 @@ import { fetchComposeSuggestions, selectComposeSuggestion, uploadCompose, + ignoreClearLinkSuggestion, + suggestClearLink, } from 'pl-fe/actions/compose'; import Button from 'pl-fe/components/ui/button'; import HStack from 'pl-fe/components/ui/hstack'; @@ -30,6 +32,7 @@ import WarningContainer from '../containers/warning-container'; import { $createEmojiNode } from '../editor/nodes/emoji-node'; import { countableText } from '../util/counter'; +import ClearLinkSuggestion from './clear-link-suggestion'; import ContentTypeButton from './content-type-button'; import InteractionPolicyButton from './interaction-policy-button'; import LanguageDropdown from './language-dropdown'; @@ -47,6 +50,7 @@ import UploadForm from './upload-form'; import VisualCharacterCounter from './visual-character-counter'; import Warning from './warning'; +import type { LinkNode } from '@lexical/link'; import type { AutoSuggestion } from 'pl-fe/components/autosuggest-input'; import type { Emoji } from 'pl-fe/features/emoji'; @@ -172,6 +176,29 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab dispatch(uploadCompose(id, files, intl)); }; + const onAcceptClearLinkSuggestion = (key: string) => { + const editor = editorRef.current; + const suggestion = compose.clear_link_suggestion; + if (!editor || !suggestion) return; + + editor.update(() => { + const node: LinkNode | null = $getNodeByKey(key); + if (node) { + node.setURL(suggestion.cleanUrl); + const children = node.getChildren(); + const textNode = children[0] as TextNode; + if (children.length === 1 && textNode.getType() === 'text' && textNode.getTextContent() === suggestion.originalUrl) { + textNode.setTextContent(suggestion.cleanUrl); + } + } + dispatch(suggestClearLink(id, null)); + }); + }; + + const onRejectClearLinkSuggestion = (key: string) => { + dispatch(ignoreClearLinkSuggestion(id, key)); + }; + useEffect(() => { document.addEventListener('click', handleClick, true); @@ -281,6 +308,8 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab + + {composeModifiers} diff --git a/packages/pl-fe/src/features/compose/editor/plugins/state-plugin.tsx b/packages/pl-fe/src/features/compose/editor/plugins/state-plugin.tsx index dbf60957e..e627569e0 100644 --- a/packages/pl-fe/src/features/compose/editor/plugins/state-plugin.tsx +++ b/packages/pl-fe/src/features/compose/editor/plugins/state-plugin.tsx @@ -1,15 +1,18 @@ +import { AutoLinkNode, LinkNode } from '@lexical/link'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $createRemarkExport } from '@mkljczk/lexical-remark'; -import { $getRoot } from 'lexical'; +import { $nodesOfType, $getRoot, type EditorState, $getNodeByKey } from 'lexical'; import debounce from 'lodash/debounce'; import { useCallback, useEffect } from 'react'; import { useIntl } from 'react-intl'; -import { addSuggestedLanguage, addSuggestedQuote, setEditorState } from 'pl-fe/actions/compose'; +import { addSuggestedLanguage, addSuggestedQuote, setEditorState, suggestClearLink } from 'pl-fe/actions/compose'; import { fetchStatus } from 'pl-fe/actions/statuses'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useFeatures } from 'pl-fe/hooks/use-features'; +import { useSettings } from 'pl-fe/hooks/use-settings'; import { getStatusIdsFromLinksInContent } from 'pl-fe/utils/status'; +import Purify from 'pl-fe/utils/url-purify'; import type { LanguageIdentificationModel } from 'fasttext.wasm.js/dist/models/language-identification/common.js'; @@ -25,6 +28,54 @@ const StatePlugin: React.FC = ({ composeId, isWysiwyg }) => { const dispatch = useAppDispatch(); const [editor] = useLexicalComposerContext(); const features = useFeatures(); + const { urlPrivacy } = useSettings(); + + const checkUrls = useCallback(debounce((editorState: EditorState) => { + dispatch(async (_, getState) => { + if (!urlPrivacy.clearLinksInCompose) return; + + const state = getState(); + const compose = state.compose[composeId]; + + editorState.read(() => { + const compareUrl = (url: string) => { + const cleanUrl = Purify.clearUrl(url); + return { + originalUrl: url, + cleanUrl, + isDirty: cleanUrl !== url, + }; + }; + + if (compose.clear_link_suggestion?.key) { + const node = $getNodeByKey(compose.clear_link_suggestion.key); + const url = (node as LinkNode | null)?.getURL?.(); + if (!url || node === null || !compareUrl(url).isDirty) { + dispatch(suggestClearLink(composeId, null)); + } else { + return; + } + } + + const links = [...$nodesOfType(AutoLinkNode), ...$nodesOfType(LinkNode)]; + + for (const link of links) { + if (compose.dismissed_clear_links_suggestions.includes(link.getKey())) { + continue; + } + + const { originalUrl, cleanUrl, isDirty } = compareUrl(link.getURL()); + if (!isDirty) { + continue; + } + + if (isDirty) { + return dispatch(suggestClearLink(composeId, { key: link.getKey(), originalUrl, cleanUrl })); + } + } + }); + }); + }, 2000), [urlPrivacy.clearLinksInCompose]); const getQuoteSuggestions = useCallback(debounce((text: string) => { dispatch(async (_, getState) => { @@ -97,6 +148,7 @@ const StatePlugin: React.FC = ({ composeId, isWysiwyg }) => { const isEmpty = text === ''; const data = isEmpty ? null : JSON.stringify(editorState.toJSON()); dispatch(setEditorState(composeId, data, text)); + checkUrls(editorState); getQuoteSuggestions(plainText); detectLanguage(plainText); }); diff --git a/packages/pl-fe/src/features/url-privacy/index.tsx b/packages/pl-fe/src/features/url-privacy/index.tsx index f47261516..30dd18dac 100644 --- a/packages/pl-fe/src/features/url-privacy/index.tsx +++ b/packages/pl-fe/src/features/url-privacy/index.tsx @@ -26,7 +26,7 @@ const UrlPrivacy = () => { const { urlPrivacy } = useSettings(); - // const [clearLinksInCompose, setClearLinksInCompose] = useState(urlPrivacy.clearLinksInCompose); + const [clearLinksInCompose, setClearLinksInCompose] = useState(urlPrivacy.clearLinksInCompose); const [clearLinksInContent, setClearLinksInContent] = useState(urlPrivacy.clearLinksInContent); // const [allowReferralMarketing, setAllowReferralMarketing] = useState(urlPrivacy.allowReferralMarketing); const [hashUrl, setHashUrl] = useState(urlPrivacy.hashUrl); @@ -35,7 +35,7 @@ const UrlPrivacy = () => { const onSubmit = () => { dispatch(changeSetting(['urlPrivacy'], { ...urlPrivacy, - // clearLinksInCompose, + clearLinksInCompose, clearLinksInContent, // allowReferralMarketing, hashUrl, @@ -59,9 +59,9 @@ const UrlPrivacy = () => {
- {/* }> + }> setClearLinksInCompose(target.checked)} /> - */} + }> setClearLinksInContent(target.checked)} /> diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index a15231ff0..53c5fab2a 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -420,6 +420,9 @@ "common.cancel": "Cancel", "compare_history_modal.header": "Edit history", "compose.character_counter.title": "Used {chars} out of {maxChars} {maxChars, plural, one {character} other {characters}}", + "compose.clear_link_suggestion.body": "The link {url} likely includes tracking elements used to mark your online activity. They are not required for the URL to work. Do you want to remove them?", + "compose.clear_link_suggestion.ignore": "Ignore", + "compose.clear_link_suggestion.remove": "Remove", "compose.edit_success": "Your post was edited", "compose.invalid_schedule": "You must schedule a post at least 5 minutes out.", "compose.language_dropdown.add_language": "Add language", @@ -1638,6 +1641,7 @@ "upload_form.preview": "Preview", "upload_form.undo": "Delete", "upload_progress.label": "Uploading…", + "url_privacy.clear_links_in_compose": "Suggest removing tracking parameters when composing a post", "url_privacy.clear_links_in_content": "Remove tracking parameters from displayed posts", "url_privacy.hash_url.hint": "SHA256 hash of rules database, used to avoid unnecessary fetches, eg. {url}", "url_privacy.hash_url.label": "URL cleaning rules hash address (optional)", diff --git a/packages/pl-fe/src/reducers/compose.ts b/packages/pl-fe/src/reducers/compose.ts index 96fa2afa6..540a90014 100644 --- a/packages/pl-fe/src/reducers/compose.ts +++ b/packages/pl-fe/src/reducers/compose.ts @@ -55,9 +55,11 @@ import { COMPOSE_CHANGE_MEDIA_ORDER, COMPOSE_ADD_SUGGESTED_QUOTE, COMPOSE_FEDERATED_CHANGE, + COMPOSE_INTERACTION_POLICY_OPTION_CHANGE, + COMPOSE_CLEAR_LINK_SUGGESTION_CREATE, + COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE, type ComposeAction, type ComposeSuggestionSelectAction, - COMPOSE_INTERACTION_POLICY_OPTION_CHANGE, } from '../actions/compose'; import { EVENT_COMPOSE_CANCEL, EVENT_FORM_SET, type EventsAction } from '../actions/events'; import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS, type MeAction } from '../actions/me'; @@ -90,6 +92,12 @@ const newPoll = (params: Partial = {}): ComposePoll => ({ ...params, }); +interface ClearLinkSuggestion { + key: string; + originalUrl: string; + cleanUrl: string; +} + interface Compose { caretPosition: number | null; content_type: string; @@ -129,6 +137,8 @@ interface Compose { federated: boolean; approvalRequired: boolean; interactionPolicy: InteractionPolicy | null; + dismissed_clear_links_suggestions: Array; + clear_link_suggestion: ClearLinkSuggestion | null; } const newCompose = (params: Partial = {}): Compose => ({ @@ -170,6 +180,8 @@ const newCompose = (params: Partial = {}): Compose => ({ federated: true, approvalRequired: false, interactionPolicy: null, + dismissed_clear_links_suggestions: [], + clear_link_suggestion: null, ...params, }); @@ -688,6 +700,17 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | In }); case INSTANCE_FETCH_SUCCESS: return updateCompose(state, 'default', (compose) => updateDefaultContentType(compose, action.instance)); + case COMPOSE_CLEAR_LINK_SUGGESTION_CREATE: + return updateCompose(state, action.composeId, compose => { + compose.clear_link_suggestion = action.suggestion; + }); + case COMPOSE_CLEAR_LINK_SUGGESTION_IGNORE: + return updateCompose(state, action.composeId, compose => { + if (compose.clear_link_suggestion?.key === action.key) { + compose.clear_link_suggestion = null; + } + compose.dismissed_clear_links_suggestions.push(action.key); + }); default: return state; } @@ -695,6 +718,7 @@ const compose = (state = initialState, action: ComposeAction | EventsAction | In export { type Compose, + type ClearLinkSuggestion, statusToMentionsAccountIdsArray, initialState, compose as default,