Merge pull request #2391 from ClearlyClaire/glitch-soc/port-upstream-hashtags
Port upstream's hashtag handling to glitch-socpull/2469/head
commit
7e5d00720b
|
@ -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('<p>Simple text</p>', []);
|
||||||
|
|
||||||
|
const { hashtagsInBar, statusContentProps } =
|
||||||
|
computeHashtagBarForStatus(status);
|
||||||
|
|
||||||
|
expect(hashtagsInBar).toEqual([]);
|
||||||
|
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||||
|
`"<p>Simple text</p>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays out of band hashtags in the bar', () => {
|
||||||
|
const status = createStatus(
|
||||||
|
'<p>Simple text <a href="test">#hashtag</a></p>',
|
||||||
|
['hashtag', 'test'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hashtagsInBar, statusContentProps } =
|
||||||
|
computeHashtagBarForStatus(status);
|
||||||
|
|
||||||
|
expect(hashtagsInBar).toEqual(['test']);
|
||||||
|
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||||
|
`"<p>Simple text <a href="test">#hashtag</a></p>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not truncate the contents when the last child is a text node', () => {
|
||||||
|
const status = createStatus(
|
||||||
|
'this is a #<a class="zrl" href="https://example.com/search?tag=test">test</a>. Some more text',
|
||||||
|
['test'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hashtagsInBar, statusContentProps } =
|
||||||
|
computeHashtagBarForStatus(status);
|
||||||
|
|
||||||
|
expect(hashtagsInBar).toEqual([]);
|
||||||
|
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||||
|
`"this is a #<a class="zrl" href="https://example.com/search?tag=test">test</a>. Some more text"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extract tags from the last line', () => {
|
||||||
|
const status = createStatus(
|
||||||
|
'<p>Simple text</p><p><a href="test">#hashtag</a></p>',
|
||||||
|
['hashtag'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hashtagsInBar, statusContentProps } =
|
||||||
|
computeHashtagBarForStatus(status);
|
||||||
|
|
||||||
|
expect(hashtagsInBar).toEqual(['hashtag']);
|
||||||
|
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||||
|
`"<p>Simple text</p>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include tags from content', () => {
|
||||||
|
const status = createStatus(
|
||||||
|
'<p>Simple text with a <a href="test">#hashtag</a></p><p><a href="test">#hashtag</a></p>',
|
||||||
|
['hashtag'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hashtagsInBar, statusContentProps } =
|
||||||
|
computeHashtagBarForStatus(status);
|
||||||
|
|
||||||
|
expect(hashtagsInBar).toEqual([]);
|
||||||
|
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||||
|
`"<p>Simple text with a <a href="test">#hashtag</a></p>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with one line status and hashtags', () => {
|
||||||
|
const status = createStatus(
|
||||||
|
'<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>',
|
||||||
|
['hashtag', 'test'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hashtagsInBar, statusContentProps } =
|
||||||
|
computeHashtagBarForStatus(status);
|
||||||
|
|
||||||
|
expect(hashtagsInBar).toEqual([]);
|
||||||
|
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||||
|
`"<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('de-duplicate accentuated characters with case differences', () => {
|
||||||
|
const status = createStatus(
|
||||||
|
'<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>',
|
||||||
|
['éaa'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hashtagsInBar, statusContentProps } =
|
||||||
|
computeHashtagBarForStatus(status);
|
||||||
|
|
||||||
|
expect(hashtagsInBar).toEqual(['Éaa']);
|
||||||
|
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||||
|
`"<p>Text</p>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles server-side normalized tags with accentuated characters', () => {
|
||||||
|
const status = createStatus(
|
||||||
|
'<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>',
|
||||||
|
['eaa'], // The server may normalize the hashtags in the `tags` attribute
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hashtagsInBar, statusContentProps } =
|
||||||
|
computeHashtagBarForStatus(status);
|
||||||
|
|
||||||
|
expect(hashtagsInBar).toEqual(['Éaa']);
|
||||||
|
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||||
|
`"<p>Text</p>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not display in bar a hashtag in content with a case difference', () => {
|
||||||
|
const status = createStatus(
|
||||||
|
'<p>Text <a href="test">#Éaa</a></p><p><a href="test">#éaa</a></p>',
|
||||||
|
['éaa'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hashtagsInBar, statusContentProps } =
|
||||||
|
computeHashtagBarForStatus(status);
|
||||||
|
|
||||||
|
expect(hashtagsInBar).toEqual([]);
|
||||||
|
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||||
|
`"<p>Text <a href="test">#Éaa</a></p>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not modify a status with a line of hashtags only', () => {
|
||||||
|
const status = createStatus(
|
||||||
|
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>',
|
||||||
|
['test', 'hashtag'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hashtagsInBar, statusContentProps } =
|
||||||
|
computeHashtagBarForStatus(status);
|
||||||
|
|
||||||
|
expect(hashtagsInBar).toEqual([]);
|
||||||
|
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||||
|
`"<p><a href="test">#test</a> <a href="test">#hashtag</a></p>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the hashtags in the bar if a status content has hashtags in the only line and has a media', () => {
|
||||||
|
const status = createStatus(
|
||||||
|
'<p>This is my content! <a href="test">#hashtag</a></p>',
|
||||||
|
['hashtag'],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hashtagsInBar, statusContentProps } =
|
||||||
|
computeHashtagBarForStatus(status);
|
||||||
|
|
||||||
|
expect(hashtagsInBar).toEqual([]);
|
||||||
|
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||||
|
`"<p>This is my content! <a href="test">#hashtag</a></p>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the hashtags in the bar if a status content is only hashtags and has a media', () => {
|
||||||
|
const status = createStatus(
|
||||||
|
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>',
|
||||||
|
['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(
|
||||||
|
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>',
|
||||||
|
['test', 'hashtag'],
|
||||||
|
true,
|
||||||
|
'My CW text',
|
||||||
|
);
|
||||||
|
|
||||||
|
const { hashtagsInBar, statusContentProps } =
|
||||||
|
computeHashtagBarForStatus(status);
|
||||||
|
|
||||||
|
expect(hashtagsInBar).toEqual([]);
|
||||||
|
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
|
||||||
|
`"<p><a href="test">#test</a> <a href="test">#hashtag</a></p>"`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 || lastChild.nodeType === Node.TEXT_NODE) 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -21,6 +21,7 @@ import { MediaGallery, Video, Audio } from '../features/ui/util/async-components
|
||||||
import { displayMedia } from '../initial_state';
|
import { displayMedia } from '../initial_state';
|
||||||
|
|
||||||
import AttachmentList from './attachment_list';
|
import AttachmentList from './attachment_list';
|
||||||
|
import { getHashtagBarForStatus } from './hashtag_bar';
|
||||||
import StatusActionBar from './status_action_bar';
|
import StatusActionBar from './status_action_bar';
|
||||||
import StatusContent from './status_content';
|
import StatusContent from './status_content';
|
||||||
import StatusHeader from './status_header';
|
import StatusHeader from './status_header';
|
||||||
|
@ -779,6 +780,9 @@ class Status extends ImmutablePureComponent {
|
||||||
muted,
|
muted,
|
||||||
}, 'focusable');
|
}, 'focusable');
|
||||||
|
|
||||||
|
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||||
|
contentMedia.push(hashtagBar);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys handlers={handlers}>
|
<HotKeys handlers={handlers}>
|
||||||
<div
|
<div
|
||||||
|
@ -828,6 +832,7 @@ class Status extends ImmutablePureComponent {
|
||||||
disabled={!history}
|
disabled={!history}
|
||||||
tagLinks={settings.get('tag_misleading_links')}
|
tagLinks={settings.get('tag_misleading_links')}
|
||||||
rewriteMentions={settings.get('rewrite_mentions')}
|
rewriteMentions={settings.get('rewrite_mentions')}
|
||||||
|
{...statusContentProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
|
{!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
|
||||||
|
|
|
@ -69,6 +69,15 @@ const isLinkMisleading = (link) => {
|
||||||
return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host));
|
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 {
|
class TranslateButton extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
@ -118,6 +127,7 @@ class StatusContent extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
|
statusContent: PropTypes.string,
|
||||||
expanded: PropTypes.bool,
|
expanded: PropTypes.bool,
|
||||||
collapsed: PropTypes.bool,
|
collapsed: PropTypes.bool,
|
||||||
onExpandedToggle: PropTypes.func,
|
onExpandedToggle: PropTypes.func,
|
||||||
|
@ -327,6 +337,7 @@ class StatusContent extends PureComponent {
|
||||||
tagLinks,
|
tagLinks,
|
||||||
rewriteMentions,
|
rewriteMentions,
|
||||||
intl,
|
intl,
|
||||||
|
statusContent,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
|
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 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 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 spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') };
|
||||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||||
const classNames = classnames('status__content', {
|
const classNames = classnames('status__content', {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { AnimatedNumber } from 'flavours/glitch/components/animated_number';
|
import { AnimatedNumber } from 'flavours/glitch/components/animated_number';
|
||||||
import AttachmentList from 'flavours/glitch/components/attachment_list';
|
import AttachmentList from 'flavours/glitch/components/attachment_list';
|
||||||
import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
|
import EditedTimestamp from 'flavours/glitch/components/edited_timestamp';
|
||||||
|
import { getHashtagBarForStatus } from 'flavours/glitch/components/hashtag_bar';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
|
||||||
import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
|
import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
|
||||||
|
@ -303,6 +304,9 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||||
|
contentMedia.push(hashtagBar);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={outerStyle}>
|
<div style={outerStyle}>
|
||||||
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
|
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })} data-status-by={status.getIn(['account', 'acct'])}>
|
||||||
|
@ -325,6 +329,7 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||||
tagLinks={settings.get('tag_misleading_links')}
|
tagLinks={settings.get('tag_misleading_links')}
|
||||||
rewriteMentions={settings.get('rewrite_mentions')}
|
rewriteMentions={settings.get('rewrite_mentions')}
|
||||||
disabled
|
disabled
|
||||||
|
{...statusContentProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='detailed-status__meta'>
|
<div className='detailed-status__meta'>
|
||||||
|
|
|
@ -1168,3 +1168,30 @@ a.status-card.compact:hover {
|
||||||
border-color: lighten($ui-base-color, 12%);
|
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: $darker-text-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…
Reference in New Issue