Merge commit '081d38679f965ac20ff1a6c72e2fc1108c34ac30' into glitch-soc/merge-upstream

This commit is contained in:
Claire committed 2025-08-04 21:24:47 +02:00
commit 53185f5381
69 files changed
+1172 -529

No files matched your search

@@ -15,17 +15,6 @@ export const SKIN_TONE_CODES = [
0x1f3ff, // Dark skin tone
] as const;
// TODO: Test and create fallback for browsers that do not handle the /v flag.
export const UNICODE_EMOJI_REGEX = /\p{RGI_Emoji}/v;
// See: https://www.unicode.org/reports/tr51/#valid-emoji-tag-sequences
export const UNICODE_FLAG_EMOJI_REGEX =
/\p{RGI_Emoji_Flag_Sequence}|\p{RGI_Emoji_Tag_Sequence}/v;
export const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
export const ANY_EMOJI_REGEX = new RegExp(
`(${UNICODE_EMOJI_REGEX.source}|${CUSTOM_EMOJI_REGEX.source})`,
'gv',
);
// Emoji rendering modes. A mode is what we are using to render emojis, a style is what the user has selected.
export const EMOJI_MODE_NATIVE = 'native';
export const EMOJI_MODE_NATIVE_WITH_FLAGS = 'native-flags';
@@ -1,5 +1,7 @@
import type { ComponentPropsWithoutRef, ElementType } from 'react';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { useEmojify } from './hooks';
import type { CustomEmojiMapArg } from './types';
@@ -12,7 +14,7 @@ type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
as?: Element;
};
export const EmojiHTML = <Element extends ElementType>({
export const ModernEmojiHTML = <Element extends ElementType>({
extraEmojis,
htmlString,
as: asElement, // Rename for syntax highlighting
@@ -29,3 +31,18 @@ export const EmojiHTML = <Element extends ElementType>({
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
);
};
export const EmojiHTML = <Element extends ElementType>(
props: EmojiHTMLProps<Element>,
) => {
if (isModernEmojiEnabled()) {
return <ModernEmojiHTML {...props} />;
}
const Wrapper = props.as ?? 'div';
return (
<Wrapper
{...props}
dangerouslySetInnerHTML={{ __html: props.htmlString }}
/>
);
};
@@ -8,7 +8,6 @@ import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { toSupportedLocale } from './locale';
import { determineEmojiMode } from './mode';
import { emojifyElement } from './render';
import type {
CustomEmojiMapArg,
EmojiAppState,
@@ -39,6 +38,7 @@ export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
async (input: string) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = input;
const { emojifyElement } = await import('./render');
const result = await emojifyElement(wrapper, appState, extra);
if (result) {
setEmojifiedText(result.innerHTML);
@@ -9,7 +9,6 @@ import {
EMOJI_TYPE_UNICODE,
EMOJI_TYPE_CUSTOM,
EMOJI_STATE_MISSING,
ANY_EMOJI_REGEX,
} from './constants';
import {
searchCustomEmojisByShortcodes,
@@ -32,7 +31,12 @@ import type {
LocaleOrCustom,
UnicodeEmojiToken,
} from './types';
import { emojiLogger, stringHasAnyEmoji, stringHasUnicodeFlags } from './utils';
import {
anyEmojiRegex,
emojiLogger,
stringHasAnyEmoji,
stringHasUnicodeFlags,
} from './utils';
const log = emojiLogger('render');
@@ -207,7 +211,7 @@ export function tokenizeText(text: string): TokenizedText {
const tokens = [];
let lastIndex = 0;
for (const match of text.matchAll(ANY_EMOJI_REGEX)) {
for (const match of text.matchAll(anyEmojiRegex())) {
if (match.index > lastIndex) {
tokens.push(text.slice(lastIndex, match.index));
}
@@ -1,23 +1,32 @@
import debug from 'debug';
import {
CUSTOM_EMOJI_REGEX,
UNICODE_EMOJI_REGEX,
UNICODE_FLAG_EMOJI_REGEX,
} from './constants';
import { emojiRegexPolyfill } from '@/mastodon/polyfills';
export function emojiLogger(segment: string) {
return debug(`emojis:${segment}`);
}
export function stringHasUnicodeEmoji(input: string): boolean {
return UNICODE_EMOJI_REGEX.test(input);
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input);
}
export function stringHasUnicodeFlags(input: string): boolean {
return UNICODE_FLAG_EMOJI_REGEX.test(input);
if (supportsRegExpSets()) {
return new RegExp(
'\\p{RGI_Emoji_Flag_Sequence}|\\p{RGI_Emoji_Tag_Sequence}',
'v',
).test(input);
}
return new RegExp(
// First range is regional indicator symbols,
// Second is a black flag + 0-9|a-z tag chars + cancel tag.
// See: https://en.wikipedia.org/wiki/Regional_indicator_symbol
'(?:\uD83C[\uDDE6-\uDDFF]){2}|\uD83C\uDFF4(?:\uDB40[\uDC30-\uDC7A])+\uDB40\uDC7F',
).test(input);
}
// Constant as this is supported by all browsers.
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
export function stringHasCustomEmoji(input: string) {
return CUSTOM_EMOJI_REGEX.test(input);
}
@@ -25,3 +34,23 @@ export function stringHasCustomEmoji(input: string) {
export function stringHasAnyEmoji(input: string) {
return stringHasUnicodeEmoji(input) || stringHasCustomEmoji(input);
}
export function anyEmojiRegex() {
return new RegExp(
`${EMOJI_REGEX}|${CUSTOM_EMOJI_REGEX.source}`,
supportedFlags('gi'),
);
}
function supportsRegExpSets() {
return 'unicodeSets' in RegExp.prototype;
}
function supportedFlags(flags = '') {
if (supportsRegExpSets()) {
return `${flags}v`;
}
return flags;
}
const EMOJI_REGEX = emojiRegexPolyfill?.source ?? '\\p{RGI_Emoji}';
@@ -8,9 +8,9 @@ import { Link, withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
import FlagIcon from '@/material-icons/400-24px/flag-fill.svg?react';
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
import InsertChartIcon from '@/material-icons/400-24px/insert_chart.svg?react';
import PersonIcon from '@/material-icons/400-24px/person-fill.svg?react';
@@ -42,6 +42,7 @@ const messages = defineMessages({
adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
relationshipsSevered: { id: 'notification.relationships_severance_event', defaultMessage: 'Lost connections with {name}' },
moderationWarning: { id: 'notification.moderation_warning', defaultMessage: 'You have received a moderation warning' },
quote: { id: 'notification.label.quote', defaultMessage: '{name} quoted your post'}
});
const notificationForScreenReader = (intl, message, timestamp) => {
@@ -251,6 +252,36 @@ class Notification extends ImmutablePureComponent {
);
}
renderQuote (notification, link) {
const { intl, unread } = this.props;
return (
<Hotkeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-quote focusable', { unread })} tabIndex={0} aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.quote, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<Icon id='quote' icon={FormatQuoteIcon} />
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.label.quote' defaultMessage='{name} quoted your post' values={{ name: link }} />
</span>
</div>
<StatusQuoteManager
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/>
</div>
</Hotkeys>
);
}
renderStatus (notification, link) {
const { intl, unread, status } = this.props;
@@ -467,6 +498,8 @@ class Notification extends ImmutablePureComponent {
return this.renderFollowRequest(notification, account, link);
case 'mention':
return this.renderMention(notification);
case 'quote':
return this.renderQuote(notification);
case 'favourite':
return this.renderFavourite(notification, link);
case 'reblog':
@@ -15,6 +15,7 @@ import { NotificationFollowRequest } from './notification_follow_request';
import { NotificationMention } from './notification_mention';
import { NotificationModerationWarning } from './notification_moderation_warning';
import { NotificationPoll } from './notification_poll';
import { NotificationQuote } from './notification_quote';
import { NotificationReblog } from './notification_reblog';
import { NotificationSeveredRelationships } from './notification_severed_relationships';
import { NotificationStatus } from './notification_status';
@@ -91,6 +92,11 @@ export const NotificationGroup: React.FC<{
<NotificationMention unread={unread} notification={notificationGroup} />
);
break;
case 'quote':
content = (
<NotificationQuote unread={unread} notification={notificationGroup} />
);
break;
case 'follow':
content = (
<NotificationFollow unread={unread} notification={notificationGroup} />
@@ -0,0 +1,33 @@
import { FormattedMessage } from 'react-intl';
import FormatQuoteIcon from '@/material-icons/400-24px/format_quote.svg?react';
import type { NotificationGroupQuote } from 'mastodon/models/notification_group';
import type { LabelRenderer } from './notification_group_with_status';
import { NotificationWithStatus } from './notification_with_status';
const quoteLabelRenderer: LabelRenderer = (displayName) => (
<FormattedMessage
id='notification.label.quote'
defaultMessage='{name} quoted your post'
values={{ name: displayName }}
/>
);
export const NotificationQuote: React.FC<{
notification: NotificationGroupQuote;
unread: boolean;
}> = ({ notification, unread }) => {
return (
<NotificationWithStatus
type='quote'
icon={FormatQuoteIcon}
iconId='quote'
accountIds={notification.sampleAccountIds}
count={notification.notifications_count}
statusId={notification.statusId}
labelRenderer={quoteLabelRenderer}
unread={unread}
/>
);
};