vt-social/app/javascript/mastodon/components/status.jsx

593 lines
22 KiB
React
Raw Normal View History

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 { Icon } from 'mastodon/components/icon';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
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 { AvatarOverlay } from './avatar_overlay';
import { DisplayName } from './display_name';
import { RelativeTimestamp } from './relative_timestamp';
import StatusActionBar from './status_action_bar';
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' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
});
2018-09-14 15:59:48 +00:00
class Status extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.map,
previousId: PropTypes.string,
nextInReplyToId: PropTypes.string,
rootId: PropTypes.string,
onClick: PropTypes.func,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
onDelete: PropTypes.func,
onDirect: PropTypes.func,
onMention: PropTypes.func,
onPin: PropTypes.func,
onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func,
onBlock: PropTypes.func,
onAddFilter: PropTypes.func,
onEmbed: PropTypes.func,
onHeightChange: PropTypes.func,
onToggleHidden: PropTypes.func,
Summary: fix slowness due to layout thrashing when reloading a large … (#12661) * Summary: fix slowness due to layout thrashing when reloading a large set of status updates in order to limit the maximum size of a status in a list view (e.g. the home timeline), so as to avoid having to scroll all the way through an abnormally large status update (see https://github.com/tootsuite/mastodon/pull/8205), the following steps are taken: •the element containing the status is rendered in the browser •its height is calculated, to determine if it exceeds the maximum height threshold. Unfortunately for performance, these steps are carried out in the componentDidMount(/Update) method, which also performs style modifications on the element. The combination of height request and style modification during javascript evaluation in the browser leads to layout-thrashing, where the elements are repeatedly re-laid-out (see https://developers.google.com/web/fundamentals/performance/rendering/avoid-large-complex-layouts-and-layout-thrashing & https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Performance_best_practices_for_Firefox_fe_engineers). The solution implemented here is to memoize the collapsed state in Redux the first time the status is seen (e.g. when fetched as part of a small batch, to populate the home timeline) , so that on subsequent re-renders, the value can be queried, rather than recalculated. This strategy is derived from https://github.com/tootsuite/mastodon/pull/4439 & https://github.com/tootsuite/mastodon/pull/4909, and should resolve https://github.com/tootsuite/mastodon/issues/12455. Andrew Lin (https://github.com/onethreeseven) is thanked for his assistance in root cause analysis and solution brainstorming * remove getSnapshotBeforeUpdate from status * remove componentWillUnmount from status * persist last-intersected status update and restore when ScrollableList is restored e.g. when navigating from home-timeline to a status conversational thread and <Back again * cache currently-viewing status id to avoid calling redux with identical value * refactor collapse toggle to pass explicit boolean
2019-12-29 04:39:48 +00:00
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,
scrollKey: PropTypes.string,
deployPictureInPicture: PropTypes.func,
pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
};
// Avoid checking props that are functions (and whose equality will always
// evaluate to false. See react-immutable-pure-component for usage.
updateOnProps = [
'status',
'account',
'muted',
'hidden',
'unread',
'pictureInPicture',
];
state = {
showMedia: defaultMediaVisibility(this.props.status),
statusId: undefined,
Revamp post filtering system (#18058) * Add model for custom filter keywords * Use CustomFilterKeyword internally Does not change the API * Fix /filters/edit and /filters/new * Add migration tests * Remove whole_word column from custom_filters (covered by custom_filter_keywords) * Redesign /filters Instead of a list, present a card that displays more information and handles multiple keywords per filter. * Redesign /filters/new and /filters/edit to add and remove keywords This adds a new gem dependency: cocoon, as well as a npm dependency: cocoon-js-vanilla. Those are used to easily populate and remove form fields from the user interface when manipulating multiple keyword filters at once. * Add /api/v2/filters to edit filter with multiple keywords Entities: - `Filter`: `id`, `title`, `filter_action` (either `hide` or `warn`), `context` `keywords` - `FilterKeyword`: `id`, `keyword`, `whole_word` API endpoits: - `GET /api/v2/filters` to list filters (including keywords) - `POST /api/v2/filters` to create a new filter `keywords_attributes` can also be passed to create keywords in one request - `GET /api/v2/filters/:id` to read a particular filter - `PUT /api/v2/filters/:id` to update a new filter `keywords_attributes` can also be passed to edit, delete or add keywords in one request - `DELETE /api/v2/filters/:id` to delete a particular filter - `GET /api/v2/filters/:id/keywords` to list keywords for a filter - `POST /api/v2/filters/:filter_id/keywords/:id` to add a new keyword to a filter - `GET /api/v2/filter_keywords/:id` to read a particular keyword - `PUT /api/v2/filter_keywords/:id` to edit a particular keyword - `DELETE /api/v2/filter_keywords/:id` to delete a particular keyword * Change from `irreversible` boolean to `action` enum * Remove irrelevent `irreversible_must_be_within_context` check * Fix /filters/new and /filters/edit with update for filter_action * Fix Rubocop/Codeclimate complaining about task names * Refactor FeedManager#phrase_filtered? This moves regexp building and filter caching to the `CustomFilter` class. This does not change the functional behavior yet, but this changes how the cache is built, doing per-custom_filter regexps so that filters can be matched independently, while still offering caching. * Perform server-side filtering and output result in REST API * Fix numerous filters_changed events being sent when editing multiple keywords at once * Add some tests * Use the new API in the WebUI - use client-side logic for filters we have fetched rules for. This is so that filter changes can be retroactively applied without reloading the UI. - use server-side logic for filters we haven't fetched rules for yet (e.g. network error, or initial timeline loading) * Minor optimizations and refactoring * Perform server-side filtering on the streaming server * Change the wording of filter action labels * Fix issues pointed out by linter * Change design of “Show anyway” link in accordence to review comments * Drop “irreversible” filtering behavior * Move /api/v2/filter_keywords to /api/v1/filters/keywords * Rename `filter_results` attribute to `filtered` * Rename REST::LegacyFilterSerializer to REST::V1::FilterSerializer * Fix systemChannelId value in streaming server * Simplify code by removing client-side filtering code The simplifcation comes at a cost though: filters aren't retroactively applied anymore.
2022-06-28 07:42:13 +00:00
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 });
2023-01-30 00:45:35 +00:00
};
handleClick = e => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
if (e) {
e.preventDefault();
}
this.handleHotkeyOpen();
2023-01-30 00:45:35 +00:00
};
handlePrependAccountClick = e => {
this.handleAccountClick(e, false);
2023-01-30 00:45:35 +00:00
};
handleAccountClick = (e, proper = true) => {
if (e && (e.button !== 0 || e.ctrlKey || e.metaKey)) {
return;
}
if (e) {
e.preventDefault();
e.stopPropagation();
}
this._openProfile(proper);
2023-01-30 00:45:35 +00:00
};
handleExpandedToggle = () => {
this.props.onToggleHidden(this._properStatus());
2023-01-30 00:45:35 +00:00
};
Summary: fix slowness due to layout thrashing when reloading a large … (#12661) * Summary: fix slowness due to layout thrashing when reloading a large set of status updates in order to limit the maximum size of a status in a list view (e.g. the home timeline), so as to avoid having to scroll all the way through an abnormally large status update (see https://github.com/tootsuite/mastodon/pull/8205), the following steps are taken: •the element containing the status is rendered in the browser •its height is calculated, to determine if it exceeds the maximum height threshold. Unfortunately for performance, these steps are carried out in the componentDidMount(/Update) method, which also performs style modifications on the element. The combination of height request and style modification during javascript evaluation in the browser leads to layout-thrashing, where the elements are repeatedly re-laid-out (see https://developers.google.com/web/fundamentals/performance/rendering/avoid-large-complex-layouts-and-layout-thrashing & https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Performance_best_practices_for_Firefox_fe_engineers). The solution implemented here is to memoize the collapsed state in Redux the first time the status is seen (e.g. when fetched as part of a small batch, to populate the home timeline) , so that on subsequent re-renders, the value can be queried, rather than recalculated. This strategy is derived from https://github.com/tootsuite/mastodon/pull/4439 & https://github.com/tootsuite/mastodon/pull/4909, and should resolve https://github.com/tootsuite/mastodon/issues/12455. Andrew Lin (https://github.com/onethreeseven) is thanked for his assistance in root cause analysis and solution brainstorming * remove getSnapshotBeforeUpdate from status * remove componentWillUnmount from status * persist last-intersected status update and restore when ScrollableList is restored e.g. when navigating from home-timeline to a status conversational thread and <Back again * cache currently-viewing status id to avoid calling redux with identical value * refactor collapse toggle to pass explicit boolean
2019-12-29 04:39:48 +00:00
handleCollapsedToggle = isCollapsed => {
this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
2023-01-30 00:45:35 +00:00
};
handleTranslate = () => {
this.props.onTranslate(this._properStatus());
2023-01-30 00:45:35 +00:00
};
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'
}
2019-08-23 20:38:02 +00:00
}
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);
2023-01-30 00:45:35 +00:00
};
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);
2023-01-30 00:45:35 +00:00
};
handleHotkeyOpenMedia = e => {
2019-12-04 23:50:51 +00:00
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);
}
}
2023-01-30 00:45:35 +00:00
};
handleDeployPictureInPicture = (type, mediaProps) => {
const { deployPictureInPicture } = this.props;
const status = this._properStatus();
deployPictureInPicture(status, type, mediaProps);
2023-01-30 00:45:35 +00:00
};
handleHotkeyReply = e => {
e.preventDefault();
this.props.onReply(this._properStatus(), this.context.router.history);
2023-01-30 00:45:35 +00:00
};
handleHotkeyFavourite = () => {
this.props.onFavourite(this._properStatus());
2023-01-30 00:45:35 +00:00
};
handleHotkeyBoost = e => {
this.props.onReblog(this._properStatus(), e);
2023-01-30 00:45:35 +00:00
};
handleHotkeyMention = e => {
e.preventDefault();
this.props.onMention(this._properStatus().get('account'), this.context.router.history);
2023-01-30 00:45:35 +00:00
};
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')}`);
2023-01-30 00:45:35 +00:00
};
handleHotkeyOpenProfile = () => {
this._openProfile();
2023-01-30 00:45:35 +00:00
};
_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'])}`);
2023-01-30 00:45:35 +00:00
};
handleHotkeyMoveUp = e => {
this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured'));
2023-01-30 00:45:35 +00:00
};
handleHotkeyMoveDown = e => {
this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured'));
2023-01-30 00:45:35 +00:00
};
handleHotkeyToggleHidden = () => {
this.props.onToggleHidden(this._properStatus());
2023-01-30 00:45:35 +00:00
};
handleHotkeyToggleSensitive = () => {
this.handleToggleMediaVisibility();
2023-01-30 00:45:35 +00:00
};
Revamp post filtering system (#18058) * Add model for custom filter keywords * Use CustomFilterKeyword internally Does not change the API * Fix /filters/edit and /filters/new * Add migration tests * Remove whole_word column from custom_filters (covered by custom_filter_keywords) * Redesign /filters Instead of a list, present a card that displays more information and handles multiple keywords per filter. * Redesign /filters/new and /filters/edit to add and remove keywords This adds a new gem dependency: cocoon, as well as a npm dependency: cocoon-js-vanilla. Those are used to easily populate and remove form fields from the user interface when manipulating multiple keyword filters at once. * Add /api/v2/filters to edit filter with multiple keywords Entities: - `Filter`: `id`, `title`, `filter_action` (either `hide` or `warn`), `context` `keywords` - `FilterKeyword`: `id`, `keyword`, `whole_word` API endpoits: - `GET /api/v2/filters` to list filters (including keywords) - `POST /api/v2/filters` to create a new filter `keywords_attributes` can also be passed to create keywords in one request - `GET /api/v2/filters/:id` to read a particular filter - `PUT /api/v2/filters/:id` to update a new filter `keywords_attributes` can also be passed to edit, delete or add keywords in one request - `DELETE /api/v2/filters/:id` to delete a particular filter - `GET /api/v2/filters/:id/keywords` to list keywords for a filter - `POST /api/v2/filters/:filter_id/keywords/:id` to add a new keyword to a filter - `GET /api/v2/filter_keywords/:id` to read a particular keyword - `PUT /api/v2/filter_keywords/:id` to edit a particular keyword - `DELETE /api/v2/filter_keywords/:id` to delete a particular keyword * Change from `irreversible` boolean to `action` enum * Remove irrelevent `irreversible_must_be_within_context` check * Fix /filters/new and /filters/edit with update for filter_action * Fix Rubocop/Codeclimate complaining about task names * Refactor FeedManager#phrase_filtered? This moves regexp building and filter caching to the `CustomFilter` class. This does not change the functional behavior yet, but this changes how the cache is built, doing per-custom_filter regexps so that filters can be matched independently, while still offering caching. * Perform server-side filtering and output result in REST API * Fix numerous filters_changed events being sent when editing multiple keywords at once * Add some tests * Use the new API in the WebUI - use client-side logic for filters we have fetched rules for. This is so that filter changes can be retroactively applied without reloading the UI. - use server-side logic for filters we haven't fetched rules for yet (e.g. network error, or initial timeline loading) * Minor optimizations and refactoring * Perform server-side filtering on the streaming server * Change the wording of filter action labels * Fix issues pointed out by linter * Change design of “Show anyway” link in accordence to review comments * Drop “irreversible” filtering behavior * Move /api/v2/filter_keywords to /api/v1/filters/keywords * Rename `filter_results` attribute to `filtered` * Rename REST::LegacyFilterSerializer to REST::V1::FilterSerializer * Fix systemChannelId value in streaming server * Simplify code by removing client-side filtering code The simplifcation comes at a cost though: filters aren't retroactively applied anymore.
2022-06-28 07:42:13 +00:00
handleUnfilterClick = e => {
this.setState({ forceFilter: false });
e.preventDefault();
2023-01-30 00:45:35 +00:00
};
Revamp post filtering system (#18058) * Add model for custom filter keywords * Use CustomFilterKeyword internally Does not change the API * Fix /filters/edit and /filters/new * Add migration tests * Remove whole_word column from custom_filters (covered by custom_filter_keywords) * Redesign /filters Instead of a list, present a card that displays more information and handles multiple keywords per filter. * Redesign /filters/new and /filters/edit to add and remove keywords This adds a new gem dependency: cocoon, as well as a npm dependency: cocoon-js-vanilla. Those are used to easily populate and remove form fields from the user interface when manipulating multiple keyword filters at once. * Add /api/v2/filters to edit filter with multiple keywords Entities: - `Filter`: `id`, `title`, `filter_action` (either `hide` or `warn`), `context` `keywords` - `FilterKeyword`: `id`, `keyword`, `whole_word` API endpoits: - `GET /api/v2/filters` to list filters (including keywords) - `POST /api/v2/filters` to create a new filter `keywords_attributes` can also be passed to create keywords in one request - `GET /api/v2/filters/:id` to read a particular filter - `PUT /api/v2/filters/:id` to update a new filter `keywords_attributes` can also be passed to edit, delete or add keywords in one request - `DELETE /api/v2/filters/:id` to delete a particular filter - `GET /api/v2/filters/:id/keywords` to list keywords for a filter - `POST /api/v2/filters/:filter_id/keywords/:id` to add a new keyword to a filter - `GET /api/v2/filter_keywords/:id` to read a particular keyword - `PUT /api/v2/filter_keywords/:id` to edit a particular keyword - `DELETE /api/v2/filter_keywords/:id` to delete a particular keyword * Change from `irreversible` boolean to `action` enum * Remove irrelevent `irreversible_must_be_within_context` check * Fix /filters/new and /filters/edit with update for filter_action * Fix Rubocop/Codeclimate complaining about task names * Refactor FeedManager#phrase_filtered? This moves regexp building and filter caching to the `CustomFilter` class. This does not change the functional behavior yet, but this changes how the cache is built, doing per-custom_filter regexps so that filters can be matched independently, while still offering caching. * Perform server-side filtering and output result in REST API * Fix numerous filters_changed events being sent when editing multiple keywords at once * Add some tests * Use the new API in the WebUI - use client-side logic for filters we have fetched rules for. This is so that filter changes can be retroactively applied without reloading the UI. - use server-side logic for filters we haven't fetched rules for yet (e.g. network error, or initial timeline loading) * Minor optimizations and refactoring * Perform server-side filtering on the streaming server * Change the wording of filter action labels * Fix issues pointed out by linter * Change design of “Show anyway” link in accordence to review comments * Drop “irreversible” filtering behavior * Move /api/v2/filter_keywords to /api/v1/filters/keywords * Rename `filter_results` attribute to `filtered` * Rename REST::LegacyFilterSerializer to REST::V1::FilterSerializer * Fix systemChannelId value in streaming server * Simplify code by removing client-side filtering code The simplifcation comes at a cost though: filters aren't retroactively applied anymore.
2022-06-28 07:42:13 +00:00
handleFilterClick = () => {
this.setState({ forceFilter: true });
2023-01-30 00:45:35 +00:00
};
Revamp post filtering system (#18058) * Add model for custom filter keywords * Use CustomFilterKeyword internally Does not change the API * Fix /filters/edit and /filters/new * Add migration tests * Remove whole_word column from custom_filters (covered by custom_filter_keywords) * Redesign /filters Instead of a list, present a card that displays more information and handles multiple keywords per filter. * Redesign /filters/new and /filters/edit to add and remove keywords This adds a new gem dependency: cocoon, as well as a npm dependency: cocoon-js-vanilla. Those are used to easily populate and remove form fields from the user interface when manipulating multiple keyword filters at once. * Add /api/v2/filters to edit filter with multiple keywords Entities: - `Filter`: `id`, `title`, `filter_action` (either `hide` or `warn`), `context` `keywords` - `FilterKeyword`: `id`, `keyword`, `whole_word` API endpoits: - `GET /api/v2/filters` to list filters (including keywords) - `POST /api/v2/filters` to create a new filter `keywords_attributes` can also be passed to create keywords in one request - `GET /api/v2/filters/:id` to read a particular filter - `PUT /api/v2/filters/:id` to update a new filter `keywords_attributes` can also be passed to edit, delete or add keywords in one request - `DELETE /api/v2/filters/:id` to delete a particular filter - `GET /api/v2/filters/:id/keywords` to list keywords for a filter - `POST /api/v2/filters/:filter_id/keywords/:id` to add a new keyword to a filter - `GET /api/v2/filter_keywords/:id` to read a particular keyword - `PUT /api/v2/filter_keywords/:id` to edit a particular keyword - `DELETE /api/v2/filter_keywords/:id` to delete a particular keyword * Change from `irreversible` boolean to `action` enum * Remove irrelevent `irreversible_must_be_within_context` check * Fix /filters/new and /filters/edit with update for filter_action * Fix Rubocop/Codeclimate complaining about task names * Refactor FeedManager#phrase_filtered? This moves regexp building and filter caching to the `CustomFilter` class. This does not change the functional behavior yet, but this changes how the cache is built, doing per-custom_filter regexps so that filters can be matched independently, while still offering caching. * Perform server-side filtering and output result in REST API * Fix numerous filters_changed events being sent when editing multiple keywords at once * Add some tests * Use the new API in the WebUI - use client-side logic for filters we have fetched rules for. This is so that filter changes can be retroactively applied without reloading the UI. - use server-side logic for filters we haven't fetched rules for yet (e.g. network error, or initial timeline loading) * Minor optimizations and refactoring * Perform server-side filtering on the streaming server * Change the wording of filter action labels * Fix issues pointed out by linter * Change design of “Show anyway” link in accordence to review comments * Drop “irreversible” filtering behavior * Move /api/v2/filter_keywords to /api/v1/filters/keywords * Rename `filter_results` attribute to `filtered` * Rename REST::LegacyFilterSerializer to REST::V1::FilterSerializer * Fix systemChannelId value in streaming server * Simplify code by removing client-side filtering code The simplifcation comes at a cost though: filters aren't retroactively applied anymore.
2022-06-28 07:42:13 +00:00
_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;
2023-01-30 00:45:35 +00:00
};
2016-08-24 19:08:00 +00:00
render () {
const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props;
let { status, account, ...other } = this.props;
if (status === null) {
return null;
}
const handlers = this.props.muted ? {} : {
reply: this.handleHotkeyReply,
favourite: this.handleHotkeyFavourite,
boost: this.handleHotkeyBoost,
mention: this.handleHotkeyMention,
open: this.handleHotkeyOpen,
openProfile: this.handleHotkeyOpenProfile,
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
openMedia: this.handleHotkeyOpenMedia,
};
let media, statusAvatar, 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');
const matchedFilters = status.get('matched_filters');
Revamp post filtering system (#18058) * Add model for custom filter keywords * Use CustomFilterKeyword internally Does not change the API * Fix /filters/edit and /filters/new * Add migration tests * Remove whole_word column from custom_filters (covered by custom_filter_keywords) * Redesign /filters Instead of a list, present a card that displays more information and handles multiple keywords per filter. * Redesign /filters/new and /filters/edit to add and remove keywords This adds a new gem dependency: cocoon, as well as a npm dependency: cocoon-js-vanilla. Those are used to easily populate and remove form fields from the user interface when manipulating multiple keyword filters at once. * Add /api/v2/filters to edit filter with multiple keywords Entities: - `Filter`: `id`, `title`, `filter_action` (either `hide` or `warn`), `context` `keywords` - `FilterKeyword`: `id`, `keyword`, `whole_word` API endpoits: - `GET /api/v2/filters` to list filters (including keywords) - `POST /api/v2/filters` to create a new filter `keywords_attributes` can also be passed to create keywords in one request - `GET /api/v2/filters/:id` to read a particular filter - `PUT /api/v2/filters/:id` to update a new filter `keywords_attributes` can also be passed to edit, delete or add keywords in one request - `DELETE /api/v2/filters/:id` to delete a particular filter - `GET /api/v2/filters/:id/keywords` to list keywords for a filter - `POST /api/v2/filters/:filter_id/keywords/:id` to add a new keyword to a filter - `GET /api/v2/filter_keywords/:id` to read a particular keyword - `PUT /api/v2/filter_keywords/:id` to edit a particular keyword - `DELETE /api/v2/filter_keywords/:id` to delete a particular keyword * Change from `irreversible` boolean to `action` enum * Remove irrelevent `irreversible_must_be_within_context` check * Fix /filters/new and /filters/edit with update for filter_action * Fix Rubocop/Codeclimate complaining about task names * Refactor FeedManager#phrase_filtered? This moves regexp building and filter caching to the `CustomFilter` class. This does not change the functional behavior yet, but this changes how the cache is built, doing per-custom_filter regexps so that filters can be matched independently, while still offering caching. * Perform server-side filtering and output result in REST API * Fix numerous filters_changed events being sent when editing multiple keywords at once * Add some tests * Use the new API in the WebUI - use client-side logic for filters we have fetched rules for. This is so that filter changes can be retroactively applied without reloading the UI. - use server-side logic for filters we haven't fetched rules for yet (e.g. network error, or initial timeline loading) * Minor optimizations and refactoring * Perform server-side filtering on the streaming server * Change the wording of filter action labels * Fix issues pointed out by linter * Change design of “Show anyway” link in accordence to review comments * Drop “irreversible” filtering behavior * Move /api/v2/filter_keywords to /api/v1/filters/keywords * Rename `filter_results` attribute to `filtered` * Rename REST::LegacyFilterSerializer to REST::V1::FilterSerializer * Fix systemChannelId value in streaming server * Simplify code by removing client-side filtering code The simplifcation comes at a cost though: filters aren't retroactively applied anymore.
2022-06-28 07:42:13 +00:00
if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
const minHandlers = this.props.muted ? {} : {
moveUp: this.handleHotkeyMoveUp,
moveDown: this.handleHotkeyMoveDown,
};
return (
<HotKeys handlers={minHandlers}>
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef}>
Revamp post filtering system (#18058) * Add model for custom filter keywords * Use CustomFilterKeyword internally Does not change the API * Fix /filters/edit and /filters/new * Add migration tests * Remove whole_word column from custom_filters (covered by custom_filter_keywords) * Redesign /filters Instead of a list, present a card that displays more information and handles multiple keywords per filter. * Redesign /filters/new and /filters/edit to add and remove keywords This adds a new gem dependency: cocoon, as well as a npm dependency: cocoon-js-vanilla. Those are used to easily populate and remove form fields from the user interface when manipulating multiple keyword filters at once. * Add /api/v2/filters to edit filter with multiple keywords Entities: - `Filter`: `id`, `title`, `filter_action` (either `hide` or `warn`), `context` `keywords` - `FilterKeyword`: `id`, `keyword`, `whole_word` API endpoits: - `GET /api/v2/filters` to list filters (including keywords) - `POST /api/v2/filters` to create a new filter `keywords_attributes` can also be passed to create keywords in one request - `GET /api/v2/filters/:id` to read a particular filter - `PUT /api/v2/filters/:id` to update a new filter `keywords_attributes` can also be passed to edit, delete or add keywords in one request - `DELETE /api/v2/filters/:id` to delete a particular filter - `GET /api/v2/filters/:id/keywords` to list keywords for a filter - `POST /api/v2/filters/:filter_id/keywords/:id` to add a new keyword to a filter - `GET /api/v2/filter_keywords/:id` to read a particular keyword - `PUT /api/v2/filter_keywords/:id` to edit a particular keyword - `DELETE /api/v2/filter_keywords/:id` to delete a particular keyword * Change from `irreversible` boolean to `action` enum * Remove irrelevent `irreversible_must_be_within_context` check * Fix /filters/new and /filters/edit with update for filter_action * Fix Rubocop/Codeclimate complaining about task names * Refactor FeedManager#phrase_filtered? This moves regexp building and filter caching to the `CustomFilter` class. This does not change the functional behavior yet, but this changes how the cache is built, doing per-custom_filter regexps so that filters can be matched independently, while still offering caching. * Perform server-side filtering and output result in REST API * Fix numerous filters_changed events being sent when editing multiple keywords at once * Add some tests * Use the new API in the WebUI - use client-side logic for filters we have fetched rules for. This is so that filter changes can be retroactively applied without reloading the UI. - use server-side logic for filters we haven't fetched rules for yet (e.g. network error, or initial timeline loading) * Minor optimizations and refactoring * Perform server-side filtering on the streaming server * Change the wording of filter action labels * Fix issues pointed out by linter * Change design of “Show anyway” link in accordence to review comments * Drop “irreversible” filtering behavior * Move /api/v2/filter_keywords to /api/v1/filters/keywords * Rename `filter_results` attribute to `filtered` * Rename REST::LegacyFilterSerializer to REST::V1::FilterSerializer * Fix systemChannelId value in streaming server * Simplify code by removing client-side filtering code The simplifcation comes at a cost though: filters aren't retroactively applied anymore.
2022-06-28 07:42:13 +00:00
<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>
</HotKeys>
);
}
if (featured) {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='thumb-tack' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
</div>
);
} else if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='retweet' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' 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>
2016-09-01 12:12:11 +00:00
);
rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: status.getIn(['account', 'acct']) });
account = status.get('account');
status = status.get('reblog');
} else if (status.get('visibility') === 'direct') {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='at' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
</div>
);
} else 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>
);
2016-09-01 12:12:11 +00:00
}
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder aspectRatio={this.getAttachmentAspectRatio()} />;
} else if (status.get('media_attachments').size > 0) {
const language = status.getIn(['translation', 'language']) || status.get('language');
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
2019-08-23 20:38:02 +00:00
const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
2019-08-23 20:38:02 +00:00
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'])}
2019-08-23 20:38:02 +00:00
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
width={this.props.cachedMediaWidth}
height={110}
cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
sensitive={status.get('sensitive')}
blurhash={attachment.get('blurhash')}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
2019-08-23 20:38:02 +00:00
/>
)}
</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} >
2018-01-17 15:57:15 +00:00
{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}
2018-01-17 15:57:15 +00:00
sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
2018-01-17 15:57:15 +00:00
/>
)}
</Bundle>
);
2016-09-17 16:05:02 +00:00
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
{Component => (
<Component
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>
);
2016-09-17 16:05:02 +00:00
}
} else if (status.get('spoiler_text').length === 0 && status.get('card')) {
media = (
<Card
onOpenMedia={this.handleOpenMedia}
card={status.get('card')}
compact
sensitive={status.get('sensitive')}
/>
);
}
if (account === undefined || account === null) {
statusAvatar = <Avatar account={status.get('account')} size={46} />;
} else {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
}
const visibilityIconInfo = {
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
};
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { '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}>
{prepend}
2016-08-24 19:08:00 +00:00
<div className={classNames('status', `status-${status.get('visibility')}`, { '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'>
<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>
2016-08-24 19:08:00 +00:00
<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>
2016-08-31 20:58:10 +00:00
<DisplayName account={status.get('account')} />
</a>
</div>
<StatusContent
status={status}
onClick={this.handleClick}
expanded={!status.get('hidden')}
onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate}
collapsible
onCollapsedToggle={this.handleCollapsedToggle}
/>
2016-08-24 19:08:00 +00:00
{media}
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
</div>
</div>
</HotKeys>
);
}
}
export default injectIntl(Status);