diff --git a/app/controllers/api/v1/filters_controller.rb b/app/controllers/api/v1/filters_controller.rb index f8d91c5f7f..9a2e0d5d17 100644 --- a/app/controllers/api/v1/filters_controller.rb +++ b/app/controllers/api/v1/filters_controller.rb @@ -56,11 +56,11 @@ class Api::V1::FiltersController < Api::BaseController end def resource_params - params.permit(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :with_profile, :whole_word, context: []) + params.permit(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_profile, :whole_word, context: []) end def filter_params - resource_params.slice(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_quote, :with_profile, :context) + resource_params.slice(:phrase, :expires_in, :irreversible, :exclude_follows, :exclude_localusers, :with_profile, :context) end def keyword_params diff --git a/app/controllers/api/v2/filters_controller.rb b/app/controllers/api/v2/filters_controller.rb index 51e778e1d6..fda4c0c215 100644 --- a/app/controllers/api/v2/filters_controller.rb +++ b/app/controllers/api/v2/filters_controller.rb @@ -43,6 +43,6 @@ class Api::V2::FiltersController < Api::BaseController end def resource_params - params.permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :with_quote, :with_profile, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) + params.permit(:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :with_profile, context: [], keywords_attributes: [:id, :keyword, :whole_word, :_destroy]) end end diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb index 20b8135908..dc47bd77ec 100644 --- a/app/controllers/filters_controller.rb +++ b/app/controllers/filters_controller.rb @@ -47,6 +47,6 @@ class FiltersController < ApplicationController end def resource_params - params.expect(custom_filter: [:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :exclude_quote, :exclude_profile, context: [], keywords_attributes: [[:id, :keyword, :whole_word, :_destroy]]]) + params.expect(custom_filter: [:title, :expires_in, :filter_action, :exclude_follows, :exclude_localusers, :exclude_profile, context: [], keywords_attributes: [[:id, :keyword, :whole_word, :_destroy]]]) end end diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index 077c5272a5..cb8fb46466 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -29,7 +29,6 @@ module ContextHelper limited_scope: { 'kmyblue' => 'http://kmy.blue/ns#', 'limitedScope' => 'kmyblue:limitedScope' }, other_setting: { 'fedibird' => 'http://fedibird.com/ns#', 'otherSetting' => 'fedibird:otherSetting' }, references: { 'fedibird' => 'http://fedibird.com/ns#', 'references' => { '@id' => 'fedibird:references', '@type' => '@id' } }, - quote_uri: { 'fedibird' => 'http://fedibird.com/ns#', 'quoteUri' => 'fedibird:quoteUri' }, keywords: { 'schema' => 'http://schema.org#', 'keywords' => 'schema:keywords' }, license: { 'schema' => 'http://schema.org#', 'license' => 'schema:license' }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, diff --git a/app/helpers/kmyblue_capabilities_helper.rb b/app/helpers/kmyblue_capabilities_helper.rb index 279505bec8..39669c0680 100644 --- a/app/helpers/kmyblue_capabilities_helper.rb +++ b/app/helpers/kmyblue_capabilities_helper.rb @@ -15,7 +15,6 @@ module KmyblueCapabilitiesHelper kmyblue_limited_scope kmyblue_antenna kmyblue_bookmark_category - kmyblue_quote kmyblue_searchability_limited kmyblue_circle_history kmyblue_list_notification @@ -41,7 +40,6 @@ module KmyblueCapabilitiesHelper capabilities = %i( enable_wide_emoji status_reference - quote emoji_keywords circle ) diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index fc165b1a1f..a527043940 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -70,10 +70,6 @@ export function importFetchedStatuses(statuses) { processStatus(status.reblog); } - if (status.quote?.id && !getState().getIn(['statuses', status.id])) { - processStatus(status.quote); - } - if (status.poll?.id) { pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls[status.poll.id])); } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index b643cf5613..93fc3b9fef 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -66,11 +66,6 @@ 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'); } diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 87b842e51f..cc258db307 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -33,7 +33,7 @@ const messages = defineMessages({ message_poll: { id: 'notification.poll', defaultMessage: 'A poll you voted in has ended' }, message_reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your post' }, message_status: { id: 'notification.status', defaultMessage: '{name} just posted' }, - message_status_reference: { id: 'notification.status_reference', defaultMessage: '{name} quoted your post' }, + message_status_reference: { id: 'notification.status_reference', defaultMessage: '{name} linked your post' }, message_update: { id: 'notification.update', defaultMessage: '{name} edited a post' }, }); diff --git a/app/javascript/mastodon/api_types/accounts.ts b/app/javascript/mastodon/api_types/accounts.ts index 9d7974eda0..a2a6dfdf28 100644 --- a/app/javascript/mastodon/api_types/accounts.ts +++ b/app/javascript/mastodon/api_types/accounts.ts @@ -20,7 +20,6 @@ export interface ApiAccountOtherSettingsJSON { hide_followers_count: boolean; translatable_private: boolean; link_preview: boolean; - allow_quote: boolean; emoji_reaction_policy: | 'allow' | 'outside_only' @@ -34,7 +33,6 @@ export interface ApiAccountOtherSettingsJSON { export interface ApiServerFeaturesJSON { circle: boolean; emoji_reaction: boolean; - quote: boolean; status_reference: boolean; } diff --git a/app/javascript/mastodon/components/compacted_status.jsx b/app/javascript/mastodon/components/compacted_status.jsx deleted file mode 100644 index 6986bcd34c..0000000000 --- a/app/javascript/mastodon/components/compacted_status.jsx +++ /dev/null @@ -1,580 +0,0 @@ -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 AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; -import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react'; -import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; -import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; -import AttachmentList from 'mastodon/components/attachment_list'; -import { ContentWarning } from 'mastodon/components/content_warning'; -import { Icon } from 'mastodon/components/icon'; -import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; -import { withOptionalRouter, WithOptionalRouterPropTypes } from 'mastodon/utils/react_router'; - -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 { SensitiveMediaContext } from '../features/ui/util/sensitive_media_context'; -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'; -import { VisibilityIcon } from './visibility_icon'; - -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({ - limited_short: { id: 'privacy.limited.short', defaultMessage: 'Limited' }, - edited: { id: 'status.edited', defaultMessage: 'Edited {date}' }, -}); - -class CompactedStatus extends ImmutablePureComponent { - - static contextType = SensitiveMediaContext; - - static propTypes = { - status: ImmutablePropTypes.map, - account: ImmutablePropTypes.record, - previousId: PropTypes.string, - nextInReplyToId: PropTypes.string, - rootId: PropTypes.string, - onClick: PropTypes.func, - onReply: PropTypes.func, - onFavourite: PropTypes.func, - onEmojiReact: PropTypes.func, - onUnEmojiReact: PropTypes.func, - onReblog: PropTypes.func, - onReblogForceModal: 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, - 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, - skipPrepend: PropTypes.bool, - deployPictureInPicture: PropTypes.func, - unfocusable: PropTypes.bool, - pictureInPicture: ImmutablePropTypes.contains({ - inUse: PropTypes.bool, - available: PropTypes.bool, - }), - withoutEmojiReactions: PropTypes.bool, - ...WithOptionalRouterPropTypes, - }; - - // 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) && !(this.context?.hideMediaByDefault), - }; - - componentDidUpdate (prevProps) { - // This will potentially cause a wasteful redraw, but in most cases `Status` components are used - // with a `key` directly depending on their `id`, preventing re-use of the component across - // different IDs. - // But just in case this does change, reset the state on status change. - - if (this.props.status?.get('id') !== prevProps.status?.get('id')) { - this.setState({ - showMedia: defaultMediaVisibility(this.props.status) && !(this.context?.hideMediaByDefault), - }); - } - } - - 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 ( -
- ); - }; - - renderLoadingVideoPlayer = () => { - return ( - - ); - }; - - renderLoadingAudioPlayer = () => { - return ( - - ); - }; - - 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); - } - } - }; - - handleDeployPictureInPicture = (type, mediaProps) => { - const { deployPictureInPicture } = this.props; - const status = this._properStatus(); - - deployPictureInPicture(status, type, mediaProps); - }; - - handleHotkeyReply = e => { - e.preventDefault(); - this.props.onReply(this._properStatus()); - }; - - handleHotkeyFavourite = () => { - this.props.onFavourite(this._properStatus()); - }; - - handleHotkeyBoost = e => { - this.props.onReblog(this._properStatus(), e); - }; - - handleHotkeyMention = e => { - e.preventDefault(); - this.props.onMention(this._properStatus().get('account')); - }; - - handleHotkeyOpen = () => { - if (this.props.onClick) { - this.props.onClick(); - return; - } - - const { history } = this.props; - const status = this._properStatus(); - - if (!history) { - return; - } - - history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`); - }; - - handleHotkeyOpenProfile = () => { - this._openProfile(); - }; - - _openProfile = (proper = true) => { - const { history } = this.props; - const status = proper ? this._properStatus() : this.props.status; - - if (!history) { - return; - } - - 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 = () => { - const { onToggleHidden } = this.props; - const status = this._properStatus(); - - onToggleHidden(status); - }; - - 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, unfocusable, unread, showThread, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend } = this.props; - - let { status } = 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, prepend, rebloggedByText; - - if (hidden) { - return ( -/g, '\n\n');
- const searchableTextContent = JSDOM.fragment(searchableContent).textContent;
- const searchableAccountContent = JSDOM.fragment([status.account.display_name, status.account.note].join('\n\n')).textContent;
+ if (req.cachedFilters) {
+ const status = payload;
+ // TODO: Calculate searchableContent in Ruby on Rails:
+ // @ts-ignore
+ const searchableContent = [
+ status.spoiler_text || '',
+ status.content,
+ ...(reference_texts || []),
+ ]
+ .concat(
+ status.poll && status.poll.options
+ ? status.poll.options.map((option) => option.title)
+ : [],
+ )
+ .concat(status.media_attachments.map((att) => att.description))
+ .join('\n\n')
+ .replace(/
/g, '\n')
+ .replace(/<\/p>
/g, '\n\n'); + const searchableTextContent = + JSDOM.fragment(searchableContent).textContent; + const searchableAccountContent = JSDOM.fragment( + [status.account.display_name, status.account.note].join('\n\n'), + ).textContent; - const now = new Date(); - // @ts-ignore - const filter_results = Object.values(req.cachedFilters).reduce((results, cachedFilter) => { - // Check the filter hasn't expired before applying: - if (cachedFilter.expires_at !== null && cachedFilter.expires_at < now) { - return results; - } + const now = new Date(); + // @ts-ignore + const filter_results = Object.values(req.cachedFilters).reduce( + (results, cachedFilter) => { + // Check the filter hasn't expired before applying: + if ( + cachedFilter.expires_at !== null && + cachedFilter.expires_at < now + ) { + return results; + } - if (cachedFilter.filter && cachedFilter.filter.excludeFollows && following) { - return results; - } + if ( + cachedFilter.filter && + cachedFilter.filter.excludeFollows && + following + ) { + return results; + } - if (cachedFilter.filter && cachedFilter.filter.excludeLocalusers && !accountDomain) { - return results; - } + if ( + cachedFilter.filter && + cachedFilter.filter.excludeLocalusers && + !accountDomain + ) { + return results; + } - // Just in-case JSDOM fails to find textContent in searchableContent - if (!searchableTextContent) { - return results; - } + // Just in-case JSDOM fails to find textContent in searchableContent + if (!searchableTextContent) { + return results; + } - const keyword_matches = searchableTextContent.match(cachedFilter.regexp) || - ((cachedFilter.withAccountName && searchableAccountContent) ? searchableAccountContent.match(cachedFilter.regexp) : null); - if (keyword_matches) { - // results is an Array of FilterResult; status_matches is always - // null as we only are only applying the keyword-based custom - // filters, not the status-based custom filters. - // https://docs.joinmastodon.org/entities/FilterResult/ - results.push({ - filter: cachedFilter.filter, - keyword_matches, - status_matches: null - }); - } + const keyword_matches = + searchableTextContent.match(cachedFilter.regexp) || + (cachedFilter.withAccountName && searchableAccountContent + ? searchableAccountContent.match(cachedFilter.regexp) + : null); + if (keyword_matches) { + // results is an Array of FilterResult; status_matches is always + // null as we only are only applying the keyword-based custom + // filters, not the status-based custom filters. + // https://docs.joinmastodon.org/entities/FilterResult/ + results.push({ + filter: cachedFilter.filter, + keyword_matches, + status_matches: null, + }); + } - return results; - }, []); + return results; + }, + [], + ); - // Send the payload + the FilterResults as the `filtered` property - // to the streaming connection. To reach this code, the `event` must - // have been either `update` or `status.update`, meaning the - // `payload` is a Status entity, which has a `filtered` property: - // - // filtered: https://docs.joinmastodon.org/entities/Status/#filtered - transmit(event, { - ...payload, - filtered: filter_results - }); - } else { - transmit(event, payload); - } - }).catch(err => { - log.error(err); - releasePgConnection(); - }); + // Send the payload + the FilterResults as the `filtered` property + // to the streaming connection. To reach this code, the `event` must + // have been either `update` or `status.update`, meaning the + // `payload` is a Status entity, which has a `filtered` property: + // + // filtered: https://docs.joinmastodon.org/entities/Status/#filtered + transmit(event, { + ...payload, + filtered: filter_results, + }); + } else { + transmit(event, payload); + } + }) + .catch((err) => { + log.error(err); + releasePgConnection(); + }); }); }; - channelIds.forEach(id => { + channelIds.forEach((id) => { subscribe(id, listener); }); @@ -914,7 +1081,9 @@ const startServer = async () => { // In theory we'll always have a channel name, but channelNameFromPath can return undefined: if (typeof channelName === 'string') { - metrics.connectedChannels.labels({ type: 'eventsource', channel: channelName }).inc(); + metrics.connectedChannels + .labels({ type: 'eventsource', channel: channelName }) + .inc(); } res.setHeader('Content-Type', 'text/event-stream'); @@ -933,7 +1102,9 @@ const startServer = async () => { metrics.connectedClients.labels({ type: 'eventsource' }).dec(); // In theory we'll always have a channel name, but channelNameFromPath can return undefined: if (typeof channelName === 'string') { - metrics.connectedChannels.labels({ type: 'eventsource', channel: channelName }).dec(); + metrics.connectedChannels + .labels({ type: 'eventsource', channel: channelName }) + .dec(); } clearInterval(heartbeat); @@ -951,17 +1122,19 @@ const startServer = async () => { * @returns {function(string[], SubscriptionListener): void} */ - const streamHttpEnd = (req, closeHandler = undefined) => (ids, listener) => { - req.on('close', () => { - ids.forEach(id => { - unsubscribe(id, listener); - }); + const streamHttpEnd = + (req, closeHandler = undefined) => + (ids, listener) => { + req.on('close', () => { + ids.forEach((id) => { + unsubscribe(id, listener); + }); - if (closeHandler) { - closeHandler(); - } - }); - }; + if (closeHandler) { + closeHandler(); + } + }); + }; /** * @param {http.IncomingMessage} req @@ -979,7 +1152,7 @@ const startServer = async () => { ws.send(message, (/** @type {Error|undefined} */ err) => { if (err) { - req.log.error({err}, `Failed to send to websocket`); + req.log.error({ err }, `Failed to send to websocket`); } }); }; @@ -987,7 +1160,7 @@ const startServer = async () => { /** * @param {http.ServerResponse} res */ - const httpNotFound = res => { + const httpNotFound = (res) => { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); }; @@ -1010,20 +1183,30 @@ const startServer = async () => { return; } - channelNameToIds(req, channelName, req.query).then(({ channelIds, options }) => { - const onSend = streamToHttp(req, res); - const onEnd = streamHttpEnd(req, subscriptionHeartbeat(channelIds)); + channelNameToIds(req, channelName, req.query) + .then(({ channelIds, options }) => { + const onSend = streamToHttp(req, res); + const onEnd = streamHttpEnd(req, subscriptionHeartbeat(channelIds)); - // @ts-ignore - streamFrom(channelIds, req, req.log, onSend, onEnd, 'eventsource', options.needsFiltering); - }).catch(err => { - const {statusCode, errorMessage } = extractErrorStatusAndMessage(err); + // @ts-ignore + streamFrom( + channelIds, + req, + req.log, + onSend, + onEnd, + 'eventsource', + options.needsFiltering, + ); + }) + .catch((err) => { + const { statusCode, errorMessage } = extractErrorStatusAndMessage(err); - res.log.info({ err }, 'Eventsource subscription error'); + res.log.info({ err }, 'Eventsource subscription error'); - res.writeHead(statusCode, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: errorMessage })); - }); + res.writeHead(statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: errorMessage })); + }); }); /** @@ -1038,7 +1221,7 @@ const startServer = async () => { * @param {any} req * @returns {string[]} */ - const channelsForUserStream = req => { + const channelsForUserStream = (req) => { const arr = [`timeline:${req.accountId}`]; if (isInScope(req, ['read', 'read:notifications'])) { @@ -1054,125 +1237,134 @@ const startServer = async () => { * @param {StreamParams} params * @returns {Promise.<{ channelIds: string[], options: { needsFiltering: boolean } }>} */ - const channelNameToIds = (req, name, params) => new Promise((resolve, reject) => { - switch (name) { - case 'user': - resolve({ - channelIds: channelsForUserStream(req), - options: { needsFiltering: false }, - }); + const channelNameToIds = (req, name, params) => + new Promise((resolve, reject) => { + switch (name) { + case 'user': + resolve({ + channelIds: channelsForUserStream(req), + options: { needsFiltering: false }, + }); - break; - case 'user:notification': - resolve({ - channelIds: [`timeline:${req.accountId}:notifications`], - options: { needsFiltering: false }, - }); + break; + case 'user:notification': + resolve({ + channelIds: [`timeline:${req.accountId}:notifications`], + options: { needsFiltering: false }, + }); - break; - case 'public': - resolve({ - channelIds: ['timeline:public'], - options: { needsFiltering: true }, - }); + break; + case 'public': + resolve({ + channelIds: ['timeline:public'], + options: { needsFiltering: true }, + }); - break; - case 'public:local': - resolve({ - channelIds: ['timeline:public:local'], - options: { needsFiltering: true }, - }); + break; + case 'public:local': + resolve({ + channelIds: ['timeline:public:local'], + options: { needsFiltering: true }, + }); - break; - case 'public:remote': - resolve({ - channelIds: ['timeline:public:remote'], - options: { needsFiltering: true }, - }); + break; + case 'public:remote': + resolve({ + channelIds: ['timeline:public:remote'], + options: { needsFiltering: true }, + }); - break; - case 'public:media': - resolve({ - channelIds: ['timeline:public:media'], - options: { needsFiltering: true }, - }); + break; + case 'public:media': + resolve({ + channelIds: ['timeline:public:media'], + options: { needsFiltering: true }, + }); - break; - case 'public:local:media': - resolve({ - channelIds: ['timeline:public:local:media'], - options: { needsFiltering: true }, - }); + break; + case 'public:local:media': + resolve({ + channelIds: ['timeline:public:local:media'], + options: { needsFiltering: true }, + }); - break; - case 'public:remote:media': - resolve({ - channelIds: ['timeline:public:remote:media'], - options: { needsFiltering: true }, - }); + break; + case 'public:remote:media': + resolve({ + channelIds: ['timeline:public:remote:media'], + options: { needsFiltering: true }, + }); - break; - case 'direct': - resolve({ - channelIds: [`timeline:direct:${req.accountId}`], - options: { needsFiltering: false }, - }); + break; + case 'direct': + resolve({ + channelIds: [`timeline:direct:${req.accountId}`], + options: { needsFiltering: false }, + }); - break; - case 'hashtag': - if (!params.tag) { - reject(new RequestError('Missing tag name parameter')); - } else { - resolve({ - channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}`], - options: { needsFiltering: true }, - }); + break; + case 'hashtag': + if (!params.tag) { + reject(new RequestError('Missing tag name parameter')); + } else { + resolve({ + channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}`], + options: { needsFiltering: true }, + }); + } + + break; + case 'hashtag:local': + if (!params.tag) { + reject(new RequestError('Missing tag name parameter')); + } else { + resolve({ + channelIds: [ + `timeline:hashtag:${normalizeHashtag(params.tag)}:local`, + ], + options: { needsFiltering: true }, + }); + } + + break; + case 'list': + if (!params.list) { + reject(new RequestError('Missing list name parameter')); + return; + } + + authorizeListAccess(params.list, req) + .then(() => { + resolve({ + channelIds: [`timeline:list:${params.list}`], + options: { needsFiltering: false }, + }); + }) + .catch(() => { + reject( + new AuthenticationError('Not authorized to stream this list'), + ); + }); + + break; + case 'antenna': + // @ts-ignore + authorizeAntennaAccess(params.antenna, req) + .then(() => { + resolve({ + channelIds: [`timeline:antenna:${params.antenna}`], + options: { needsFiltering: false }, + }); + }) + .catch(() => { + reject('Not authorized to stream this antenna'); + }); + + break; + default: + reject(new RequestError('Unknown stream type')); } - - break; - case 'hashtag:local': - if (!params.tag) { - reject(new RequestError('Missing tag name parameter')); - } else { - resolve({ - channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}:local`], - options: { needsFiltering: true }, - }); - } - - break; - case 'list': - if (!params.list) { - reject(new RequestError('Missing list name parameter')); - return; - } - - authorizeListAccess(params.list, req).then(() => { - resolve({ - channelIds: [`timeline:list:${params.list}`], - options: { needsFiltering: false }, - }); - }).catch(() => { - reject(new AuthenticationError('Not authorized to stream this list')); - }); - - break; - case 'antenna': - // @ts-ignore - authorizeAntennaAccess(params.antenna, req).then(() => { - resolve({ - channelIds: [`timeline:antenna:${params.antenna}`], - options: { needsFiltering: false }, - }); - }).catch(() => { - reject('Not authorized to stream this antenna'); - }); - - break; - default: - reject(new RequestError('Unknown stream type')); - } - }); + }); /** * @param {string} channelName @@ -1184,7 +1376,10 @@ const startServer = async () => { return [channelName, params.list]; } else if (channelName === 'antenna' && params.antenna) { return [channelName, params.antenna]; - } else if (['hashtag', 'hashtag:local'].includes(channelName) && params.tag) { + } else if ( + ['hashtag', 'hashtag:local'].includes(channelName) && + params.tag + ) { return [channelName, params.tag]; } else { return [channelName]; @@ -1205,46 +1400,69 @@ const startServer = async () => { * @param {StreamParams} params * @returns {void} */ - const subscribeWebsocketToChannel = ({ websocket, request, logger, subscriptions }, channelName, params) => { - checkScopes(request, logger, channelName).then(() => channelNameToIds(request, channelName, params)).then(({ - channelIds, - options, - }) => { - if (subscriptions[channelIds.join(';')]) { - return; - } + const subscribeWebsocketToChannel = ( + { websocket, request, logger, subscriptions }, + channelName, + params, + ) => { + checkScopes(request, logger, channelName) + .then(() => channelNameToIds(request, channelName, params)) + .then(({ channelIds, options }) => { + if (subscriptions[channelIds.join(';')]) { + return; + } - const onSend = streamToWs(request, websocket, streamNameFromChannelName(channelName, params)); - const stopHeartbeat = subscriptionHeartbeat(channelIds); - const listener = streamFrom(channelIds, request, logger, onSend, undefined, 'websocket', options.needsFiltering); + const onSend = streamToWs( + request, + websocket, + streamNameFromChannelName(channelName, params), + ); + const stopHeartbeat = subscriptionHeartbeat(channelIds); + const listener = streamFrom( + channelIds, + request, + logger, + onSend, + undefined, + 'websocket', + options.needsFiltering, + ); - metrics.connectedChannels.labels({ type: 'websocket', channel: channelName }).inc(); + metrics.connectedChannels + .labels({ type: 'websocket', channel: channelName }) + .inc(); - subscriptions[channelIds.join(';')] = { - channelName, - listener, - stopHeartbeat, - }; - }).catch(err => { - const {statusCode, errorMessage } = extractErrorStatusAndMessage(err); + subscriptions[channelIds.join(';')] = { + channelName, + listener, + stopHeartbeat, + }; + }) + .catch((err) => { + const { statusCode, errorMessage } = extractErrorStatusAndMessage(err); - logger.error({ err }, 'Websocket subscription error'); + logger.error({ err }, 'Websocket subscription error'); - // If we have a socket that is alive and open still, send the error back to the client: - if (websocket.isAlive && websocket.readyState === websocket.OPEN) { - websocket.send(JSON.stringify({ - error: errorMessage, - status: statusCode - })); - } - }); + // If we have a socket that is alive and open still, send the error back to the client: + if (websocket.isAlive && websocket.readyState === websocket.OPEN) { + websocket.send( + JSON.stringify({ + error: errorMessage, + status: statusCode, + }), + ); + } + }); }; /** * @param {WebSocketSession} session * @param {string[]} channelIds */ - const removeSubscription = ({ request, logger, subscriptions }, channelIds) => { + const removeSubscription = ( + { request, logger, subscriptions }, + channelIds, + ) => { logger.info({ channelIds, accountId: request.accountId }, `Ending stream`); const subscription = subscriptions[channelIds.join(';')]; @@ -1253,11 +1471,13 @@ const startServer = async () => { return; } - channelIds.forEach(channelId => { + channelIds.forEach((channelId) => { unsubscribe(channelId, subscription.listener); }); - metrics.connectedChannels.labels({ type: 'websocket', channel: subscription.channelName }).dec(); + metrics.connectedChannels + .labels({ type: 'websocket', channel: subscription.channelName }) + .dec(); subscription.stopHeartbeat(); delete subscriptions[channelIds.join(';')]; @@ -1272,23 +1492,31 @@ const startServer = async () => { const unsubscribeWebsocketFromChannel = (session, channelName, params) => { const { websocket, request, logger } = session; - channelNameToIds(request, channelName, params).then(({ channelIds }) => { - removeSubscription(session, channelIds); - }).catch(err => { - logger.error({err}, 'Websocket unsubscribe error'); + channelNameToIds(request, channelName, params) + .then(({ channelIds }) => { + removeSubscription(session, channelIds); + }) + .catch((err) => { + logger.error({ err }, 'Websocket unsubscribe error'); - // If we have a socket that is alive and open still, send the error back to the client: - if (websocket.isAlive && websocket.readyState === websocket.OPEN) { - // TODO: Use a better error response here - websocket.send(JSON.stringify({ error: "Error unsubscribing from channel" })); - } - }); + // If we have a socket that is alive and open still, send the error back to the client: + if (websocket.isAlive && websocket.readyState === websocket.OPEN) { + // TODO: Use a better error response here + websocket.send( + JSON.stringify({ error: 'Error unsubscribing from channel' }), + ); + } + }); }; /** * @param {WebSocketSession} session */ - const subscribeWebsocketToSystemChannel = ({ websocket, request, subscriptions }) => { + const subscribeWebsocketToSystemChannel = ({ + websocket, + request, + subscriptions, + }) => { const accessTokenChannelId = `timeline:access_token:${request.accessTokenId}`; const systemChannelId = `timeline:system:${request.accountId}`; @@ -1304,18 +1532,18 @@ const startServer = async () => { subscriptions[accessTokenChannelId] = { channelName: 'system', listener, - stopHeartbeat: () => { - }, + stopHeartbeat: () => {}, }; subscriptions[systemChannelId] = { channelName: 'system', listener, - stopHeartbeat: () => { - }, + stopHeartbeat: () => {}, }; - metrics.connectedChannels.labels({ type: 'websocket', channel: 'system' }).inc(2); + metrics.connectedChannels + .labels({ type: 'websocket', channel: 'system' }) + .inc(2); }; /** @@ -1348,7 +1576,7 @@ const startServer = async () => { ws.on('close', function onWebsocketClose() { const subscriptions = Object.keys(session.subscriptions); - subscriptions.forEach(channelIds => { + subscriptions.forEach((channelIds) => { removeSubscription(session, channelIds.split(';')); }); @@ -1375,7 +1603,10 @@ const startServer = async () => { ws.on('message', (data, isBinary) => { if (isBinary) { log.warn('Received binary data, closing connection'); - ws.close(1003, 'The mastodon streaming server does not support binary messages'); + ws.close( + 1003, + 'The mastodon streaming server does not support binary messages', + ); return; } const message = data.toString('utf8'); @@ -1401,14 +1632,18 @@ const startServer = async () => { const location = req.url && url.parse(req.url, true); if (location && location.query.stream) { - subscribeWebsocketToChannel(session, firstParam(location.query.stream), location.query); + subscribeWebsocketToChannel( + session, + firstParam(location.query.stream), + location.query, + ); } } wss.on('connection', onConnection); setInterval(() => { - wss.clients.forEach(ws => { + wss.clients.forEach((ws) => { // @ts-ignore if (ws.isAlive === false) { ws.terminate(); @@ -1421,7 +1656,7 @@ const startServer = async () => { }); }, 30000); - attachServerWithConfig(server, address => { + attachServerWithConfig(server, (address) => { logger.info(`Streaming API now listening on ${address}`); });