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'; // Fit on a single line on desktop const VISIBLE_HASHTAGS = 3; // 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) { return ( hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag ).normalize('NFKC'); } 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: 'base', // we use this to emulate the ASCII folding done on the server-side, hopefuly more efficiently }); function localeAwareInclude(collection: string[], value: string) { const normalizedValue = value.normalize('NFKC'); return !!collection.find( (item) => collator.compare(item.normalize('NFKC'), normalizedValue) === 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 || lastChild.nodeType === Node.TEXT_NODE) 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; const normalizedTagNames = tagNames.map((tag) => tag.normalize('NFKC')); Array.from(lastChild.childNodes).forEach((node) => { if (isNodeLinkHashtag(node) && node.textContent) { const normalized = normalizeHashtag(node.textContent); if (!localeAwareInclude(normalizedTagNames, 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) => { const normalizedTag = tag.normalize('NFKC'); // the tag does not appear at all in the status content, it is an out-of-band tag return ( !localeAwareInclude(contentHashtags, normalizedTag) && !localeAwareInclude(lastLineHashtags, normalizedTag) ); }); 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); return (
{revealedHashtags.map((hashtag) => ( #{hashtag} ))} {!expanded && hashtags.length > VISIBLE_HASHTAGS && ( )}
); };