pl-fe: Move hashtags from last line of the post to a separate component

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-11-06 16:46:04 +01:00
parent 5f4dde2590
commit 42ed1c4141
8 changed files with 211 additions and 101 deletions

View file

@ -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<IReactionsBar> = ({ announcementId, reactions, emoj
return (
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
{items => (
<div className={clsx('flex flex-wrap items-center gap-1', { 'reactions-bar--empty': visibleReactions.length === 0 })}>
<div className='flex flex-wrap items-center gap-1'>
{items.map(({ key, data, style }) => (
<Reaction
key={key}

View file

@ -0,0 +1,62 @@
// Adapted from Mastodon https://github.com/mastodon/mastodon/blob/main/app/javascript/mastodon/components/hashtag_bar.tsx
import React, { useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import HStack from './ui/hstack';
import Text from './ui/text';
// Fit on a single line on desktop
const VISIBLE_HASHTAGS = 3;
interface IHashtagsBar {
hashtags: Array<string>;
}
const HashtagsBar: React.FC<IHashtagsBar> = ({ hashtags }) => {
const [expanded, setExpanded] = useState(false);
const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
setExpanded(true);
}, []);
if (hashtags.length === 0) {
return null;
}
const revealedHashtags = expanded
? hashtags
: hashtags.slice(0, VISIBLE_HASHTAGS);
return (
<HStack space={2} wrap>
{revealedHashtags.map((hashtag) => (
<Link
key={hashtag}
to={`/tags/${hashtag}`}
onClick={(e) => 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'
>
<Text size='xs' weight='semibold' theme='inherit'>
#<span>{hashtag}</span>
</Text>
</Link>
))}
{!expanded && hashtags.length > VISIBLE_HASHTAGS && (
<button onClick={handleClick}>
<Text className='hover:underline' size='xs' weight='semibold' theme='muted'>
<FormattedMessage
id='hashtags.and_other'
defaultMessage='…and {count, plural, other {# more}}'
values={{ count: hashtags.length - VISIBLE_HASHTAGS }}
/>
</Text>
</button>
)}
</HStack>
);
};
export { HashtagsBar as default };

View file

@ -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<CustomEmoji>;
}
const ParsedContent: React.FC<IParsedContent> = 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<typeof domToReact>;
function parseContent(props: IParsedContent, extractHashtags: true): {
hashtags: Array<string>;
content: ReturnType<typeof domToReact>;
};
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<IParsedContent> = React.memo(({ html, mentions, ha
// Quote posting
if (hasQuote) selectors.push('quote-inline');
const hashtags: Array<string> = [];
const options: HTMLReactParserOptions = {
replace(domNode) {
replace(domNode, index) {
if (!(domNode instanceof Element)) {
return;
}
@ -104,6 +147,25 @@ const ParsedContent: React.FC<IParsedContent> = 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<IParsedContent> = 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<IParsedContent> = React.memo((props) => parseContent(props), (prevProps, nextProps) => prevProps.html === nextProps.html);
export { ParsedContent, parseContent };

View file

@ -100,7 +100,7 @@ const QuotedStatus: React.FC<IQuotedStatus> = ({ status, onCancel, compose }) =>
<StatusContent
status={status}
collapsable
quote
isQuote
/>
{status.quote_id && <QuotedStatusIndicator statusId={status.quote_id} />}

View file

@ -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<IStatusContent> = 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<IStatusContent> = 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<IStatusContent> = 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<IStatusContent> = 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<IStatusContent> = React.memo(({
if (expandable && !expanded) return <>{output}</>;
let quote;
if (withMedia && status.quote_id) {
if ((status.quote_visible ?? true) === false) {
quote = (
<div className='quoted-status-tombstone'>
<p><FormattedMessage id='statuses.quote_tombstone' defaultMessage='Post is unavailable.' /></p>
</div>
);
} else {
quote = <QuotedStatus statusId={status.quote_id} />;
}
}
const media = withMedia && ((quote || status.card || status.media_attachments.length > 0)) && (
<Stack space={4}>
{(status.media_attachments.length > 0 || (status.card && !quote)) && (
<div className='relative'>
<SensitiveContentOverlay status={status} />
<StatusMedia status={status} />
</div>
)}
{quote}
</Stack>
);
if (onClick) {
if (status.content) {
output.push(
@ -189,7 +230,7 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
lang={status.language || undefined}
size={textSize}
>
<ParsedContent html={content} mentions={status.mentions} hasQuote={!!status.quote_id} emojis={status.emojis} />
{parsedContent}
</Markup>,
);
}
@ -197,13 +238,25 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
const hasPoll = !!status.poll_id;
if (collapsed) {
output.push(<ReadMoreButton onClick={onClick} key='read-more' quote={quote} poll={hasPoll} />);
output.push(<ReadMoreButton onClick={onClick} key='read-more' quote={isQuote} poll={hasPoll} />);
}
if (status.poll_id) {
output.push(<Poll id={status.poll_id} key='poll' status={status} />);
}
if (translatable) {
output.push(<TranslateButton status={status} />);
}
if (media) {
output.push(media);
}
if (hashtags.length) {
output.push(<HashtagsBar key='hashtags' hashtags={hashtags} />);
}
return <Stack space={4} className={clsx({ 'bg-gray-100 dark:bg-primary-800 rounded-md p-4': hasPoll })}>{output}</Stack>;
} else {
if (status.content) {
@ -217,19 +270,23 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
lang={status.language || undefined}
size={textSize}
>
<ParsedContent html={content} mentions={status.mentions} hasQuote={!!status.quote_id} emojis={status.emojis} />
{parsedContent}
</Markup>,
);
}
if (collapsed) {
output.push(<ReadMoreButton onClick={() => {}} key='read-more' quote={quote} preview={preview} />);
output.push(<ReadMoreButton onClick={() => {}} key='read-more' quote={isQuote} preview={preview} />);
}
if (status.poll_id) {
output.push(<Poll id={status.poll_id} key='poll' status={status} />);
}
if (translatable) {
output.push(<TranslateButton status={status} />);
}
return <>{output}</>;
}
});

View file

@ -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<IStatus> = (props) => {
);
}
let quote;
if (actualStatus.quote_id) {
if ((actualStatus.quote_visible ?? true) === false) {
quote = (
<div className='quoted-status-tombstone'>
<p><FormattedMessage id='statuses.quote_tombstone' defaultMessage='Post is unavailable.' /></p>
</div>
);
} else {
quote = <QuotedStatus statusId={actualStatus.quote_id} />;
}
}
const handlers = muted ? undefined : {
reply: handleHotkeyReply,
favourite: handleHotkeyFavourite,
@ -423,26 +405,8 @@ const Status: React.FC<IStatus> = (props) => {
onClick={handleClick}
collapsable
translatable
withMedia
/>
<TranslateButton status={actualStatus} />
{(quote || actualStatus.card || actualStatus.media_attachments.length > 0) && (
<Stack space={4}>
{(actualStatus.media_attachments.length > 0 || (actualStatus.card && !quote)) && (
<div className='relative'>
<SensitiveContentOverlay status={actualStatus} />
<StatusMedia
status={actualStatus}
muted={muted}
onClick={handleClick}
/>
</div>
)}
{quote}
</Stack>
)}
</Stack>
)}
</Stack>

View file

@ -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<IEventInformation> = ({ params }) => {
<FormattedMessage id='event.description' defaultMessage='Description' />
</Text>
<StatusContent status={status} translatable />
<TranslateButton status={status} />
<StatusContent status={status} translatable withMedia />
</Stack>
)}
<StatusMedia status={status} />
{status.quote_id && (status.quote_visible ?? true) && (
<QuotedStatus statusId={status.quote_id} />
)}
{renderEventLocation()}
{renderEventDate()}

View file

@ -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<IDetailedStatus> = ({
const { account } = actualStatus;
if (!account || typeof account !== 'object') return null;
let quote;
if (actualStatus.quote_id) {
if (actualStatus.quote_visible === false) {
quote = (
<div className='quoted-actualStatus-tombstone'>
<p><FormattedMessage id='status.quote_tombstone' defaultMessage='Post is unavailable.' /></p>
</div>
);
} else {
quote = <QuotedStatus statusId={actualStatus.quote_id} />;
}
}
return (
<div className='border-box'>
<div ref={node} className='detailed-actualStatus' tabIndex={-1}>
@ -123,22 +105,8 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
status={actualStatus}
textSize='lg'
translatable
withMedia
/>
<TranslateButton status={actualStatus} />
{(withMedia && (quote || actualStatus.card || actualStatus.media_attachments.length > 0)) && (
<Stack space={4}>
{(actualStatus.media_attachments.length > 0 || (actualStatus.card && !quote)) && (
<div className='relative'>
<SensitiveContentOverlay status={status} />
<StatusMedia status={actualStatus} />
</div>
)}
{quote}
</Stack>
)}
</Stack>
</Stack>