diff --git a/packages/pl-fe/src/components/announcements/reactions-bar.tsx b/packages/pl-fe/src/components/announcements/reactions-bar.tsx index c40d69351..d0db5971b 100644 --- a/packages/pl-fe/src/components/announcements/reactions-bar.tsx +++ b/packages/pl-fe/src/components/announcements/reactions-bar.tsx @@ -1,4 +1,3 @@ -import clsx from 'clsx'; import React from 'react'; import { TransitionMotion, spring } from 'react-motion'; @@ -40,7 +39,7 @@ const ReactionsBar: React.FC = ({ announcementId, reactions, emoj return ( {items => ( -
+
{items.map(({ key, data, style }) => ( ; +} + +const HashtagsBar: React.FC = ({ hashtags }) => { + const [expanded, setExpanded] = useState(false); + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + + setExpanded(true); + }, []); + + if (hashtags.length === 0) { + return null; + } + + const revealedHashtags = expanded + ? hashtags + : hashtags.slice(0, VISIBLE_HASHTAGS); + + return ( + + {revealedHashtags.map((hashtag) => ( + e.stopPropagation()} + className='flex items-center rounded-sm bg-gray-100 px-1.5 py-1 text-center text-xs font-medium text-primary-600 black:bg-primary-900 dark:bg-primary-700 dark:text-white' + > + + #{hashtag} + + + ))} + + {!expanded && hashtags.length > VISIBLE_HASHTAGS && ( + + )} + + ); +}; + +export { HashtagsBar as default }; diff --git a/packages/pl-fe/src/components/parsed-content.tsx b/packages/pl-fe/src/components/parsed-content.tsx index a481f931f..668c48e49 100644 --- a/packages/pl-fe/src/components/parsed-content.tsx +++ b/packages/pl-fe/src/components/parsed-content.tsx @@ -1,5 +1,8 @@ +/* eslint-disable no-redeclare */ import parse, { Element, type HTMLReactParserOptions, domToReact, type DOMNode } from 'html-react-parser'; import DOMPurify from 'isomorphic-dompurify'; +import groupBy from 'lodash/groupBy'; +import minBy from 'lodash/minBy'; import React from 'react'; import { Link } from 'react-router-dom'; @@ -26,9 +29,47 @@ interface IParsedContent { emojis?: Array; } -const ParsedContent: React.FC = React.memo(({ html, mentions, hasQuote, emojis }) => { +// Adapted from Mastodon https://github.com/mastodon/mastodon/blob/main/app/javascript/mastodon/components/hashtag_bar.tsx +const normalizeHashtag = (hashtag: string) =>( + !!hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag +).normalize('NFKC'); + +const uniqueHashtagsWithCaseHandling = (hashtags: string[]) => { + const groups = groupBy(hashtags, (tag) => + tag.normalize('NFKD').toLowerCase(), + ); + + return Object.values(groups).map((tags) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know that the array has at least one element + const firstTag = tags[0]!; + + if (tags.length === 1) return firstTag; + + // 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 ?? firstTag; + }); +}; + +function parseContent(props: IParsedContent): ReturnType; +function parseContent(props: IParsedContent, extractHashtags: true): { + hashtags: Array; + content: ReturnType; +}; + +function parseContent({ html, mentions, hasQuote, emojis }: IParsedContent, extractHashtags = false) { if (html.length === 0) { - return null; + return extractHashtags ? { content: null, hashtags: [] } : null; } const emojiMap = emojis ? makeEmojiMap(emojis) : undefined; @@ -41,8 +82,10 @@ const ParsedContent: React.FC = React.memo(({ html, mentions, ha // Quote posting if (hasQuote) selectors.push('quote-inline'); + const hashtags: Array = []; + const options: HTMLReactParserOptions = { - replace(domNode) { + replace(domNode, index) { if (!(domNode instanceof Element)) { return; } @@ -104,6 +147,25 @@ const ParsedContent: React.FC = React.memo(({ html, mentions, ha return fallback; } + + if (extractHashtags && domNode.type === 'tag' && domNode.parent === null && domNode.next === null) { + for (const child of domNode.children) { + switch (child.type) { + case 'text': + if (child.data.trim().length) return; + break; + case 'tag': + if (child.name !== 'a') return; + if (!child.attribs.class?.split(' ').includes('hashtag')) return; + hashtags.push(normalizeHashtag(nodesToText([child]))); + break; + default: + return; + } + } + + return <>; + } }, transform(reactNode, _domNode, index) { @@ -115,7 +177,16 @@ const ParsedContent: React.FC = React.memo(({ html, mentions, ha }, }; - return parse(DOMPurify.sanitize(html, { ADD_ATTR: ['target'], USE_PROFILES: { html: true } }), options); -}, (prevProps, nextProps) => prevProps.html === nextProps.html); + const content = parse(DOMPurify.sanitize(html, { ADD_ATTR: ['target'], USE_PROFILES: { html: true } }), options); -export { ParsedContent }; + if (extractHashtags) return { + content, + hashtags: uniqueHashtagsWithCaseHandling(hashtags), + }; + + return content; +} + +const ParsedContent: React.FC = React.memo((props) => parseContent(props), (prevProps, nextProps) => prevProps.html === nextProps.html); + +export { ParsedContent, parseContent }; diff --git a/packages/pl-fe/src/components/quoted-status.tsx b/packages/pl-fe/src/components/quoted-status.tsx index b1f8e388b..fdab7971f 100644 --- a/packages/pl-fe/src/components/quoted-status.tsx +++ b/packages/pl-fe/src/components/quoted-status.tsx @@ -100,7 +100,7 @@ const QuotedStatus: React.FC = ({ status, onCancel, compose }) => {status.quote_id && } diff --git a/packages/pl-fe/src/components/status-content.tsx b/packages/pl-fe/src/components/status-content.tsx index 8d8f22684..3241ff01d 100644 --- a/packages/pl-fe/src/components/status-content.tsx +++ b/packages/pl-fe/src/components/status-content.tsx @@ -8,15 +8,20 @@ import Button from 'pl-fe/components/ui/button'; import Stack from 'pl-fe/components/ui/stack'; import Text from 'pl-fe/components/ui/text'; import Emojify from 'pl-fe/features/emoji/emojify'; +import QuotedStatus from 'pl-fe/features/status/containers/quoted-status-container'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useSettings } from 'pl-fe/hooks/use-settings'; import { onlyEmoji as isOnlyEmoji } from 'pl-fe/utils/rich-content'; import { getTextDirection } from '../utils/rtl'; +import HashtagsBar from './hashtags-bar'; import Markup from './markup'; -import { ParsedContent } from './parsed-content'; +import { parseContent } from './parsed-content'; import Poll from './polls/poll'; +import StatusMedia from './status-media'; +import SensitiveContentOverlay from './statuses/sensitive-content-overlay'; +import TranslateButton from './translate-button'; import type { Sizes } from 'pl-fe/components/ui/text'; import type { MinifiedStatus } from 'pl-fe/reducers/statuses'; @@ -60,8 +65,9 @@ interface IStatusContent { collapsable?: boolean; translatable?: boolean; textSize?: Sizes; - quote?: boolean; + isQuote?: boolean; preview?: boolean; + withMedia?: boolean; } /** Renders the text content of a status */ @@ -71,8 +77,9 @@ const StatusContent: React.FC = React.memo(({ collapsable = false, translatable, textSize = 'md', - quote = false, + isQuote = false, preview, + withMedia, }) => { const dispatch = useAppDispatch(); const { displaySpoilers } = useSettings(); @@ -89,7 +96,7 @@ const StatusContent: React.FC = React.memo(({ if ((collapsable || preview) && !collapsed) { // 20px * x lines (+ 2px padding at the top) - if (node.current.clientHeight > (preview ? 82 : quote ? 202 : 282)) { + if (node.current.clientHeight > (preview ? 82 : isQuote ? 202 : 282)) { setCollapsed(true); } } @@ -126,6 +133,13 @@ const StatusContent: React.FC = React.memo(({ [status.content, status.translation, status.currentLanguage], ); + const { content: parsedContent, hashtags } = useMemo(() => parseContent({ + html: content, + mentions: status.mentions, + hasQuote: !!status.quote_id, + emojis: status.emojis, + }, true), [content]); + useEffect(() => { setLineClamp(!spoilerNode.current || spoilerNode.current.clientHeight >= 96); }, [spoilerNode.current]); @@ -140,8 +154,8 @@ const StatusContent: React.FC = React.memo(({ const className = clsx('relative text-ellipsis break-words text-gray-900 focus:outline-none dark:text-gray-100', { 'cursor-pointer': onClick, 'overflow-hidden': collapsed, - 'max-h-[200px]': collapsed && !quote && !preview, - 'max-h-[120px]': collapsed && quote, + 'max-h-[200px]': collapsed && !isQuote && !preview, + 'max-h-[120px]': collapsed && isQuote, 'max-h-[80px]': collapsed && preview, 'leading-normal big-emoji': onlyEmoji, }); @@ -177,6 +191,33 @@ const StatusContent: React.FC = React.memo(({ if (expandable && !expanded) return <>{output}; + let quote; + + if (withMedia && status.quote_id) { + if ((status.quote_visible ?? true) === false) { + quote = ( +
+

+
+ ); + } else { + quote = ; + } + } + + const media = withMedia && ((quote || status.card || status.media_attachments.length > 0)) && ( + + {(status.media_attachments.length > 0 || (status.card && !quote)) && ( +
+ + +
+ )} + + {quote} +
+ ); + if (onClick) { if (status.content) { output.push( @@ -189,7 +230,7 @@ const StatusContent: React.FC = React.memo(({ lang={status.language || undefined} size={textSize} > - + {parsedContent} , ); } @@ -197,13 +238,25 @@ const StatusContent: React.FC = React.memo(({ const hasPoll = !!status.poll_id; if (collapsed) { - output.push(); + output.push(); } if (status.poll_id) { output.push(); } + if (translatable) { + output.push(); + } + + if (media) { + output.push(media); + } + + if (hashtags.length) { + output.push(); + } + return {output}; } else { if (status.content) { @@ -217,19 +270,23 @@ const StatusContent: React.FC = React.memo(({ lang={status.language || undefined} size={textSize} > - + {parsedContent} , ); } if (collapsed) { - output.push( {}} key='read-more' quote={quote} preview={preview} />); + output.push( {}} key='read-more' quote={isQuote} preview={preview} />); } if (status.poll_id) { output.push(); } + if (translatable) { + output.push(); + } + return <>{output}; } }); diff --git a/packages/pl-fe/src/components/status.tsx b/packages/pl-fe/src/components/status.tsx index 33ed00068..f5e819594 100644 --- a/packages/pl-fe/src/components/status.tsx +++ b/packages/pl-fe/src/components/status.tsx @@ -6,7 +6,6 @@ import { Link, useHistory } from 'react-router-dom'; import { mentionCompose, replyCompose } from 'pl-fe/actions/compose'; import { toggleFavourite, toggleReblog } from 'pl-fe/actions/interactions'; import { toggleStatusMediaHidden, unfilterStatus } from 'pl-fe/actions/statuses'; -import TranslateButton from 'pl-fe/components/translate-button'; import Card from 'pl-fe/components/ui/card'; import Icon from 'pl-fe/components/ui/icon'; import Stack from 'pl-fe/components/ui/stack'; @@ -14,7 +13,6 @@ import Text from 'pl-fe/components/ui/text'; import AccountContainer from 'pl-fe/containers/account-container'; import Emojify from 'pl-fe/features/emoji/emojify'; import StatusTypeIcon from 'pl-fe/features/status/components/status-type-icon'; -import QuotedStatus from 'pl-fe/features/status/containers/quoted-status-container'; import { HotKeys } from 'pl-fe/features/ui/components/hotkeys'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; @@ -27,10 +25,8 @@ import EventPreview from './event-preview'; import StatusActionBar from './status-action-bar'; import StatusContent from './status-content'; import StatusLanguagePicker from './status-language-picker'; -import StatusMedia from './status-media'; import StatusReactionsBar from './status-reactions-bar'; import StatusReplyMentions from './status-reply-mentions'; -import SensitiveContentOverlay from './statuses/sensitive-content-overlay'; import StatusInfo from './statuses/status-info'; const messages = defineMessages({ @@ -341,20 +337,6 @@ const Status: React.FC = (props) => { ); } - let quote; - - if (actualStatus.quote_id) { - if ((actualStatus.quote_visible ?? true) === false) { - quote = ( -
-

-
- ); - } else { - quote = ; - } - } - const handlers = muted ? undefined : { reply: handleHotkeyReply, favourite: handleHotkeyFavourite, @@ -423,26 +405,8 @@ const Status: React.FC = (props) => { onClick={handleClick} collapsable translatable + withMedia /> - - - - {(quote || actualStatus.card || actualStatus.media_attachments.length > 0) && ( - - {(actualStatus.media_attachments.length > 0 || (actualStatus.card && !quote)) && ( -
- - -
- )} - - {quote} -
- )} )} diff --git a/packages/pl-fe/src/features/event/event-information.tsx b/packages/pl-fe/src/features/event/event-information.tsx index 021aab416..41bf8d4c2 100644 --- a/packages/pl-fe/src/features/event/event-information.tsx +++ b/packages/pl-fe/src/features/event/event-information.tsx @@ -4,13 +4,10 @@ import { FormattedDate, FormattedMessage, useIntl } from 'react-intl'; import { fetchStatus } from 'pl-fe/actions/statuses'; import MissingIndicator from 'pl-fe/components/missing-indicator'; import StatusContent from 'pl-fe/components/status-content'; -import StatusMedia from 'pl-fe/components/status-media'; -import TranslateButton from 'pl-fe/components/translate-button'; import HStack from 'pl-fe/components/ui/hstack'; import Icon from 'pl-fe/components/ui/icon'; import Stack from 'pl-fe/components/ui/stack'; import Text from 'pl-fe/components/ui/text'; -import QuotedStatus from 'pl-fe/features/status/containers/quoted-status-container'; import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch'; import { useAppSelector } from 'pl-fe/hooks/use-app-selector'; import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config'; @@ -185,18 +182,10 @@ const EventInformation: React.FC = ({ params }) => { - - - + )} - - - {status.quote_id && (status.quote_visible ?? true) && ( - - )} - {renderEventLocation()} {renderEventDate()} diff --git a/packages/pl-fe/src/features/status/components/detailed-status.tsx b/packages/pl-fe/src/features/status/components/detailed-status.tsx index 826e6ea48..5c9b3bb0c 100644 --- a/packages/pl-fe/src/features/status/components/detailed-status.tsx +++ b/packages/pl-fe/src/features/status/components/detailed-status.tsx @@ -5,18 +5,14 @@ import { Link } from 'react-router-dom'; import Account from 'pl-fe/components/account'; import StatusContent from 'pl-fe/components/status-content'; import StatusLanguagePicker from 'pl-fe/components/status-language-picker'; -import StatusMedia from 'pl-fe/components/status-media'; import StatusReactionsBar from 'pl-fe/components/status-reactions-bar'; import StatusReplyMentions from 'pl-fe/components/status-reply-mentions'; -import SensitiveContentOverlay from 'pl-fe/components/statuses/sensitive-content-overlay'; import StatusInfo from 'pl-fe/components/statuses/status-info'; -import TranslateButton from 'pl-fe/components/translate-button'; import HStack from 'pl-fe/components/ui/hstack'; import Icon from 'pl-fe/components/ui/icon'; import Stack from 'pl-fe/components/ui/stack'; import Text from 'pl-fe/components/ui/text'; import Emojify from 'pl-fe/features/emoji/emojify'; -import QuotedStatus from 'pl-fe/features/status/containers/quoted-status-container'; import StatusInteractionBar from './status-interaction-bar'; import StatusTypeIcon from './status-type-icon'; @@ -86,20 +82,6 @@ const DetailedStatus: React.FC = ({ const { account } = actualStatus; if (!account || typeof account !== 'object') return null; - let quote; - - if (actualStatus.quote_id) { - if (actualStatus.quote_visible === false) { - quote = ( -
-

-
- ); - } else { - quote = ; - } - } - return (
@@ -123,22 +105,8 @@ const DetailedStatus: React.FC = ({ status={actualStatus} textSize='lg' translatable + withMedia /> - - - - {(withMedia && (quote || actualStatus.card || actualStatus.media_attachments.length > 0)) && ( - - {(actualStatus.media_attachments.length > 0 || (actualStatus.card && !quote)) && ( -
- - -
- )} - - {quote} -
- )}