Skip to content

Commit

Permalink
Merge upstream hashtag changes
Browse files Browse the repository at this point in the history
Referenced glitch-soc#2391
  • Loading branch information
Taullo committed Sep 22, 2023
1 parent 92d8a0d commit 1f7627e
Show file tree
Hide file tree
Showing 10 changed files with 566 additions and 2 deletions.
234 changes: 234 additions & 0 deletions app/javascript/flavours/aether/components/hashtag_bar.tsx
Original file line number Diff line number Diff line change
@@ -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<TagLike>;
contentHTML: string;
media_attachments: List<unknown>;
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 <a> starting with a hashtag
(element.textContent?.[0] === '#' ||
// or a #<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) return defaultResult;

template.content.removeChild(lastChild);
const contentWithoutLastLine = template;

// First, try to parse
const contentHashtags = Array.from(
contentWithoutLastLine.content.querySelectorAll<HTMLLinkElement>('a[href]'),
).reduce<string[]>((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 <StatusContent> component, and the hashtagBar to render
*/
export function getHashtagBarForStatus(status: StatusLike) {
const { statusContentProps, hashtagsInBar } =
computeHashtagBarForStatus(status);

return {
statusContentProps,
hashtagBar: <HashtagBar hashtags={hashtagsInBar} />,
};
}

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 (
<div className='hashtag-bar'>
{revealedHashtags.map((hashtag) => (
<Link key={hashtag} to={`/tags/${hashtag}`}>
#<span>{hashtag}</span>
</Link>
))}

{!expanded && hashtags.length > VISIBLE_HASHTAGS && (
<button className='link-button' onClick={handleClick}>
<FormattedMessage
id='hashtags.and_other'
defaultMessage='…and {count, plural, other {# more}}'
values={{ count: hashtags.length - VISIBLE_HASHTAGS }}
/>
</button>
)}
</div>
);
};
5 changes: 5 additions & 0 deletions app/javascript/flavours/aether/components/status.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';

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';
Expand Down Expand Up @@ -757,6 +758,9 @@ class Status extends ImmutablePureComponent {
unread,
muted,
}, 'focusable');

const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
contentMedia.push(hashtagBar);

return (
<HotKeys handlers={handlers}>
Expand Down Expand Up @@ -807,6 +811,7 @@ class Status extends ImmutablePureComponent {
tagLinks={settings.get('tag_misleading_links')}
rewriteMentions={settings.get('rewrite_mentions')}
cwSettings={settings.getIn(['content_warnings', 'cw_visibility'])}
{...statusContentProps}
/>

{!isCollapsed ? (
Expand Down
13 changes: 12 additions & 1 deletion app/javascript/flavours/aether/components/status_content.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,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 = {
Expand Down Expand Up @@ -115,6 +124,7 @@ class StatusContent extends PureComponent {

static propTypes = {
status: ImmutablePropTypes.map.isRequired,
statusContent: PropTypes.string,
expanded: PropTypes.bool,
collapsed: PropTypes.bool,
onExpandedToggle: PropTypes.func,
Expand Down Expand Up @@ -325,13 +335,14 @@ class StatusContent extends PureComponent {
rewriteMentions,
cwSettings,
intl,
statusContent,
} = this.props;

const hidden = this.props.onExpandedToggle ? (!this.props.expanded || (cwSettings === 'visible')) : this.state.hidden;
const contentLocale = intl.locale.replace(/[_-].*/, '');
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) };
let spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') };
const language = status.getIn(['translation', 'language']) || status.get('language');
const classNames = classnames('status__content', {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import AttachmentList from 'flavours/aether/components/attachment_list';
import { Avatar } from 'flavours/aether/components/avatar';
import { DisplayName } from 'flavours/aether/components/display_name';
import EditedTimestamp from 'flavours/aether/components/edited_timestamp';
import { getHashtagBarForStatus } from 'flavours/aether/components/hashtag_bar';
import { Icon } from 'flavours/aether/components/icon';
import MediaGallery from 'flavours/aether/components/media_gallery';
import PictureInPicturePlaceholder from 'flavours/aether/components/picture_in_picture_placeholder';
Expand Down Expand Up @@ -302,6 +303,9 @@ class DetailedStatus extends ImmutablePureComponent {
);
}

const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
contentMedia.push(hashtagBar);

return (
<div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
Expand All @@ -325,6 +329,7 @@ class DetailedStatus extends ImmutablePureComponent {
rewriteMentions={settings.get('rewrite_mentions')}
cwSettings={settings.getIn(['content_warnings', 'cw_visibility'])}
disabled
{...statusContentProps}
/>

<div className='detailed-status__meta'>
Expand Down
27 changes: 27 additions & 0 deletions app/javascript/flavours/aether/styles/components/status.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1186,3 +1186,30 @@ 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;
line-height: 18px;
gap: 4px;
color: var(--ui-font-dimmer-color);

a {
display: inline-flex;
color: inherit;
text-decoration: none;

&:hover span {
text-decoration: underline;
}
}

.link-button {
color: inherit;
font-size: inherit;
line-height: inherit;
padding: 0;
}
}
Loading

0 comments on commit 1f7627e

Please sign in to comment.