Remove hashtags from the last line of a status if it only contains hashtags (#26499)
parent
ac0eb0533e
commit
061fd66ee6
6 changed files with 428 additions and 56 deletions
@ -0,0 +1,184 @@ |
||||
import { fromJS } from 'immutable'; |
||||
|
||||
import type { StatusLike } from '../hashtag_bar'; |
||||
import { computeHashtagBarForStatus } from '../hashtag_bar'; |
||||
|
||||
function createStatus( |
||||
content: string, |
||||
hashtags: string[], |
||||
hasMedia = false, |
||||
spoilerText?: string, |
||||
) { |
||||
return fromJS({ |
||||
tags: hashtags.map((name) => ({ name })), |
||||
contentHtml: content, |
||||
media_attachments: hasMedia ? ['fakeMedia'] : [], |
||||
spoiler_text: spoilerText, |
||||
}) as unknown as StatusLike; // need to force the type here, as it is not properly defined
|
||||
} |
||||
|
||||
describe('computeHashtagBarForStatus', () => { |
||||
it('does nothing when there are no tags', () => { |
||||
const status = createStatus('<p>Simple text</p>', []); |
||||
|
||||
const { hashtagsInBar, statusContentProps } = |
||||
computeHashtagBarForStatus(status); |
||||
|
||||
expect(hashtagsInBar).toEqual([]); |
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot( |
||||
`"<p>Simple text</p>"`, |
||||
); |
||||
}); |
||||
|
||||
it('displays out of band hashtags in the bar', () => { |
||||
const status = createStatus( |
||||
'<p>Simple text <a href="test">#hashtag</a></p>', |
||||
['hashtag', 'test'], |
||||
); |
||||
|
||||
const { hashtagsInBar, statusContentProps } = |
||||
computeHashtagBarForStatus(status); |
||||
|
||||
expect(hashtagsInBar).toEqual(['test']); |
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot( |
||||
`"<p>Simple text <a href="test">#hashtag</a></p>"`, |
||||
); |
||||
}); |
||||
|
||||
it('extract tags from the last line', () => { |
||||
const status = createStatus( |
||||
'<p>Simple text</p><p><a href="test">#hashtag</a></p>', |
||||
['hashtag'], |
||||
); |
||||
|
||||
const { hashtagsInBar, statusContentProps } = |
||||
computeHashtagBarForStatus(status); |
||||
|
||||
expect(hashtagsInBar).toEqual(['hashtag']); |
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot( |
||||
`"<p>Simple text</p>"`, |
||||
); |
||||
}); |
||||
|
||||
it('does not include tags from content', () => { |
||||
const status = createStatus( |
||||
'<p>Simple text with a <a href="test">#hashtag</a></p><p><a href="test">#hashtag</a></p>', |
||||
['hashtag'], |
||||
); |
||||
|
||||
const { hashtagsInBar, statusContentProps } = |
||||
computeHashtagBarForStatus(status); |
||||
|
||||
expect(hashtagsInBar).toEqual([]); |
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot( |
||||
`"<p>Simple text with a <a href="test">#hashtag</a></p>"`, |
||||
); |
||||
}); |
||||
|
||||
it('works with one line status and hashtags', () => { |
||||
const status = createStatus( |
||||
'<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>', |
||||
['hashtag', 'test'], |
||||
); |
||||
|
||||
const { hashtagsInBar, statusContentProps } = |
||||
computeHashtagBarForStatus(status); |
||||
|
||||
expect(hashtagsInBar).toEqual([]); |
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot( |
||||
`"<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>"`, |
||||
); |
||||
}); |
||||
|
||||
it('de-duplicate accentuated characters with case differences', () => { |
||||
const status = createStatus( |
||||
'<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>', |
||||
['éaa'], |
||||
); |
||||
|
||||
const { hashtagsInBar, statusContentProps } = |
||||
computeHashtagBarForStatus(status); |
||||
|
||||
expect(hashtagsInBar).toEqual(['Éaa']); |
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot( |
||||
`"<p>Text</p>"`, |
||||
); |
||||
}); |
||||
|
||||
it('does not display in bar a hashtag in content with a case difference', () => { |
||||
const status = createStatus( |
||||
'<p>Text <a href="test">#Éaa</a></p><p><a href="test">#éaa</a></p>', |
||||
['éaa'], |
||||
); |
||||
|
||||
const { hashtagsInBar, statusContentProps } = |
||||
computeHashtagBarForStatus(status); |
||||
|
||||
expect(hashtagsInBar).toEqual([]); |
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot( |
||||
`"<p>Text <a href="test">#Éaa</a></p>"`, |
||||
); |
||||
}); |
||||
|
||||
it('does not modify a status with a line of hashtags only', () => { |
||||
const status = createStatus( |
||||
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>', |
||||
['test', 'hashtag'], |
||||
); |
||||
|
||||
const { hashtagsInBar, statusContentProps } = |
||||
computeHashtagBarForStatus(status); |
||||
|
||||
expect(hashtagsInBar).toEqual([]); |
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot( |
||||
`"<p><a href="test">#test</a> <a href="test">#hashtag</a></p>"`, |
||||
); |
||||
}); |
||||
|
||||
it('puts the hashtags in the bar if a status content has hashtags in the only line and has a media', () => { |
||||
const status = createStatus( |
||||
'<p>This is my content! <a href="test">#hashtag</a></p>', |
||||
['hashtag'], |
||||
true, |
||||
); |
||||
|
||||
const { hashtagsInBar, statusContentProps } = |
||||
computeHashtagBarForStatus(status); |
||||
|
||||
expect(hashtagsInBar).toEqual([]); |
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot( |
||||
`"<p>This is my content! <a href="test">#hashtag</a></p>"`, |
||||
); |
||||
}); |
||||
|
||||
it('puts the hashtags in the bar if a status content is only hashtags and has a media', () => { |
||||
const status = createStatus( |
||||
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>', |
||||
['test', 'hashtag'], |
||||
true, |
||||
); |
||||
|
||||
const { hashtagsInBar, statusContentProps } = |
||||
computeHashtagBarForStatus(status); |
||||
|
||||
expect(hashtagsInBar).toEqual(['test', 'hashtag']); |
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot(`""`); |
||||
}); |
||||
|
||||
it('does not use the hashtag bar if the status content is only hashtags, has a CW and a media', () => { |
||||
const status = createStatus( |
||||
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>', |
||||
['test', 'hashtag'], |
||||
true, |
||||
'My CW text', |
||||
); |
||||
|
||||
const { hashtagsInBar, statusContentProps } = |
||||
computeHashtagBarForStatus(status); |
||||
|
||||
expect(hashtagsInBar).toEqual([]); |
||||
expect(statusContentProps.statusContent).toMatchInlineSnapshot( |
||||
`"<p><a href="test">#test</a> <a href="test">#hashtag</a></p>"`, |
||||
); |
||||
}); |
||||
}); |
@ -1,50 +0,0 @@ |
||||
import PropTypes from 'prop-types'; |
||||
import { useMemo, useState, useCallback } from 'react'; |
||||
|
||||
import { FormattedMessage } from 'react-intl'; |
||||
|
||||
import { Link } from 'react-router-dom'; |
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes'; |
||||
|
||||
const domParser = new DOMParser(); |
||||
|
||||
// About two lines on desktop |
||||
const VISIBLE_HASHTAGS = 7; |
||||
|
||||
export const HashtagBar = ({ hashtags, text }) => { |
||||
const renderedHashtags = useMemo(() => { |
||||
const body = domParser.parseFromString(text, 'text/html').documentElement; |
||||
return [].filter.call(body.querySelectorAll('a[href]'), link => link.textContent[0] === '#' || (link.previousSibling?.textContent?.[link.previousSibling.textContent.length - 1] === '#')).map(node => node.textContent); |
||||
}, [text]); |
||||
|
||||
const invisibleHashtags = useMemo(() => ( |
||||
hashtags.filter(hashtag => !renderedHashtags.some(textContent => textContent.localeCompare(`#${hashtag.get('name')}`, undefined, { sensitivity: 'accent' }) === 0 || textContent.localeCompare(hashtag.get('name'), undefined, { sensitivity: 'accent' }) === 0)) |
||||
), [hashtags, renderedHashtags]); |
||||
|
||||
const [expanded, setExpanded] = useState(false); |
||||
const handleClick = useCallback(() => setExpanded(true), []); |
||||
|
||||
if (invisibleHashtags.isEmpty()) { |
||||
return null; |
||||
} |
||||
|
||||
const revealedHashtags = expanded ? invisibleHashtags : invisibleHashtags.take(VISIBLE_HASHTAGS); |
||||
|
||||
return ( |
||||
<div className='hashtag-bar'> |
||||
{revealedHashtags.map(hashtag => ( |
||||
<Link key={hashtag.get('name')} to={`/tags/${hashtag.get('name')}`}> |
||||
#{hashtag.get('name')} |
||||
</Link> |
||||
))} |
||||
|
||||
{!expanded && invisibleHashtags.size > VISIBLE_HASHTAGS && <button className='link-button' onClick={handleClick}><FormattedMessage id='hashtags.and_other' defaultMessage='…and {count, plural, other {# more}}' values={{ count: invisibleHashtags.size - VISIBLE_HASHTAGS }} /></button>} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
HashtagBar.propTypes = { |
||||
hashtags: ImmutablePropTypes.list, |
||||
text: PropTypes.string, |
||||
}; |
@ -0,0 +1,222 @@ |
||||
import { useState, useCallback } from 'react'; |
||||
|
||||
import { FormattedMessage } from 'react-intl'; |
||||
|
||||
import { Link } from 'react-router-dom'; |
||||
|
||||
import type { List, Record } from 'immutable'; |
||||
|
||||
import { groupBy, minBy } from 'lodash'; |
||||
|
||||
import { getStatusContent } from './status_content'; |
||||
|
||||
// About two lines on desktop
|
||||
const VISIBLE_HASHTAGS = 7; |
||||
|
||||
// Those types are not correct, they need to be replaced once this part of the state is typed
|
||||
export type TagLike = Record<{ name: string }>; |
||||
export type StatusLike = Record<{ |
||||
tags: List<TagLike>; |
||||
contentHTML: string; |
||||
media_attachments: List<unknown>; |
||||
spoiler_text?: string; |
||||
}>; |
||||
|
||||
function normalizeHashtag(hashtag: string) { |
||||
if (hashtag && hashtag.startsWith('#')) return hashtag.slice(1); |
||||
else return hashtag; |
||||
} |
||||
|
||||
function isNodeLinkHashtag(element: Node): element is HTMLLinkElement { |
||||
return ( |
||||
element instanceof HTMLAnchorElement && |
||||
// it may be a <a> starting with a hashtag
|
||||
(element.textContent?.[0] === '#' || |
||||
// or a #<a>
|
||||
element.previousSibling?.textContent?.[ |
||||
element.previousSibling.textContent.length - 1 |
||||
] === '#') |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Removes duplicates from an hashtag list, case-insensitive, keeping only the best one |
||||
* "Best" here is defined by the one with the more casing difference (ie, the most camel-cased one) |
||||
* @param hashtags The list of hashtags |
||||
* @returns The input hashtags, but with only 1 occurence of each (case-insensitive) |
||||
*/ |
||||
function uniqueHashtagsWithCaseHandling(hashtags: string[]) { |
||||
const groups = groupBy(hashtags, (tag) => |
||||
tag.normalize('NFKD').toLowerCase(), |
||||
); |
||||
|
||||
return Object.values(groups).map((tags) => { |
||||
if (tags.length === 1) return tags[0]; |
||||
|
||||
// The best match is the one where we have the less difference between upper and lower case letter count
|
||||
const best = minBy(tags, (tag) => { |
||||
const upperCase = Array.from(tag).reduce( |
||||
(acc, char) => (acc += char.toUpperCase() === char ? 1 : 0), |
||||
0, |
||||
); |
||||
|
||||
const lowerCase = tag.length - upperCase; |
||||
|
||||
return Math.abs(lowerCase - upperCase); |
||||
}); |
||||
|
||||
return best ?? tags[0]; |
||||
}); |
||||
} |
||||
|
||||
// Create the collator once, this is much more efficient
|
||||
const collator = new Intl.Collator(undefined, { sensitivity: 'accent' }); |
||||
function localeAwareInclude(collection: string[], value: string) { |
||||
return collection.find((item) => collator.compare(item, value) === 0); |
||||
} |
||||
|
||||
// We use an intermediate function here to make it easier to test
|
||||
export function computeHashtagBarForStatus(status: StatusLike): { |
||||
statusContentProps: { statusContent: string }; |
||||
hashtagsInBar: string[]; |
||||
} { |
||||
let statusContent = getStatusContent(status); |
||||
|
||||
const tagNames = status |
||||
.get('tags') |
||||
.map((tag) => tag.get('name')) |
||||
.toJS(); |
||||
|
||||
// this is returned if we stop the processing early, it does not change what is displayed
|
||||
const defaultResult = { |
||||
statusContentProps: { statusContent }, |
||||
hashtagsInBar: [], |
||||
}; |
||||
|
||||
// return early if this status does not have any tags
|
||||
if (tagNames.length === 0) return defaultResult; |
||||
|
||||
const template = document.createElement('template'); |
||||
template.innerHTML = statusContent.trim(); |
||||
|
||||
const lastChild = template.content.lastChild; |
||||
|
||||
if (!lastChild) return defaultResult; |
||||
|
||||
template.content.removeChild(lastChild); |
||||
const contentWithoutLastLine = template; |
||||
|
||||
// First, try to parse
|
||||
const contentHashtags = Array.from( |
||||
contentWithoutLastLine.content.querySelectorAll<HTMLLinkElement>('a[href]'), |
||||
).reduce<string[]>((result, link) => { |
||||
if (isNodeLinkHashtag(link)) { |
||||
if (link.textContent) result.push(normalizeHashtag(link.textContent)); |
||||
} |
||||
return result; |
||||
}, []); |
||||
|
||||
// Now we parse the last line, and try to see if it only contains hashtags
|
||||
const lastLineHashtags: string[] = []; |
||||
// try to see if the last line is only hashtags
|
||||
let onlyHashtags = true; |
||||
|
||||
Array.from(lastChild.childNodes).forEach((node) => { |
||||
if (isNodeLinkHashtag(node) && node.textContent) { |
||||
const normalized = normalizeHashtag(node.textContent); |
||||
|
||||
if (!localeAwareInclude(tagNames, normalized)) { |
||||
// stop here, this is not a real hashtag, so consider it as text
|
||||
onlyHashtags = false; |
||||
return; |
||||
} |
||||
|
||||
if (!localeAwareInclude(contentHashtags, normalized)) |
||||
// only add it if it does not appear in the rest of the content
|
||||
lastLineHashtags.push(normalized); |
||||
} else if (node.nodeType !== Node.TEXT_NODE || node.nodeValue?.trim()) { |
||||
// not a space
|
||||
onlyHashtags = false; |
||||
} |
||||
}); |
||||
|
||||
const hashtagsInBar = tagNames.filter( |
||||
(tag) => |
||||
// the tag does not appear at all in the status content, it is an out-of-band tag
|
||||
!localeAwareInclude(contentHashtags, tag) && |
||||
!localeAwareInclude(lastLineHashtags, tag), |
||||
); |
||||
|
||||
const isOnlyOneLine = contentWithoutLastLine.content.childElementCount === 0; |
||||
const hasMedia = status.get('media_attachments').size > 0; |
||||
const hasSpoiler = !!status.get('spoiler_text'); |
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- due to https://github.com/microsoft/TypeScript/issues/9998
|
||||
if (onlyHashtags && ((hasMedia && !hasSpoiler) || !isOnlyOneLine)) { |
||||
// if the last line only contains hashtags, and we either:
|
||||
// - have other content in the status
|
||||
// - dont have other content, but a media and no CW. If it has a CW, then we do not remove the content to avoid having an empty content behind the CW button
|
||||
statusContent = contentWithoutLastLine.innerHTML; |
||||
// and add the tags to the bar
|
||||
hashtagsInBar.push(...lastLineHashtags); |
||||
} |
||||
|
||||
return { |
||||
statusContentProps: { statusContent }, |
||||
hashtagsInBar: uniqueHashtagsWithCaseHandling(hashtagsInBar), |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* This function will process a status to, at the same time (avoiding parsing it twice): |
||||
* - build the HashtagBar for this status |
||||
* - remove the last-line hashtags from the status content |
||||
* @param status The status to process |
||||
* @returns Props to be passed to the <StatusContent> component, and the hashtagBar to render |
||||
*/ |
||||
export function getHashtagBarForStatus(status: StatusLike) { |
||||
const { statusContentProps, hashtagsInBar } = |
||||
computeHashtagBarForStatus(status); |
||||
|
||||
return { |
||||
statusContentProps, |
||||
hashtagBar: <HashtagBar hashtags={hashtagsInBar} />, |
||||
}; |
||||
} |
||||
|
||||
const HashtagBar: React.FC<{ |
||||
hashtags: string[]; |
||||
}> = ({ hashtags }) => { |
||||
const [expanded, setExpanded] = useState(false); |
||||
const handleClick = useCallback(() => { |
||||
setExpanded(true); |
||||
}, []); |
||||
|
||||
if (hashtags.length === 0) { |
||||
return null; |
||||
} |
||||
|
||||
const revealedHashtags = expanded |
||||
? hashtags |
||||
: hashtags.slice(0, VISIBLE_HASHTAGS - 1); |
||||
|
||||
return ( |
||||
<div className='hashtag-bar'> |
||||
{revealedHashtags.map((hashtag) => ( |
||||
<Link key={hashtag} to={`/tags/${hashtag}`}> |
||||
#{hashtag} |
||||
</Link> |
||||
))} |
||||
|
||||
{!expanded && hashtags.length > VISIBLE_HASHTAGS && ( |
||||
<button className='link-button' onClick={handleClick}> |
||||
<FormattedMessage |
||||
id='hashtags.and_other' |
||||
defaultMessage='…and {count, plural, other {# more}}' |
||||
values={{ count: hashtags.length - VISIBLE_HASHTAGS }} |
||||
/> |
||||
</button> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
Loading…
Reference in new issue