pl-fe: Suggest cleaning dirty URLs in compose form

Signed-off-by: mkljczk <git@mkljczk.pl>
This commit is contained in:
mkljczk
2025-03-09 20:40:07 +01:00
parent 994bc8bb39
commit a6ca601c1f
7 changed files with 200 additions and 9 deletions

View File

@ -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 (
<OptionalMotion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
<Stack space={1} className='rounded border border-solid border-gray-400 bg-transparent px-2.5 py-2 text-xs text-gray-900 dark:border-gray-800 dark:text-white' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
<span>
<FormattedMessage
id='compose.clear_link_suggestion.body'
defaultMessage='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?'
values={{ url: <span className='underline'>{suggestion.originalUrl.length > 20 ? suggestion.originalUrl.slice(0, 20) + '…' : suggestion.originalUrl}</span> }}
/>
</span>
<HStack space={2} justifyContent='end'>
<Button
theme='muted'
size='xs'
onClick={() => handleReject(suggestion.key)}
>
<FormattedMessage id='compose.clear_link_suggestion.ignore' defaultMessage='Ignore' />
</Button>
<Button
theme='muted'
size='xs'
onClick={() => handleAccept(suggestion.key)}
>
<FormattedMessage id='compose.clear_link_suggestion.remove' defaultMessage='Remove' />
</Button>
</HStack>
</Stack>
)}
</OptionalMotion>
);
};
export { ClearLinkSuggestion as default };

View File

@ -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 extends string>({ 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 extends string>({ id, shouldCondense, autoFocus, clickab
</Suspense>
</div>
<ClearLinkSuggestion composeId={id} handleAccept={onAcceptClearLinkSuggestion} handleReject={onRejectClearLinkSuggestion} />
{composeModifiers}
<QuotedStatusContainer composeId={id} />

View File

@ -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<IStatePlugin> = ({ 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<IStatePlugin> = ({ composeId, isWysiwyg }) => {
const isEmpty = text === '';
const data = isEmpty ? null : JSON.stringify(editorState.toJSON());
dispatch(setEditorState(composeId, data, text));
checkUrls(editorState);
getQuoteSuggestions(plainText);
detectLanguage(plainText);
});

View File

@ -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 = () => {
<CardBody>
<Form onSubmit={onSubmit}>
<List>
{/* <ListItem label={<FormattedMessage id='url_privacy.clear_links_in_compose' defaultMessage='Suggest removing tracking parameters when composing a post' />}>
<ListItem label={<FormattedMessage id='url_privacy.clear_links_in_compose' defaultMessage='Suggest removing tracking parameters when composing a post' />}>
<Toggle checked={clearLinksInCompose} onChange={({ target }) => setClearLinksInCompose(target.checked)} />
</ListItem> */}
</ListItem>
<ListItem label={<FormattedMessage id='url_privacy.clear_links_in_content' defaultMessage='Remove tracking parameters from displayed posts' />}>
<Toggle checked={clearLinksInContent} onChange={({ target }) => setClearLinksInContent(target.checked)} />