diff --git a/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx b/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx new file mode 100644 index 0000000000..b7225fc92e --- /dev/null +++ b/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx @@ -0,0 +1,214 @@ +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('does not truncate the contents when the last child is a text node', () => { + const status = createStatus( + 'this is a #test. Some more text', + ['test'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"this is a #test. Some more text"`, + ); + }); + + it('extract tags from the last line', () => { + const status = createStatus( + 'Simple text
', + ['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'], + ); + + 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( + '', + ['hashtag', 'test'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `""`, + ); + }); + + it('de-duplicate accentuated characters with case differences', () => { + const status = createStatus( + 'Text
', + ['éaa'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual(['Éaa']); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `"Text
"`, + ); + }); + + it('handles server-side normalized tags with accentuated characters', () => { + const status = createStatus( + 'Text
', + ['eaa'], // The server may normalize the hashtags in the `tags` attribute + ); + + 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'], + ); + + 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'], + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `""`, + ); + }); + + 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'], + 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'], + true, + 'My CW text', + ); + + const { hashtagsInBar, statusContentProps } = + computeHashtagBarForStatus(status); + + expect(hashtagsInBar).toEqual([]); + expect(statusContentProps.statusContent).toMatchInlineSnapshot( + `""`, + ); + }); +}); diff --git a/app/javascript/flavours/glitch/components/hashtag_bar.tsx b/app/javascript/flavours/glitch/components/hashtag_bar.tsx new file mode 100644 index 0000000000..91fa922198 --- /dev/null +++ b/app/javascript/flavours/glitch/components/hashtag_bar.tsx @@ -0,0 +1,234 @@ +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