diff --git a/app/javascript/mastodon/components/__tests__/hashtag_bar.tsx b/app/javascript/mastodon/components/__tests__/hashtag_bar.tsx new file mode 100644 index 000000000..c7db485d0 --- /dev/null +++ b/app/javascript/mastodon/components/__tests__/hashtag_bar.tsx @@ -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('

Simple text

', []); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Simple text

"`, + ); + }); + + it('displays out of band hashtags in the bar', () => { + const status = createStatus( + '

Simple text #hashtag

', + ['hashtag', 'test'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['test']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Simple text #hashtag

"`, + ); + }); + + it('extract tags from the last line', () => { + const status = createStatus( + '

Simple text

#hashtag

', + ['hashtag'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['hashtag']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Simple text

"`, + ); + }); + + it('does not include tags from content', () => { + const status = createStatus( + '

Simple text with a #hashtag

#hashtag

', + ['hashtag'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Simple text with a #hashtag

"`, + ); + }); + + it('works with one line status and hashtags', () => { + const status = createStatus( + '

#test. And another #hashtag

', + ['hashtag', 'test'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

#test. And another #hashtag

"`, + ); + }); + + it('de-duplicate accentuated characters with case differences', () => { + const status = createStatus( + '

Text

#éaa #Éaa

', + ['éaa'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['Éaa']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Text

"`, + ); + }); + + it('does not display in bar a hashtag in content with a case difference', () => { + const status = createStatus( + '

Text #Éaa

#éaa

', + ['éaa'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

Text #Éaa

"`, + ); + }); + + it('does not modify a status with a line of hashtags only', () => { + const status = createStatus( + '

#test #hashtag

', + ['test', 'hashtag'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

#test #hashtag

"`, + ); + }); + + it('puts the hashtags in the bar if a status content has hashtags in the only line and has a media', () => { + const status = createStatus( + '

This is my content! #hashtag

', + ['hashtag'], + true, + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

This is my content! #hashtag

"`, + ); + }); + + it('puts the hashtags in the bar if a status content is only hashtags and has a media', () => { + const status = createStatus( + '

#test #hashtag

', + ['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( + '

#test #hashtag

', + ['test', 'hashtag'], + true, + 'My CW text', + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"

#test #hashtag

"`, + ); + }); +}); diff --git a/app/javascript/mastodon/components/hashtag_bar.jsx b/app/javascript/mastodon/components/hashtag_bar.jsx deleted file mode 100644 index 3c7e24228..000000000 --- a/app/javascript/mastodon/components/hashtag_bar.jsx +++ /dev/null @@ -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 ( -
- {revealedHashtags.map(hashtag => ( - - #{hashtag.get('name')} - - ))} - - {!expanded && invisibleHashtags.size > VISIBLE_HASHTAGS && } -
- ); -}; - -HashtagBar.propTypes = { - hashtags: ImmutablePropTypes.list, - text: PropTypes.string, -}; diff --git a/app/javascript/mastodon/components/hashtag_bar.tsx b/app/javascript/mastodon/components/hashtag_bar.tsx new file mode 100644 index 000000000..8781c2663 --- /dev/null +++ b/app/javascript/mastodon/components/hashtag_bar.tsx @@ -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; + contentHTML: string; + media_attachments: List; + 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 starting with a hashtag + (element.textContent?.[0] === '#' || + // or 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('a[href]'), + ).reduce((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 component, and the hashtagBar to render + */ +export function getHashtagBarForStatus(status: StatusLike) { + const { statusContentProps, hashtagsInBar } = + computeHashtagBarForStatus(status); + + return { + statusContentProps, + hashtagBar: , + }; +} + +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 ( +
+ {revealedHashtags.map((hashtag) => ( + + #{hashtag} + + ))} + + {!expanded && hashtags.length > VISIBLE_HASHTAGS && ( + + )} +
+ ); +}; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 7c34684d7..45a2106db 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -22,7 +22,7 @@ import { displayMedia } from '../initial_state'; import { Avatar } from './avatar'; import { AvatarOverlay } from './avatar_overlay'; import { DisplayName } from './display_name'; -import { HashtagBar } from './hashtag_bar'; +import { getHashtagBarForStatus } from './hashtag_bar'; import { RelativeTimestamp } from './relative_timestamp'; import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; @@ -545,6 +545,8 @@ class Status extends ImmutablePureComponent { const visibilityIcon = visibilityIconInfo[status.get('visibility')]; + const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); + return (
@@ -577,11 +579,12 @@ class Status extends ImmutablePureComponent { onTranslate={this.handleTranslate} collapsible onCollapsedToggle={this.handleCollapsedToggle} + {...statusContentProps} /> {media} - + {hashtagBar}
diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 84a698810..d3bbc1ba0 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -15,6 +15,15 @@ import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_s const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) +/** + * + * @param {any} status + * @returns {string} + */ +export function getStatusContent(status) { + return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); +} + class TranslateButton extends PureComponent { static propTypes = { @@ -65,6 +74,7 @@ class StatusContent extends PureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, + statusContent: PropTypes.string, expanded: PropTypes.bool, onExpandedToggle: PropTypes.func, onTranslate: PropTypes.func, @@ -225,7 +235,7 @@ class StatusContent extends PureComponent { }; render () { - const { status, intl } = this.props; + const { status, intl, statusContent } = this.props; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; const renderReadMore = this.props.onClick && status.get('collapsed'); @@ -233,7 +243,7 @@ class StatusContent extends PureComponent { const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale); - const content = { __html: status.getIn(['translation', 'contentHtml']) || status.get('contentHtml') }; + const content = { __html: statusContent ?? getStatusContent(status) }; const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') }; const language = status.getIn(['translation', 'language']) || status.get('language'); const classNames = classnames('status__content', { diff --git a/app/javascript/mastodon/features/status/components/detailed_status.jsx b/app/javascript/mastodon/features/status/components/detailed_status.jsx index c1815b916..401550e49 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.jsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.jsx @@ -10,7 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { AnimatedNumber } from 'mastodon/components/animated_number'; import EditedTimestamp from 'mastodon/components/edited_timestamp'; -import { HashtagBar } from 'mastodon/components/hashtag_bar'; +import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar'; import { Icon } from 'mastodon/components/icon'; import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; @@ -292,6 +292,8 @@ class DetailedStatus extends ImmutablePureComponent { ); } + const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); + return (
@@ -311,11 +313,12 @@ class DetailedStatus extends ImmutablePureComponent { expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} onTranslate={this.handleTranslate} + {...statusContentProps} /> {media} - + {hashtagBar}