pl-fe: suspicious link warning

Signed-off-by: nicole mikołajczyk <git@mkljczk.pl>
This commit is contained in:
nicole mikołajczyk
2025-07-30 19:50:41 +02:00
parent 110331e3d3
commit ad63577792
3 changed files with 567 additions and 3 deletions

View File

@ -4,6 +4,7 @@ import DOMPurify from 'isomorphic-dompurify';
import groupBy from 'lodash/groupBy';
import minBy from 'lodash/minBy';
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import Emojify from 'pl-fe/features/emoji/emojify';
@ -20,6 +21,15 @@ import type { CustomEmoji, Mention } from 'pl-api';
const GREENTEXT_CLASS = 'dark:text-accent-green text-lime-600';
const checkSuspiciousUrl = (url: string): boolean => {
try {
const { host } = new URL(url);
return /^verify\.form/.test(host);
} catch (e) {
return false;
}
};
const nodesToText = (nodes: Array<DOMNode>): string =>
nodes.map(node => node.type === 'text' ? node.data : node.type === 'tag' ? nodesToText(node.children as Array<DOMNode>) : '').join('');
@ -173,6 +183,8 @@ function parseContent({
const hashtags: Array<string> = [];
let hasSuspiciousUrl = false;
const transformText = (data: string, key?: React.Key) => {
const text = speakAsCat ? nyaize(data) : data;
@ -264,6 +276,10 @@ function parseContent({
}
}
if (checkSuspiciousUrl(domNode.attribs.href)) {
hasSuspiciousUrl = true;
}
return fallback;
}
@ -296,7 +312,21 @@ function parseContent({
},
};
const content = parse(DOMPurify.sanitize(html, { ADD_ATTR: ['target'], USE_PROFILES: { html: true } }), options);
let content = parse(DOMPurify.sanitize(html, { ADD_ATTR: ['target'], USE_PROFILES: { html: true } }), options);
if (hasSuspiciousUrl) {
content = (
<>
<div className='mb-2 rounded border border-solid border-gray-400 px-2.5 py-2 text-xs text-gray-900 dark:border-gray-800 dark:text-white'>
<FormattedMessage
id='suspicious_url_warning.body'
defaultMessage='This post might include a suspicious link. Please be cautious before entering any personal data or payment information.'
/>
</div>
{content}
</>
);
}
if (extractHashtags) return {
content,

View File

@ -0,0 +1,533 @@
import React, { useRef, useEffect } from 'react';
import Icon from './icon';
interface IMultiselectProps {
options: any;
// disablePreSelectedValues?: boolean;
selectedValues?: any;
displayValue: string;
placeholder?: string;
emptyRecordMsg?: string;
onSelect?: (selectedList:any, selectedItem: any) => void;
onRemove?: (selectedList:any, selectedItem: any) => void;
disable?: boolean;
className?: string;
}
function useOutsideAlerter(ref, clickEvent) {
useEffect(() => {
function handleClickOutside(event) {
if (ref.current && !ref.current.contains(event.target)) {
clickEvent();
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref]);
}
/**
* Component that alerts if you click outside of it
*/
function OutsideAlerter(props) {
const wrapperRef = useRef(null);
useOutsideAlerter(wrapperRef, props.outsideClick);
return <div ref={wrapperRef}>{props.children}</div>;
}
/**
*
*/
export class Multiselect extends React.Component<IMultiselectProps, any> {
static defaultProps: IMultiselectProps;
/**
*
*/
constructor(props: IMultiselectProps) {
super(props);
this.state = {
inputValue: '',
options: props.options,
filteredOptions: props.options,
unfilteredOptions: props.options,
selectedValues: Object.assign([], props.selectedValues),
preSelectedValues: Object.assign([], props.selectedValues),
toggleOptionsList: false,
highlightOption: 0,
// keepSearchTerm: props.keepSearchTerm,
groupedObject: [],
};
// @ts-ignore
this.optionTimeout = null;
// @ts-ignore
this.searchWrapper = React.createRef();
// @ts-ignore
this.searchBox = React.createRef();
this.onChange = this.onChange.bind(this);
this.onFocus = this.onFocus.bind(this);
this.onBlur = this.onBlur.bind(this);
this.renderMultiselectContainer = this.renderMultiselectContainer.bind(this);
this.renderSelectedList = this.renderSelectedList.bind(this);
this.onRemoveSelectedItem = this.onRemoveSelectedItem.bind(this);
this.toggelOptionList = this.toggelOptionList.bind(this);
this.onArrowKeyNavigation = this.onArrowKeyNavigation.bind(this);
this.onSelectItem = this.onSelectItem.bind(this);
this.filterOptionsByInput = this.filterOptionsByInput.bind(this);
this.removeSelectedValuesFromOptions = this.removeSelectedValuesFromOptions.bind(this);
this.isSelectedValue = this.isSelectedValue.bind(this);
this.isDisablePreSelectedValues = this.isDisablePreSelectedValues.bind(this);
this.renderNormalOption = this.renderNormalOption.bind(this);
this.listenerCallback = this.listenerCallback.bind(this);
this.resetSelectedValues = this.resetSelectedValues.bind(this);
this.getSelectedItems = this.getSelectedItems.bind(this);
this.getSelectedItemsCount = this.getSelectedItemsCount.bind(this);
this.hideOnClickOutside = this.hideOnClickOutside.bind(this);
this.onCloseOptionList = this.onCloseOptionList.bind(this);
this.isVisible = this.isVisible.bind(this);
}
/**
*
*/
initialSetValue() {
this.removeSelectedValuesFromOptions(false);
}
/**
*
*/
resetSelectedValues() {
const { unfilteredOptions } = this.state;
return new Promise((resolve) => {
this.setState({
selectedValues: [],
preSelectedValues: [],
options: unfilteredOptions,
filteredOptions: unfilteredOptions,
}, () => {
// @ts-ignore
resolve();
this.initialSetValue();
});
});
}
/**
*
*/
getSelectedItems() {
return this.state.selectedValues;
}
/**
*
*/
getSelectedItemsCount() {
return this.state.selectedValues.length;
}
/**
*
*/
componentDidMount() {
this.initialSetValue();
// @ts-ignore
this.searchWrapper.current.addEventListener('click', this.listenerCallback);
}
/**
*
*/
componentDidUpdate(prevProps: IMultiselectProps) {
const { options, selectedValues } = this.props;
const { options: prevOptions, selectedValues: prevSelectedvalues } = prevProps;
if (JSON.stringify(prevOptions) !== JSON.stringify(options)) {
this.setState({ options, filteredOptions: options, unfilteredOptions: options }, this.initialSetValue);
}
if (JSON.stringify(prevSelectedvalues) !== JSON.stringify(selectedValues)) {
this.setState({ selectedValues: Object.assign([], selectedValues), preSelectedValues: Object.assign([], selectedValues) }, this.initialSetValue);
}
}
/**
*
*/
listenerCallback() {
// @ts-ignore
this.searchBox.current.focus();
}
/**
*
*/
componentWillUnmount() {
// @ts-ignore
if (this.optionTimeout) {
// @ts-ignore
clearTimeout(this.optionTimeout);
}
// @ts-ignore
this.searchWrapper.current.removeEventListener('click', this.listenerCallback);
}
// Skipcheck flag - value will be true when the func called from on deselect anything.
/**
*
*/
removeSelectedValuesFromOptions(skipCheck: boolean) {
const { displayValue } = this.props;
const { selectedValues = [], unfilteredOptions } = this.state;
if (!selectedValues.length && !skipCheck) {
return;
}
const optionList = unfilteredOptions.filter(item => {
return selectedValues.findIndex(
v => v[displayValue] === item[displayValue],
) === -1
? true
: false;
});
this.setState(
{ options: optionList, filteredOptions: optionList },
this.filterOptionsByInput,
);
return;
}
/**
*
*/
onChange(event) {
const { onSearch } = this.props;
this.setState(
{ inputValue: event.target.value },
this.filterOptionsByInput,
);
if (onSearch) {
onSearch(event.target.value);
}
}
/**
*
*/
filterOptionsByInput() {
let { options, filteredOptions, inputValue } = this.state;
const { displayValue } = this.props;
options = filteredOptions.filter(i => this.matchValues(i[displayValue], inputValue));
this.setState({ options });
}
/**
*
*/
matchValues(value, search) {
if (value.toLowerCase) {
return value.toLowerCase().indexOf(search.toLowerCase()) > -1;
}
return value.toString().indexOf(search) > -1;
}
/**
*
*/
onArrowKeyNavigation(e) {
const {
options,
highlightOption,
toggleOptionsList,
inputValue,
selectedValues,
} = this.state;
if (e.keyCode === 8 && !inputValue && selectedValues.length) {
this.onRemoveSelectedItem(selectedValues.length - 1);
}
if (!options.length) {
return;
}
if (e.keyCode === 38) {
if (highlightOption > 0) {
this.setState(previousState => ({
highlightOption: previousState.highlightOption - 1,
}));
} else {
this.setState({ highlightOption: options.length - 1 });
}
} else if (e.keyCode === 40) {
if (highlightOption < options.length - 1) {
this.setState(previousState => ({
highlightOption: previousState.highlightOption + 1,
}));
} else {
this.setState({ highlightOption: 0 });
}
} else if (e.key === 'Enter' && options.length && toggleOptionsList) {
if (highlightOption === -1) {
return;
}
this.onSelectItem(options[highlightOption]);
}
// TODO: Instead of scrollIntoView need to find better soln for scroll the dropwdown container.
// setTimeout(() => {
// const element = document.querySelector("ul.optionContainer .highlight");
// if (element) {
// element.scrollIntoView();
// }
// });
}
/**
*
*/
onRemoveSelectedItem(item) {
let { selectedValues, index = 0 } = this.state;
const { onRemove, displayValue } = this.props;
index = selectedValues.findIndex(
i => i[displayValue] === item[displayValue],
);
selectedValues.splice(index, 1);
onRemove(selectedValues, item);
this.setState({ selectedValues }, () => {
this.removeSelectedValuesFromOptions(true);
});
}
/**
*
*/
onSelectItem(item) {
const { selectedValues } = this.state;
const { onSelect } = this.props;
this.setState({
inputValue: '',
});
if (this.isSelectedValue(item)) {
this.onRemoveSelectedItem(item);
return;
}
selectedValues.push(item);
onSelect(selectedValues, item);
this.setState({ selectedValues }, () => {
this.removeSelectedValuesFromOptions(true);
});
}
/**
*
*/
isSelectedValue(item) {
const { displayValue } = this.props;
const { selectedValues } = this.state;
return (
selectedValues.filter(i => i[displayValue] === item[displayValue])
.length > 0
);
}
/**
*
*/
renderOptionList() {
const { emptyRecordMsg } = this.props;
const { options } = this.state;
return (
<ul className={'optionContainer'}>
{options.length === 0 && <span className={'notFound'}>{emptyRecordMsg}</span>}
{this.renderNormalOption()}
</ul>
);
}
/**
*
*/
renderNormalOption() {
const { displayValue } = this.props;
const { highlightOption } = this.state;
return this.state.options.map((option, i) => {
const isSelected = this.isSelectedValue(option);
return (
<li
key={`option${i}`}
className={`option ${isSelected ? 'selected' : ''} ${highlightOption === i ? 'highlightOption highlight' : ''} ${this.isDisablePreSelectedValues(option) ? 'disableSelection' : ''}`}
onClick={() => this.onSelectItem(option)}
>
{option[displayValue]}
</li>
);
});
}
/**
*
*/
renderSelectedList() {
const { displayValue } = this.props;
const { selectedValues } = this.state;
return selectedValues.map((value, index) => (
<span className={`chip} ${this.isDisablePreSelectedValues(value) && 'disableSelection'}`} key={index}>
{value[displayValue]}
{!this.isDisablePreSelectedValues(value) && (
<div
role='button'
onClick={() => this.onRemoveSelectedItem(value)}
>
<Icon
className='ml-1 size-4 hover:cursor-pointer' src={require('@tabler/icons/outline/circle-x.svg')}
/>
</div>
)}
</span>
));
}
/**
*
*/
isDisablePreSelectedValues(value) {
const { displayValue } = this.props;
const { preSelectedValues } = this.state;
if (!preSelectedValues.length) {
return false;
}
return (
preSelectedValues.filter(i => i[displayValue] === value[displayValue])
.length > 0
);
}
/**
*
*/
toggelOptionList() {
this.setState({
toggleOptionsList: !this.state.toggleOptionsList,
highlightOption: 0,
});
}
/**
*
*/
onCloseOptionList() {
this.setState({
toggleOptionsList: false,
highlightOption: 0,
inputValue: '',
});
}
/**
*
*/
onFocus(){
if (this.state.toggleOptionsList) {
// @ts-ignore
clearTimeout(this.optionTimeout);
} else {
this.toggelOptionList();
}
}
/**
*
*/
onBlur(){
this.setState({ inputValue: '' }, this.filterOptionsByInput);
// @ts-ignore
this.optionTimeout = setTimeout(this.onCloseOptionList, 250);
}
/**
*
*/
isVisible(elem) {
return !!elem && !!(elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length);
}
/**
*
*/
hideOnClickOutside() {
const element = document.getElementsByClassName('multiselect-container')[0];
const outsideClickListener = event => {
if (element && !element.contains(event.target) && this.isVisible(element)) {
this.toggelOptionList();
}
};
document.addEventListener('click', outsideClickListener);
}
/**
*
*/
renderMultiselectContainer() {
const { inputValue, toggleOptionsList, selectedValues } = this.state;
const { placeholder, id, name, disable, className } = this.props;
return (
<div className={`multiselect-container multiSelectContainer ${disable ? 'disable_ms' : ''} ${className || ''}`} id={id || 'multiselectContainerReact'}>
<div
className={'search-wrapper searchWrapper'}
ref={this.searchWrapper}
>
{this.renderSelectedList()}
<input
type='text'
ref={this.searchBox}
className='searchBox'
id={`${id || 'search'}_input`}
name={`${name || 'search_name'}_input`}
onChange={this.onChange}
value={inputValue}
onFocus={this.onFocus}
onBlur={this.onBlur}
placeholder={(selectedValues.length) ? '' : placeholder}
onKeyDown={this.onArrowKeyNavigation}
autoComplete='off'
disabled={disable}
/>
</div>
<div
className={`optionListContainer ${
toggleOptionsList ? 'displayBlock' : 'displayNone'
}`}
onMouseDown={(e) => {
e.preventDefault();
}}
>
{this.renderOptionList()}
</div>
</div>
);
}
/**
*
*/
render() {
return (
<OutsideAlerter outsideClick={this.onCloseOptionList}>
{this.renderMultiselectContainer()}
</OutsideAlerter>
);
}
}
Multiselect.defaultProps = {
options: [],
selectedValues: [],
displayValue: 'model',
placeholder: 'Select',
emptyRecordMsg: 'No Options Available',
onSelect: () => {},
onRemove: () => {},
id: '',
name: '',
className: '',
} as IMultiselectProps;

View File

@ -2,7 +2,7 @@
"about.also_available": "Available in:",
"accordion.collapse": "Collapse",
"accordion.expand": "Expand",
"account.add_or_remove_from_list": "Add or Remove from lists",
"account.add_or_remove_from_list": "Add or remove from lists",
"account.avatar.alt": "Avatar",
"account.avatar.description": "Avatar description",
"account.avatar.with_content": "Avatar for {username}: {alt}",
@ -1084,7 +1084,7 @@
"lightbox.view_context": "View context",
"link_preview.more_from_author": "More from {name}",
"list.click_to_add": "Click here to add people",
"list_adder.header_title": "Add or Remove from Lists",
"list_adder.header_title": "Add or remove from lists",
"lists.account.add": "Add to list",
"lists.account.remove": "Remove from list",
"lists.delete": "Delete list",
@ -1123,6 +1123,7 @@
"login_form.divider": "or",
"login_form.external": "Sign in from remote instance",
"login_form.header": "Sign in",
"suspicious_url_warning.body": "This post might include a suspicious link. Please be cautious before entering any personal data or payment information.",
"manage_group.blocked_members": "Banned members",
"manage_group.confirmation.copy": "Copy link",
"manage_group.confirmation.info_1": "As the owner of this group, you can assign staff, delete posts and much more.",