From a81ed84453091148f4436c62eb8b2c859406769d Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 14 Aug 2023 23:42:30 +0200 Subject: [PATCH 01/10] [Glitch] Add display of out-of-band hashtags in the web interface Port df6e7198984b42cf8d64e66548742e14264236a0 to glitch-soc Co-authored-by: Eugen Rochko Signed-off-by: Claire --- .../glitch/components/hashtag_bar.jsx | 50 +++++++++++++++++++ .../flavours/glitch/components/status.jsx | 5 ++ .../status/components/detailed_status.jsx | 3 ++ .../glitch/styles/components/misc.scss | 23 +++++++++ 4 files changed, 81 insertions(+) create mode 100644 app/javascript/flavours/glitch/components/hashtag_bar.jsx diff --git a/app/javascript/flavours/glitch/components/hashtag_bar.jsx b/app/javascript/flavours/glitch/components/hashtag_bar.jsx new file mode 100644 index 0000000000..6a39005e16 --- /dev/null +++ b/app/javascript/flavours/glitch/components/hashtag_bar.jsx @@ -0,0 +1,50 @@ +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 [].map.call(body.querySelectorAll('[rel=tag]'), node => node.textContent.toLowerCase()); + }, [text]); + + const invisibleHashtags = useMemo(() => ( + hashtags.filter(hashtag => !renderedHashtags.some(textContent => textContent === `#${hashtag.get('name')}` || textContent === hashtag.get('name'))) + ), [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, +}; \ No newline at end of file diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 72ea42d90a..c43e913c66 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -20,6 +20,7 @@ import Bundle from '../features/ui/components/bundle'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { displayMedia } from '../initial_state'; +import { HashtagBar } from './hashtag_bar'; import AttachmentList from './attachment_list'; import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; @@ -739,6 +740,10 @@ class Status extends ImmutablePureComponent { contentMediaIcons.push('tasks'); } + media.push( + + ); + // Here we prepare extra data-* attributes for CSS selectors. // Users can use those for theming, hiding avatars etc via UserStyle const selectorAttribs = { diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx index ec8db1febb..f8134cf5ee 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx @@ -11,6 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { AnimatedNumber } from 'flavours/glitch/components/animated_number'; import AttachmentList from 'flavours/glitch/components/attachment_list'; import EditedTimestamp from 'flavours/glitch/components/edited_timestamp'; +import { HashtagBar } from 'flavours/glitch/components/hashtag_bar'; import { Icon } from 'flavours/glitch/components/icon'; import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon'; @@ -327,6 +328,8 @@ class DetailedStatus extends ImmutablePureComponent { disabled /> + +
diff --git a/app/javascript/flavours/glitch/styles/components/misc.scss b/app/javascript/flavours/glitch/styles/components/misc.scss index 5262096179..34c1cd820a 100644 --- a/app/javascript/flavours/glitch/styles/components/misc.scss +++ b/app/javascript/flavours/glitch/styles/components/misc.scss @@ -1661,3 +1661,26 @@ noscript { opacity: 1; } } + +.hashtag-bar { + margin-top: 16px; + display: flex; + flex-wrap: wrap; + font-size: 14px; + gap: 4px; + + a { + display: inline-flex; + border-radius: 4px; + background: rgba($highlight-text-color, 0.2); + color: $highlight-text-color; + padding: 0.4em 0.6em; + text-decoration: none; + + &:hover, + &:focus, + &:active { + background: rgba($highlight-text-color, 0.3); + } + } +} From fe8d9f62218528fe5384406c825edd562a93ca3f Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 16 Aug 2023 11:47:59 +0200 Subject: [PATCH 02/10] [Glitch] Fix hashtag bar sometimes including tags that appear in the post's body Port f0862bcf984d994b9f0b0bbea82431e5a1812351 Signed-off-by: Claire --- app/javascript/flavours/glitch/components/hashtag_bar.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/flavours/glitch/components/hashtag_bar.jsx b/app/javascript/flavours/glitch/components/hashtag_bar.jsx index 6a39005e16..3ad6362551 100644 --- a/app/javascript/flavours/glitch/components/hashtag_bar.jsx +++ b/app/javascript/flavours/glitch/components/hashtag_bar.jsx @@ -15,11 +15,11 @@ const VISIBLE_HASHTAGS = 7; export const HashtagBar = ({ hashtags, text }) => { const renderedHashtags = useMemo(() => { const body = domParser.parseFromString(text, 'text/html').documentElement; - return [].map.call(body.querySelectorAll('[rel=tag]'), node => node.textContent.toLowerCase()); + return [].filter.call(body.querySelectorAll('a[href]'), link => link.textContent[0] === '#' || (link.previousSibling?.textContent?.[link.previousSibling.textContent.length - 1] === '#')).map(node => node.textContent.toLowerCase()); }, [text]); const invisibleHashtags = useMemo(() => ( - hashtags.filter(hashtag => !renderedHashtags.some(textContent => textContent === `#${hashtag.get('name')}` || textContent === hashtag.get('name'))) + hashtags.filter(hashtag => !renderedHashtags.some(textContent => textContent === `#${hashtag.get('name').toLowerCase()}` || textContent === hashtag.get('name').toLowerCase())) ), [hashtags, renderedHashtags]); const [expanded, setExpanded] = useState(false); From 2ce03420d69839eb7b8d67af45f0550863c26d8e Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 17 Aug 2023 12:49:52 +0200 Subject: [PATCH 03/10] [Glitch] Fix case-insensitive comparison of hashtags to do case-folding Port 3ed2bf92d06a5ebaf3ad92a55d5ae3c0c2640686 to glitch-soc Signed-off-by: Claire --- app/javascript/flavours/glitch/components/hashtag_bar.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/javascript/flavours/glitch/components/hashtag_bar.jsx b/app/javascript/flavours/glitch/components/hashtag_bar.jsx index 3ad6362551..3c7e24228d 100644 --- a/app/javascript/flavours/glitch/components/hashtag_bar.jsx +++ b/app/javascript/flavours/glitch/components/hashtag_bar.jsx @@ -15,11 +15,11 @@ 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.toLowerCase()); + 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 === `#${hashtag.get('name').toLowerCase()}` || textContent === hashtag.get('name').toLowerCase())) + 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); @@ -47,4 +47,4 @@ export const HashtagBar = ({ hashtags, text }) => { HashtagBar.propTypes = { hashtags: ImmutablePropTypes.list, text: PropTypes.string, -}; \ No newline at end of file +}; From 18462ee4b6b7b17fbffdba9756139c844f20689a Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Mon, 21 Aug 2023 19:39:01 +0200 Subject: [PATCH 04/10] [Glitch] Remove hashtags from the last line of a status if it only contains hashtags Port 061fd66ee66afc8a5c7923ff7648f51a6da3fe7c to glitch-soc Signed-off-by: Claire --- .../components/__tests__/hashtag_bar.tsx | 184 +++++++++++++++ .../glitch/components/hashtag_bar.jsx | 50 ---- .../glitch/components/hashtag_bar.tsx | 222 ++++++++++++++++++ .../flavours/glitch/components/status.jsx | 10 +- .../glitch/components/status_content.jsx | 13 +- .../status/components/detailed_status.jsx | 7 +- 6 files changed, 428 insertions(+), 58 deletions(-) create mode 100644 app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx delete mode 100644 app/javascript/flavours/glitch/components/hashtag_bar.jsx create mode 100644 app/javascript/flavours/glitch/components/hashtag_bar.tsx 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..c7db485d08 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/hashtag_bar.jsx b/app/javascript/flavours/glitch/components/hashtag_bar.jsx deleted file mode 100644 index 3c7e24228d..0000000000 --- a/app/javascript/flavours/glitch/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/flavours/glitch/components/hashtag_bar.tsx b/app/javascript/flavours/glitch/components/hashtag_bar.tsx new file mode 100644 index 0000000000..8781c26630 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index c43e913c66..e5693a94df 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -20,8 +20,8 @@ import Bundle from '../features/ui/components/bundle'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { displayMedia } from '../initial_state'; -import { HashtagBar } from './hashtag_bar'; import AttachmentList from './attachment_list'; +import { getHashtagBarForStatus } from './hashtag_bar'; import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; import StatusHeader from './status_header'; @@ -740,10 +740,6 @@ class Status extends ImmutablePureComponent { contentMediaIcons.push('tasks'); } - media.push( - - ); - // Here we prepare extra data-* attributes for CSS selectors. // Users can use those for theming, hiding avatars etc via UserStyle const selectorAttribs = { @@ -784,6 +780,9 @@ class Status extends ImmutablePureComponent { muted, }, 'focusable'); + const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); + media.push(hashtagBar); + return (
{!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx index 03b2799168..67e5d53d83 100644 --- a/app/javascript/flavours/glitch/components/status_content.jsx +++ b/app/javascript/flavours/glitch/components/status_content.jsx @@ -69,6 +69,15 @@ const isLinkMisleading = (link) => { return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host)); }; +/** + * + * @param {any} status + * @returns {string} + */ +export function getStatusContent(status) { + return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); +} + class TranslateButton extends PureComponent { static propTypes = { @@ -118,6 +127,7 @@ class StatusContent extends PureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, + statusContent: PropTypes.string, expanded: PropTypes.bool, collapsed: PropTypes.bool, onExpandedToggle: PropTypes.func, @@ -327,6 +337,7 @@ class StatusContent extends PureComponent { tagLinks, rewriteMentions, intl, + statusContent, } = this.props; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; @@ -334,7 +345,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/flavours/glitch/features/status/components/detailed_status.jsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx index f8134cf5ee..99e6e63e04 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx @@ -11,7 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { AnimatedNumber } from 'flavours/glitch/components/animated_number'; import AttachmentList from 'flavours/glitch/components/attachment_list'; import EditedTimestamp from 'flavours/glitch/components/edited_timestamp'; -import { HashtagBar } from 'flavours/glitch/components/hashtag_bar'; +import { getHashtagBarForStatus } from 'flavours/glitch/components/hashtag_bar'; import { Icon } from 'flavours/glitch/components/icon'; import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon'; @@ -304,6 +304,8 @@ class DetailedStatus extends ImmutablePureComponent { ); } + const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); + return (
@@ -326,9 +328,10 @@ class DetailedStatus extends ImmutablePureComponent { tagLinks={settings.get('tag_misleading_links')} rewriteMentions={settings.get('rewrite_mentions')} disabled + {...statusContentProps} /> - + {hashtagBar}
From fc514fa8c6da3ff14a0045336c0799ebfb8aa095 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Wed, 23 Aug 2023 08:18:07 +0200 Subject: [PATCH 05/10] [Glitch] Better hashtag normalization when processing a post Port 58acaa9ae666d4e40c5b68316513a0fcb9200daf to glitch-soc Signed-off-by: Claire --- .../components/__tests__/hashtag_bar.tsx | 15 ++++++++ .../glitch/components/hashtag_bar.tsx | 34 +++++++++++++------ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx b/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx index c7db485d08..1856b7109e 100644 --- a/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx +++ b/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx @@ -105,6 +105,21 @@ describe('computeHashtagBarForStatus', () => { ); }); + it('handles server-side normalized tags with accentuated characters', () => { + const status = createStatus( + '

Text

#éaa #Éaa

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

', diff --git a/app/javascript/flavours/glitch/components/hashtag_bar.tsx b/app/javascript/flavours/glitch/components/hashtag_bar.tsx index 8781c26630..75bd74da01 100644 --- a/app/javascript/flavours/glitch/components/hashtag_bar.tsx +++ b/app/javascript/flavours/glitch/components/hashtag_bar.tsx @@ -23,8 +23,9 @@ export type StatusLike = Record<{ }>; function normalizeHashtag(hashtag: string) { - if (hashtag && hashtag.startsWith('#')) return hashtag.slice(1); - else return hashtag; + return ( + hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag + ).normalize('NFKC'); } function isNodeLinkHashtag(element: Node): element is HTMLLinkElement { @@ -70,9 +71,16 @@ function uniqueHashtagsWithCaseHandling(hashtags: string[]) { } // Create the collator once, this is much more efficient -const collator = new Intl.Collator(undefined, { sensitivity: 'accent' }); +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) { - return collection.find((item) => collator.compare(item, value) === 0); + 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 @@ -121,11 +129,13 @@ export function computeHashtagBarForStatus(status: StatusLike): { // 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(tagNames, normalized)) { + if (!localeAwareInclude(normalizedTagNames, normalized)) { // stop here, this is not a real hashtag, so consider it as text onlyHashtags = false; return; @@ -140,12 +150,14 @@ export function computeHashtagBarForStatus(status: StatusLike): { } }); - 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 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; From 45690b01d109ff1d73fababb03e44de28c75c967 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 23 Aug 2023 15:44:52 +0200 Subject: [PATCH 06/10] [Glitch] Change hashtag bar tags to be de-emphasized Port 613cfd625c8be11b4fb91d769ddbeee7a535a57a to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/components/hashtag_bar.tsx | 2 +- .../flavours/glitch/styles/components/misc.scss | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/javascript/flavours/glitch/components/hashtag_bar.tsx b/app/javascript/flavours/glitch/components/hashtag_bar.tsx index 75bd74da01..674c481b81 100644 --- a/app/javascript/flavours/glitch/components/hashtag_bar.tsx +++ b/app/javascript/flavours/glitch/components/hashtag_bar.tsx @@ -216,7 +216,7 @@ const HashtagBar: React.FC<{
{revealedHashtags.map((hashtag) => ( - #{hashtag} + #{hashtag} ))} diff --git a/app/javascript/flavours/glitch/styles/components/misc.scss b/app/javascript/flavours/glitch/styles/components/misc.scss index 34c1cd820a..5a6ac40ab9 100644 --- a/app/javascript/flavours/glitch/styles/components/misc.scss +++ b/app/javascript/flavours/glitch/styles/components/misc.scss @@ -1671,16 +1671,15 @@ noscript { a { display: inline-flex; - border-radius: 4px; - background: rgba($highlight-text-color, 0.2); - color: $highlight-text-color; - padding: 0.4em 0.6em; + color: $dark-text-color; text-decoration: none; - &:hover, - &:focus, - &:active { - background: rgba($highlight-text-color, 0.3); + &:hover { + text-decoration: none; + + span { + text-decoration: underline; + } } } } From 7ae45676c8244e97ea39773e99446eb4833f5d5a Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 25 Aug 2023 21:46:43 +0200 Subject: [PATCH 07/10] Fix hashtag bar styling --- .../glitch/styles/components/misc.scss | 22 ------------------- .../glitch/styles/components/status.scss | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/app/javascript/flavours/glitch/styles/components/misc.scss b/app/javascript/flavours/glitch/styles/components/misc.scss index 5a6ac40ab9..5262096179 100644 --- a/app/javascript/flavours/glitch/styles/components/misc.scss +++ b/app/javascript/flavours/glitch/styles/components/misc.scss @@ -1661,25 +1661,3 @@ noscript { opacity: 1; } } - -.hashtag-bar { - margin-top: 16px; - display: flex; - flex-wrap: wrap; - font-size: 14px; - gap: 4px; - - a { - display: inline-flex; - color: $dark-text-color; - text-decoration: none; - - &:hover { - text-decoration: none; - - span { - text-decoration: underline; - } - } - } -} diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index e25636780e..682782948b 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -1168,3 +1168,25 @@ a.status-card.compact:hover { border-color: lighten($ui-base-color, 12%); } } + +.hashtag-bar { + margin-top: 16px; + display: flex; + flex-wrap: wrap; + font-size: 14px; + gap: 4px; + + a { + display: inline-flex; + color: $dark-text-color; + text-decoration: none; + + &:hover { + text-decoration: none; + + span { + text-decoration: underline; + } + } + } +} From c226d00490749cb9ef00fde2904c75b9f1d19b15 Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 25 Aug 2023 21:54:15 +0200 Subject: [PATCH 08/10] Fix interaction between CWs and hashtag bars --- app/javascript/flavours/glitch/components/status.jsx | 2 +- .../glitch/features/status/components/detailed_status.jsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index e5693a94df..f71fd40f77 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -781,7 +781,7 @@ class Status extends ImmutablePureComponent { }, 'focusable'); const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); - media.push(hashtagBar); + contentMedia.push(hashtagBar); return ( diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx index 99e6e63e04..bf41aa931d 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.jsx @@ -305,6 +305,7 @@ class DetailedStatus extends ImmutablePureComponent { } const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status); + contentMedia.push(hashtagBar); return (
@@ -331,8 +332,6 @@ class DetailedStatus extends ImmutablePureComponent { {...statusContentProps} /> - {hashtagBar} -
From 6a8623588a4d49eeaf05e6e685c34b50b575950c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 28 Aug 2023 19:49:38 +0200 Subject: [PATCH 09/10] [Glitch] Fix colors and typography on hashtag bar in web UI Port 10b06436d13702aedaa92a8ece84fbfde2829d57 to glitch-soc Signed-off-by: Claire --- .../glitch/components/hashtag_bar.tsx | 6 +++--- .../glitch/styles/components/status.scss | 19 ++++++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/app/javascript/flavours/glitch/components/hashtag_bar.tsx b/app/javascript/flavours/glitch/components/hashtag_bar.tsx index 674c481b81..d45a6e20eb 100644 --- a/app/javascript/flavours/glitch/components/hashtag_bar.tsx +++ b/app/javascript/flavours/glitch/components/hashtag_bar.tsx @@ -10,8 +10,8 @@ import { groupBy, minBy } from 'lodash'; import { getStatusContent } from './status_content'; -// About two lines on desktop -const VISIBLE_HASHTAGS = 7; +// 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 }>; @@ -210,7 +210,7 @@ const HashtagBar: React.FC<{ const revealedHashtags = expanded ? hashtags - : hashtags.slice(0, VISIBLE_HASHTAGS - 1); + : hashtags.slice(0, VISIBLE_HASHTAGS); return (
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 682782948b..0b720637de 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -1174,19 +1174,24 @@ a.status-card.compact:hover { display: flex; flex-wrap: wrap; font-size: 14px; + line-height: 18px; gap: 4px; + color: $darker-text-color; a { display: inline-flex; - color: $dark-text-color; + color: inherit; text-decoration: none; - &:hover { - text-decoration: none; - - span { - text-decoration: underline; - } + &:hover span { + text-decoration: underline; } } + + .link-button { + color: inherit; + font-size: inherit; + line-height: inherit; + padding: 0; + } } From cd3a636b7fe7fa8272c3f15a5e4a028e59be081b Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 6 Oct 2023 12:58:02 +0200 Subject: [PATCH 10/10] [Glitch] Fix some remote posts getting truncated Port 4d59dfb1c619ebec44243acc7f21dc3edb9feb3b to glitch-soc Signed-off-by: Claire --- .../glitch/components/__tests__/hashtag_bar.tsx | 15 +++++++++++++++ .../flavours/glitch/components/hashtag_bar.tsx | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx b/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx index 1856b7109e..b7225fc92e 100644 --- a/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx +++ b/app/javascript/flavours/glitch/components/__tests__/hashtag_bar.tsx @@ -45,6 +45,21 @@ describe('computeHashtagBarForStatus', () => { ); }); + 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

', diff --git a/app/javascript/flavours/glitch/components/hashtag_bar.tsx b/app/javascript/flavours/glitch/components/hashtag_bar.tsx index d45a6e20eb..91fa922198 100644 --- a/app/javascript/flavours/glitch/components/hashtag_bar.tsx +++ b/app/javascript/flavours/glitch/components/hashtag_bar.tsx @@ -109,7 +109,7 @@ export function computeHashtagBarForStatus(status: StatusLike): { const lastChild = template.content.lastChild; - if (!lastChild) return defaultResult; + if (!lastChild || lastChild.nodeType === Node.TEXT_NODE) return defaultResult; template.content.removeChild(lastChild); const contentWithoutLastLine = template;