Add: Webでの引用表示 (#50)

* Add compacted component

* 引用表示の間にコンテナをはさみ、不要なコードを削除

* 引用APIを作成、ついでにブロック状況を引用APIに反映

* テスト修正など

* 引用をキャッシュに登録

* `quote_id`が`quote_of_id`になったのをSerializerに反映

* Fix test

* 引用をフィルターの対象に含める設定+エラー修正

* ストリーミングの存在しないプロパティ削除によるエラーを修正

* Fix lint

* 他のサーバーから来た引用付き投稿を処理

* Fix test

* フィルター設定時エラーの調整

* 画像つき投稿のスタイルを調整

* 画像つき投稿の最大高さを調整

* 引用禁止・非表示の設定を追加

* ブロック対応

* マイグレーションコード調整

* 引用設定の翻訳を作成

* Lint修正

* 参照1つの場合は引用に変換する設定を削除

* 不要になったテストを削除

* ブロック設定追加、バグ修正

* 他サーバーへ引用送信・受け入れ
This commit is contained in:
KMY(雪あすか) 2023-10-02 11:12:51 +09:00 committed by GitHub
parent 3c649aa74d
commit 44b739a39a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1362 additions and 120 deletions

View file

@ -80,6 +80,10 @@ export function importFetchedStatuses(statuses) {
processStatus(status.reblog);
}
if (status.quote && status.quote.id) {
processStatus(status.quote);
}
if (status.poll && status.poll.id) {
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
}

View file

@ -85,6 +85,11 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoiler_text = normalOldStatus.get('spoiler_text');
normalStatus.hidden = normalOldStatus.get('hidden');
// for quoted post
if (!normalStatus.filtered && normalOldStatus.get('filtered')) {
normalStatus.filtered = normalOldStatus.get('filtered');
}
if (normalOldStatus.get('translation')) {
normalStatus.translation = normalOldStatus.get('translation');
}

View file

@ -0,0 +1,503 @@
import PropTypes from 'prop-types';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { HotKeys } from 'react-hotkeys';
import AttachmentList from 'mastodon/components/attachment_list';
import { Icon } from 'mastodon/components/icon';
import Card from '../features/status/components/card';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { displayMedia } from '../initial_state';
import { Avatar } from './avatar';
import { DisplayName } from './display_name';
import { getHashtagBarForStatus } from './hashtag_bar';
import { RelativeTimestamp } from './relative_timestamp';
import StatusContent from './status_content';
const domParser = new DOMParser();
export const textForScreenReader = (intl, status, rebloggedByText = false) => {
const displayName = status.getIn(['account', 'display_name']);
const spoilerText = status.getIn(['translation', 'spoiler_text']) || status.get('spoiler_text');
const contentHtml = status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
const contentText = domParser.parseFromString(contentHtml, 'text/html').documentElement.textContent;
const values = [
displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
spoilerText && status.get('hidden') ? spoilerText : contentText,
intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
status.getIn(['account', 'acct']),
];
if (rebloggedByText) {
values.push(rebloggedByText);
}
return values.join(', ');
};
export const defaultMediaVisibility = (status) => {
if (!status) {
return undefined;
}
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
status = status.get('reblog');
}
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
};
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
public_unlisted_short: { id: 'privacy.public_unlisted.short', defaultMessage: 'Public unlisted' },
login_short: { id: 'privacy.login.short', defaultMessage: 'Login only' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited menbers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual followers only' },
circle_short: { id: 'privacy.circle.short', defaultMessage: 'Circle members only' },
personal_short: { id: 'privacy.personal.short', defaultMessage: 'Yourself only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
});
class CompactedStatus extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map,
previousId: PropTypes.string,
nextInReplyToId: PropTypes.string,
rootId: PropTypes.string,
onClick: PropTypes.func,
onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func,
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
onToggleCollapsed: PropTypes.func,
onTranslate: PropTypes.func,
onInteractionModal: PropTypes.func,
muted: PropTypes.bool,
hidden: PropTypes.bool,
unread: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
showThread: PropTypes.bool,
getScrollPosition: PropTypes.func,
updateScrollBottom: PropTypes.func,
cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number,
};
// Avoid checking props that are functions (and whose equality will always
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
'status',
'muted',
'hidden',
'unread',
];
state = {
showMedia: defaultMediaVisibility(this.props.status),
statusId: undefined,
forceFilter: undefined,
};
static getDerivedStateFromProps(nextProps, prevState) {
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
return {
showMedia: defaultMediaVisibility(nextProps.status),
statusId: nextProps.status.get('id'),
};
} else {
return null;
}
}
handleToggleMediaVisibility = () => {
this.setState({ showMedia: !this.state.showMedia });
};
handleClick = e => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
if (e) {
e.preventDefault();
}
this.handleHotkeyOpen();
};
handlePrependAccountClick = e => {
this.handleAccountClick(e, false);
};
handleAccountClick = (e, proper = true) => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
if (e) {
e.preventDefault();
e.stopPropagation();
}
this._openProfile(proper);
};
handleExpandedToggle = () => {
this.props.onToggleHidden(this._properStatus());
};
handleCollapsedToggle = isCollapsed => {
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
};
handleTranslate = () => {
this.props.onTranslate(this._properStatus());
};
getAttachmentAspectRatio () {
const attachments = this._properStatus().get('media_attachments');
if (attachments.getIn([0, 'type']) === 'video') {
return `${attachments.getIn([0, 'meta', 'original', 'width'])} / ${attachments.getIn([0, 'meta', 'original', 'height'])}`;
} else if (attachments.getIn([0, 'type']) === 'audio') {
return '16 / 9';
} else {
return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2'
}
}
renderLoadingMediaGallery = () => {
return (
<div className='media-gallery' style={{ aspectRatio: this.getAttachmentAspectRatio() }} />
);
};
renderLoadingVideoPlayer = () => {
return (
<div className='video-player' style={{ aspectRatio: this.getAttachmentAspectRatio() }} />
);
};
renderLoadingAudioPlayer = () => {
return (
<div className='audio-player' style={{ aspectRatio: this.getAttachmentAspectRatio() }} />
);
};
handleOpenVideo = (options) => {
const status = this._properStatus();
const lang = status.getIn(['translation', 'language']) || status.get('language');
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, options);
};
handleOpenMedia = (media, index) => {
const status = this._properStatus();
const lang = status.getIn(['translation', 'language']) || status.get('language');
this.props.onOpenMedia(status.get('id'), media, index, lang);
};
handleHotkeyOpenMedia = e => {
const { onOpenMedia, onOpenVideo } = this.props;
const status = this._properStatus();
e.preventDefault();
if (status.get('media_attachments').size > 0) {
const lang = status.getIn(['translation', 'language']) || status.get('language');
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, { startTime: 0 });
} else {
onOpenMedia(status.get('id'), status.get('media_attachments'), 0, lang);
}
}
};
handleHotkeyOpen = () => {
if (this.props.onClick) {
this.props.onClick();
return;
}
const { router } = this.context;
const status = this._properStatus();
if (!router) {
return;
}
router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
};
handleHotkeyOpenProfile = () => {
this._openProfile();
};
_openProfile = (proper = true) => {
const { router } = this.context;
const status = proper ? this._properStatus() : this.props.status;
if (!router) {
return;
}
router.history.push(`/@${status.getIn(['account', 'acct'])}`);
};
handleHotkeyMoveUp = e => {
this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
};
handleHotkeyMoveDown = e => {
this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
};
handleHotkeyToggleHidden = () => {
this.props.onToggleHidden(this._properStatus());
};
handleHotkeyToggleSensitive = () => {
this.handleToggleMediaVisibility();
};
_properStatus () {
const { status } = this.props;
if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
return status.get('reblog');
} else {
return status;
}
}
handleRef = c => {
this.node = c;
};
render () {
const { intl, hidden, featured, unread, showThread, previousId, nextInReplyToId, rootId } = this.props;
let { status } = this.props;
if (status === null) {
return null;
}
const handlers = this.props.muted ? {} : {
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
openMedia: this.handleHotkeyOpenMedia,
};
let media, isCardMediaWithSensitive, prepend, rebloggedByText;
if (hidden) {
return (
<HotKeys handlers={handlers}>
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={0}>
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
<span>{status.get('content')}</span>
</div>
</HotKeys>
);
}
const connectUp = previousId && previousId === status.get('in_reply_to_id');
const connectToRoot = rootId && rootId === status.get('in_reply_to_id');
const connectReply = nextInReplyToId && nextInReplyToId === status.get('id');
if (showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])) {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='reply' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
</div>
);
}
if (status.get('quote_muted')) {
const minHandlers = {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
return (
<HotKeys handlers={minHandlers}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className='status__wrapper status__wrapper__compact status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef} onClick={this.handleClick}>
<FormattedMessage id='status.quote_filtered' defaultMessage='This quote is filtered because of muting, blocking or domain blocking' />
</div>
</HotKeys>
);
}
isCardMediaWithSensitive = false;
if (status.get('media_attachments').size > 0) {
const language = status.getIn(['translation', 'language']) || status.get('language');
if (this.props.muted) {
media = (
<AttachmentList
compact
media={status.get('media_attachments')}
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
media = (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
{Component => (
<Component
src={attachment.get('url')}
alt={description}
lang={language}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
width={this.props.cachedMediaWidth}
height={110}
cacheWidth={this.props.cacheMediaWidth}
sensitive={status.get('sensitive')}
blurhash={attachment.get('blurhash')}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => (
<Component
preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
aspectRatio={`${attachment.getIn(['meta', 'original', 'width'])} / ${attachment.getIn(['meta', 'original', 'height'])}`}
blurhash={attachment.get('blurhash')}
src={attachment.get('url')}
alt={description}
lang={language}
sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
compact
media={status.get('media_attachments')}
lang={language}
sensitive={status.get('sensitive')}
height={110}
onOpenMedia={this.handleOpenMedia}
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>
)}
</Bundle>
);
}
} else if (status.get('card') && !this.props.muted) {
media = (
<Card
onOpenMedia={this.handleOpenMedia}
card={status.get('card')}
compact
sensitive={status.get('sensitive') && !status.get('spoiler_text')}
/>
);
isCardMediaWithSensitive = status.get('spoiler_text').length > 0;
}
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', 'status__wrapper__compact', `status__wrapper-${status.get('visibility_ex')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
{prepend}
<div className={classNames('status', `status-${status.get('visibility_ex')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onClick={this.handleClick} className='status__info'>
<a href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</a>
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar status__avatar__compact'>
<Avatar account={status.get('account')} size={24} inline />
</div>
<DisplayName account={status.get('account')} />
</a>
</div>
<StatusContent
status={status}
onClick={this.handleClick}
expanded={expanded}
onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate}
collapsible
onCollapsedToggle={this.handleCollapsedToggle}
{...statusContentProps}
/>
{(!isCardMediaWithSensitive || !status.get('hidden')) && media}
{(!status.get('spoiler_text') || expanded) && hashtagBar}
</div>
</div>
</HotKeys>
);
}
}
export default injectIntl(CompactedStatus);

View file

@ -236,6 +236,7 @@ class MediaGallery extends PureComponent {
visible: PropTypes.bool,
autoplay: PropTypes.bool,
onToggleVisibility: PropTypes.func,
compact: PropTypes.bool,
};
state = {
@ -306,7 +307,7 @@ class MediaGallery extends PureComponent {
}
render () {
const { media, lang, intl, sensitive, defaultWidth, autoplay } = this.props;
const { media, lang, intl, sensitive, defaultWidth, autoplay, compact } = this.props;
const { visible } = this.state;
const width = this.state.width || defaultWidth;
@ -359,9 +360,10 @@ class MediaGallery extends PureComponent {
const columnClass = (size === 9) ? 'media-gallery--column3' :
(size === 10 || size === 11 || size === 12 || size === 13 || size === 14 || size === 15 || size === 16) ? 'media-gallery--column4' :
'media-gallery--column2';
const compactClass = compact ? 'media-gallery__compact' : null;
return (
<div className={classNames('media-gallery', rowClass, columnClass)} style={style} ref={this.handleRef}>
<div className={classNames('media-gallery', rowClass, columnClass, compactClass)} style={style} ref={this.handleRef}>
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
{spoilerButton}
</div>

View file

@ -13,12 +13,13 @@ import AttachmentList from 'mastodon/components/attachment_list';
import { Icon } from 'mastodon/components/icon';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import CompactedStatusContainer from '../containers/compacted_status_container'
import Card from '../features/status/components/card';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { displayMedia, enableEmojiReaction, showEmojiReactionOnTimeline } from '../initial_state';
import { displayMedia, enableEmojiReaction, showEmojiReactionOnTimeline, showQuoteInHome, showQuoteInPublic } from '../initial_state';
import { Avatar } from './avatar';
import { AvatarOverlay } from './avatar_overlay';
@ -87,6 +88,7 @@ class Status extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
contextType: PropTypes.string,
previousId: PropTypes.string,
nextInReplyToId: PropTypes.string,
rootId: PropTypes.string,
@ -357,15 +359,17 @@ class Status extends ImmutablePureComponent {
};
render () {
const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props;
const { intl, hidden, featured, unread, muted, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props;
let { status, account, ...other } = this.props;
const contextType = (this.props.contextType || '').split(':')[0];
if (status === null) {
return null;
}
const handlers = this.props.muted ? {} : {
const handlers = muted ? {} : {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
@ -384,7 +388,7 @@ class Status extends ImmutablePureComponent {
if (hidden) {
return (
<HotKeys handlers={handlers}>
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={0}>
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !muted })} tabIndex={0}>
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
<span>{status.get('content')}</span>
</div>
@ -412,12 +416,53 @@ class Status extends ImmutablePureComponent {
let visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')];
if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={46} />;
} else {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
}
if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
const minHandlers = this.props.muted ? {} : {
const minHandlers = muted ? {} : {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
if (status.get('filter_action') === 'half_warn') {
return (
<HotKeys handlers={minHandlers}>
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onClick={this.handleClick} className='status__info'>
<a href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
{withReference}
{withExpiration}
{withLimited}
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</a>
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'>
{statusAvatar}
</div>
<DisplayName account={status.get('account')} />
</a>
</div>
<div >
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
{' '}
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
</button>
</div>
</div>
</HotKeys>
);
}
return (
<HotKeys handlers={minHandlers}>
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef}>
@ -478,7 +523,7 @@ class Status extends ImmutablePureComponent {
} else if (status.get('media_attachments').size > 0) {
const language = status.getIn(['translation', 'language']) || status.get('language');
if (this.props.muted) {
if (muted) {
media = (
<AttachmentList
compact
@ -556,7 +601,7 @@ class Status extends ImmutablePureComponent {
</Bundle>
);
}
} else if (status.get('card') && !this.props.muted) {
} else if (status.get('card') && !muted) {
media = (
<Card
onOpenMedia={this.handleOpenMedia}
@ -568,12 +613,6 @@ class Status extends ImmutablePureComponent {
isCardMediaWithSensitive = status.get('spoiler_text').length > 0;
}
if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={46} />;
} else {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
}
visibilityIcon = visibilityIconInfo[status.get('limited_scope') || status.get('visibility_ex')] || visibilityIconInfo[status.get('visibility')];
let emojiReactionsBar = null;
@ -588,20 +627,24 @@ class Status extends ImmutablePureComponent {
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
const withLimited = status.get('visibility_ex') === 'limited' && status.get('limited_scope') ? <span className='status__visibility-icon'><Icon id='get-pocket' title='Limited' /></span> : null;
const withReference = status.get('status_references_count') > 0 ? <span className='status__visibility-icon'><Icon id='link' title='Reference' /></span> : null;
const withQuote = status.get('quote_id') ? <span className='status__visibility-icon'><Icon id='quote-right' title='Quote' /></span> : null;
const withReference = (!withQuote && status.get('status_references_count') > 0) ? <span className='status__visibility-icon'><Icon id='link' title='Reference' /></span> : null;
const withExpiration = status.get('expires_at') ? <span className='status__visibility-icon'><Icon id='clock-o' title='Expiration' /></span> : null;
const quote = !muted && status.get('quote_id') && (['public', 'community'].includes(contextType) ? showQuoteInPublic : showQuoteInHome) && <CompactedStatusContainer id={status.get('quote_id')} />
return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility_ex')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility_ex')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !muted })} tabIndex={muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
{prepend}
<div className={classNames('status', `status-${status.get('visibility_ex')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
<div className={classNames('status', `status-${status.get('visibility_ex')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: muted })} data-id={status.get('id')}>
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onClick={this.handleClick} className='status__info'>
<a href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
{withQuote}
{withReference}
{withExpiration}
{withLimited}
@ -629,6 +672,8 @@ class Status extends ImmutablePureComponent {
{...statusContentProps}
/>
{(!status.get('spoiler_text') || expanded) && quote}
{(!isCardMediaWithSensitive || !status.get('hidden')) && media}
{(!status.get('spoiler_text') || expanded) && hashtagBar}

View file

@ -298,6 +298,7 @@ class StatusActionBar extends ImmutablePureComponent {
const account = status.get('account');
const writtenByMe = status.getIn(['account', 'id']) === me;
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
const allowQuote = status.getIn(['account', 'other_settings', 'allow_quote']);
let menu = [];
@ -332,7 +333,10 @@ class StatusActionBar extends ImmutablePureComponent {
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference });
menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote });
if (allowQuote) {
menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote });
}
}
menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClickOriginal });

View file

@ -0,0 +1,78 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from '../actions/modal';
import {
hideStatus,
revealStatus,
toggleStatusCollapse,
translateStatus,
undoStatusTranslation,
} from '../actions/statuses';
import CompactedStatus from '../components/compacted_status';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
const mapStateToProps = (state, props) => ({
status: getStatus(state, props),
nextInReplyToId: props.nextId ? state.getIn(['statuses', props.nextId, 'in_reply_to_id']) : null,
pictureInPicture: getPictureInPicture(state, props),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch) => ({
onTranslate (status) {
if (status.get('translation')) {
dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));
} else {
dispatch(translateStatus(status.get('id')));
}
},
onOpenMedia (statusId, media, index, lang) {
dispatch(openModal({
modalType: 'MEDIA',
modalProps: { statusId, media, index, lang },
}));
},
onOpenVideo (statusId, media, lang, options) {
dispatch(openModal({
modalType: 'VIDEO',
modalProps: { statusId, media, lang, options },
}));
},
onToggleHidden (status) {
if (status.get('hidden')) {
dispatch(revealStatus(status.get('id')));
} else {
dispatch(hideStatus(status.get('id')));
}
},
onToggleCollapsed (status, isCollapsed) {
dispatch(toggleStatusCollapse(status.get('id'), isCollapsed));
},
onInteractionModal (type, status) {
dispatch(openModal({
modalType: 'INTERACTION',
modalProps: {
type,
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}));
},
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(CompactedStatus));

View file

@ -80,6 +80,8 @@ const makeMapStateToProps = () => {
const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
contextType,
onReply (status, router) {
dispatch((_, getState) => {
let state = getState();

View file

@ -236,6 +236,7 @@ class ActionBar extends PureComponent {
const account = status.get('account');
const writtenByMe = status.getIn(['account', 'id']) === me;
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
const allowQuote = status.getIn(['account', 'other_settings', 'allow_quote']);
let menu = [];
@ -259,7 +260,10 @@ class ActionBar extends PureComponent {
if (publicStatus) {
menu.push({ text: intl.formatMessage(messages.reference), action: this.handleReference });
menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote });
if (allowQuote) {
menu.push({ text: intl.formatMessage(messages.quote), action: this.handleQuote });
}
}
menu.push({ text: intl.formatMessage(messages.bookmark_category), action: this.handleBookmarkCategoryAdderClick });

View file

@ -64,6 +64,7 @@
* @property {boolean} enable_local_privacy
* @property {boolean} enable_dtl_menu
* @property {boolean=} expand_spoilers
* @property {boolean} hide_blocking_quote
* @property {boolean} hide_recent_emojis
* @property {boolean} limited_federation_mode
* @property {string} locale
@ -78,6 +79,8 @@
* @property {boolean} search_enabled
* @property {boolean} trends_enabled
* @property {boolean} show_emoji_reaction_on_timeline
* @property {boolean} show_quote_in_home
* @property {boolean} show_quote_in_public
* @property {string} simple_timeline_menu
* @property {boolean} single_user_mode
* @property {string} source_url
@ -136,6 +139,7 @@ export const enableLoginPrivacy = getMeta('enable_login_privacy');
export const enableDtlMenu = getMeta('enable_dtl_menu');
export const expandSpoilers = getMeta('expand_spoilers');
export const forceSingleColumn = !getMeta('advanced_layout');
export const hideBlockingQuote = getMeta('hide_blocking_quote');
export const hideRecentEmojis = getMeta('hide_recent_emojis');
export const limitedFederationMode = getMeta('limited_federation_mode');
export const mascot = getMeta('mascot');
@ -149,6 +153,8 @@ export const repository = getMeta('repository');
export const searchEnabled = getMeta('search_enabled');
export const trendsEnabled = getMeta('trends_enabled');
export const showEmojiReactionOnTimeline = getMeta('show_emoji_reaction_on_timeline');
export const showQuoteInHome = getMeta('show_quote_in_home');
export const showQuoteInPublic = getMeta('show_quote_in_public');
export const showTrends = getMeta('show_trends');
export const simpleTimelineMenu = getMeta('simple_timeline_menu');
export const singleUserMode = getMeta('single_user_mode');

View file

@ -692,7 +692,7 @@
"status.open": "Expand this post",
"status.pin": "Pin on profile",
"status.pinned": "Pinned post",
"status.quote": "Ref (quote in other servers)",
"status.quote": "Quote",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost with original visibility",

View file

@ -778,7 +778,7 @@
"status.open": "詳細を表示",
"status.pin": "プロフィールに固定表示",
"status.pinned": "固定された投稿",
"status.quote": "参照 (他サーバーで引用扱い)",
"status.quote": "引用",
"status.read_more": "もっと見る",
"status.reblog": "ブースト",
"status.reblog_private": "ブースト",

View file

@ -3,7 +3,7 @@ import { createSelector } from 'reselect';
import { toServerSideType } from 'mastodon/utils/filters';
import { me } from '../initial_state';
import { me, hideBlockingQuote } from '../initial_state';
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
@ -53,7 +53,12 @@ export const makeGetStatus = () => {
statusReblog = null;
}
if (hideBlockingQuote && statusBase.getIn(['quote', 'quote_muted'])) {
return null;
}
let filtered = false;
let filterAction = 'warn';
if ((accountReblog || accountBase).get('id') !== me && filters) {
let filterResults = statusReblog?.get('filtered') || statusBase.get('filtered') || ImmutableList();
if (filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'hide')) {
@ -62,6 +67,7 @@ export const makeGetStatus = () => {
filterResults = filterResults.filter(result => filters.has(result.get('filter')));
if (!filterResults.isEmpty()) {
filtered = filterResults.map(result => filters.getIn([result.get('filter'), 'title']));
filterAction = filterResults.some((result) => filters.getIn([result.get('filter'), 'filter_action']) === 'warn') ? 'warn' : 'half_warn';
}
}
@ -69,6 +75,7 @@ export const makeGetStatus = () => {
map.set('reblog', statusReblog);
map.set('account', accountBase);
map.set('matched_filters', filtered);
map.set('filter_action', filterAction);
});
},
);