pl-fe: Suggest cleaning dirty URLs in compose form
Signed-off-by: mkljczk <git@mkljczk.pl>
This commit is contained in:
@ -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<typeof changeCompose>
|
||||
@ -994,7 +1010,9 @@ type ComposeAction =
|
||||
| ReturnType<typeof addSuggestedQuote>
|
||||
| ReturnType<typeof addSuggestedLanguage>
|
||||
| ReturnType<typeof changeComposeFederated>
|
||||
| ReturnType<typeof changeComposeInteractionPolicyOption>;
|
||||
| ReturnType<typeof changeComposeInteractionPolicyOption>
|
||||
| ReturnType<typeof suggestClearLink>
|
||||
| ReturnType<typeof ignoreClearLinkSuggestion>;
|
||||
|
||||
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,
|
||||
|
||||
@ -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 };
|
||||
@ -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} />
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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)} />
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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> = {}): 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<string>;
|
||||
clear_link_suggestion: ClearLinkSuggestion | null;
|
||||
}
|
||||
|
||||
const newCompose = (params: Partial<Compose> = {}): Compose => ({
|
||||
@ -170,6 +180,8 @@ const newCompose = (params: Partial<Compose> = {}): 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,
|
||||
|
||||
Reference in New Issue
Block a user