Merge remote-tracking branch 'parent/main' into upstream-20250328

This commit is contained in:
KMY 2025-03-28 08:44:30 +09:00
commit 12ed20b6d5
257 changed files with 3505 additions and 2010 deletions

View file

@ -0,0 +1,105 @@
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import type { IntlShape } from 'react-intl';
import classNames from 'classnames';
import { dismissAlert } from 'mastodon/actions/alerts';
import type {
Alert,
TranslatableString,
TranslatableValues,
} from 'mastodon/models/alert';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const formatIfNeeded = (
intl: IntlShape,
message: TranslatableString,
values?: TranslatableValues,
) => {
if (typeof message === 'object') {
return intl.formatMessage(message, values);
}
return message;
};
const Alert: React.FC<{
alert: Alert;
dismissAfter: number;
}> = ({
alert: { key, title, message, values, action, onClick },
dismissAfter,
}) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const [active, setActive] = useState(false);
useEffect(() => {
const setActiveTimeout = setTimeout(() => {
setActive(true);
}, 1);
return () => {
clearTimeout(setActiveTimeout);
};
}, []);
useEffect(() => {
const dismissTimeout = setTimeout(() => {
setActive(false);
// Allow CSS transition to finish before removing from the DOM
setTimeout(() => {
dispatch(dismissAlert({ key }));
}, 500);
}, dismissAfter);
return () => {
clearTimeout(dismissTimeout);
};
}, [dispatch, setActive, key, dismissAfter]);
return (
<div
className={classNames('notification-bar', {
'notification-bar-active': active,
})}
>
<div className='notification-bar-wrapper'>
{title && (
<span className='notification-bar-title'>
{formatIfNeeded(intl, title, values)}
</span>
)}
<span className='notification-bar-message'>
{formatIfNeeded(intl, message, values)}
</span>
{action && (
<button className='notification-bar-action' onClick={onClick}>
{formatIfNeeded(intl, action, values)}
</button>
)}
</div>
</div>
);
};
export const AlertsController: React.FC = () => {
const alerts = useAppSelector((state) => state.alerts);
if (alerts.length === 0) {
return null;
}
return (
<div className='notification-list'>
{alerts.map((alert, idx) => (
<Alert key={alert.key} alert={alert} dismissAfter={5000 + idx * 1000} />
))}
</div>
);
};

View file

@ -6,6 +6,7 @@ import { FormattedMessage, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
import InlineAccount from 'mastodon/components/inline_account';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
@ -60,12 +61,12 @@ class EditedTimestamp extends PureComponent {
};
render () {
const { timestamp, intl, statusId } = this.props;
const { timestamp, statusId } = this.props;
return (
<DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
<button className='dropdown-menu__text-button'>
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <span className='animated-number'>{intl.formatDate(timestamp, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span> }} />
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <FormattedDateWrapper className='animated-number' value={timestamp} month='short' day='2-digit' hour='2-digit' minute='2-digit' /> }} />
</button>
</DropdownMenu>
);

View file

@ -0,0 +1,26 @@
import type { ComponentProps } from 'react';
import { FormattedDate } from 'react-intl';
export const FormattedDateWrapper = (
props: ComponentProps<typeof FormattedDate> & { className?: string },
) => (
<FormattedDate {...props}>
{(date) => (
<time dateTime={tryIsoString(props.value)} className={props.className}>
{date}
</time>
)}
</FormattedDate>
);
const tryIsoString = (date?: string | number | Date): string => {
if (!date) {
return '';
}
try {
return new Date(date).toISOString();
} catch {
return date.toString();
}
};

View file

@ -12,6 +12,7 @@ import { debounce } from 'lodash';
import { AltTextBadge } from 'mastodon/components/alt_text_badge';
import { Blurhash } from 'mastodon/components/blurhash';
import { SpoilerButton } from 'mastodon/components/spoiler_button';
import { formatTime } from 'mastodon/features/video';
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
@ -38,6 +39,7 @@ class Item extends PureComponent {
state = {
loaded: false,
error: false,
};
handleMouseEnter = (e) => {
@ -81,6 +83,10 @@ class Item extends PureComponent {
this.setState({ loaded: true });
};
handleImageError = () => {
this.setState({ error: true });
};
render () {
const { attachment, lang, index, size, standalone, displayWidth, visible } = this.props;
@ -164,6 +170,7 @@ class Item extends PureComponent {
lang={lang}
style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad}
onError={this.handleImageError}
/>
</a>
);
@ -199,7 +206,7 @@ class Item extends PureComponent {
}
return (
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--error': this.state.error, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
<Blurhash
hash={attachment.get('blurhash')}
dummy={!useBlurhash}
@ -236,6 +243,7 @@ class MediaGallery extends PureComponent {
autoplay: PropTypes.bool,
onToggleVisibility: PropTypes.func,
compact: PropTypes.bool,
matchedFilters: PropTypes.arrayOf(PropTypes.string),
};
state = {
@ -306,11 +314,11 @@ class MediaGallery extends PureComponent {
}
render () {
const { media, lang, sensitive, defaultWidth, autoplay, compact } = this.props;
const { media, lang, sensitive, defaultWidth, autoplay, compact, matchedFilters } = this.props;
const { visible } = this.state;
const width = this.state.width || defaultWidth;
let children, spoilerButton;
let children;
const style = {};
@ -329,26 +337,6 @@ class MediaGallery extends PureComponent {
children = media.map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} displayWidth={width} visible={visible || uncached} />);
}
if (uncached) {
spoilerButton = (
<button type='button' disabled className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>
<FormattedMessage id='status.uncached_media_warning' defaultMessage='Preview not available' />
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.open' defaultMessage='Click to open' /></span>
</span>
</button>
);
} else if (!visible) {
spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
<span className='spoiler-button__overlay__label'>
{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}
<span className='spoiler-button__overlay__action'><FormattedMessage id='status.media.show' defaultMessage='Click to show' /></span>
</span>
</button>
);
}
const rowClass = (size === 5 || size === 6 || size === 9 || size === 10 || size === 11 || size === 12) ? 'media-gallery--row3' :
(size === 7 || size === 8 || size === 13 || size === 14 || size === 15 || size === 16) ? 'media-gallery--row4' :
'media-gallery--row2';
@ -366,11 +354,7 @@ class MediaGallery extends PureComponent {
<div className={classNames(classList)} style={style} ref={this.handleRef}>
{children}
{(!visible || uncached) && (
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
{spoilerButton}
</div>
)}
{(!visible || uncached) && <SpoilerButton uncached={uncached} sensitive={sensitive} onClick={this.handleOpen} matchedFilters={matchedFilters} />}
{(visible && !uncached) && (
<div className='media-gallery__actions'>

View file

@ -0,0 +1,89 @@
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
interface Props {
hidden?: boolean;
sensitive: boolean;
uncached?: boolean;
matchedFilters?: string[];
onClick: React.MouseEventHandler<HTMLButtonElement>;
}
export const SpoilerButton: React.FC<Props> = ({
hidden = false,
sensitive,
uncached = false,
matchedFilters,
onClick,
}) => {
let warning;
let action;
if (uncached) {
warning = (
<FormattedMessage
id='status.uncached_media_warning'
defaultMessage='Preview not available'
/>
);
action = (
<FormattedMessage id='status.media.open' defaultMessage='Click to open' />
);
} else if (matchedFilters) {
warning = (
<FormattedMessage
id='filter_warning.matches_filter'
defaultMessage='Matches filter “<span>{title}</span>”'
values={{
title: matchedFilters.join(', '),
span: (chunks) => <span className='filter-name'>{chunks}</span>,
}}
/>
);
action = (
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
);
} else if (sensitive) {
warning = (
<FormattedMessage
id='status.sensitive_warning'
defaultMessage='Sensitive content'
/>
);
action = (
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
);
} else {
warning = (
<FormattedMessage
id='status.media_hidden'
defaultMessage='Media hidden'
/>
);
action = (
<FormattedMessage id='status.media.show' defaultMessage='Click to show' />
);
}
return (
<div
className={classNames('spoiler-button', {
'spoiler-button--hidden': hidden,
'spoiler-button--click-thru': uncached,
})}
>
<button
type='button'
className='spoiler-button__overlay'
onClick={onClick}
disabled={uncached}
>
<span className='spoiler-button__overlay__label'>
{warning}
<span className='spoiler-button__overlay__action'>{action}</span>
</span>
</button>
</div>
);
};

View file

@ -77,7 +77,7 @@ export const defaultMediaVisibility = (status) => {
status = status.get('reblog');
}
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
return !status.get('matched_media_filters') && (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
};
const messages = defineMessages({
@ -496,6 +496,7 @@ class Status extends ImmutablePureComponent {
defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
matchedFilters={status.get('matched_media_filters')}
/>
)}
</Bundle>
@ -524,6 +525,7 @@ class Status extends ImmutablePureComponent {
blurhash={attachment.get('blurhash')}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
matchedFilters={status.get('matched_media_filters')}
/>
)}
</Bundle>
@ -548,6 +550,7 @@ class Status extends ImmutablePureComponent {
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
matchedFilters={status.get('matched_media_filters')}
/>
)}
</Bundle>