diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 2493dbb52..7547e4357 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -1,15 +1,20 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; import Permalink from './permalink'; import classnames from 'classnames'; import PollContainer from 'mastodon/containers/poll_container'; import Icon from 'mastodon/components/icon'; import { autoPlayGif, show_reply_tree_button } from 'mastodon/initial_state'; +const messages = defineMessages({ + postByAcct: { id: 'status.post_by_acct', defaultMessage: 'Post by @{acct}' }, +}); + const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) +@injectIntl export default class StatusContent extends React.PureComponent { static contextTypes = { @@ -25,6 +30,7 @@ export default class StatusContent extends React.PureComponent { collapsable: PropTypes.bool, onCollapsedToggle: PropTypes.func, quote: PropTypes.bool, + intl: PropTypes.object.isRequired, }; state = { @@ -58,6 +64,9 @@ export default class StatusContent extends React.PureComponent { link.setAttribute('title', mention.get('acct')); } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); + } else if (link.classList.contains('status-url-link')) { + link.setAttribute('title', this.props.intl.formatMessage(messages.postByAcct, { acct: link.dataset.statusAccountAcct })); + link.addEventListener('click', this.onStatusUrlClick.bind(this, link.dataset.statusId), false); } else { link.setAttribute('title', link.href); link.classList.add('unhandled-link'); @@ -137,6 +146,13 @@ export default class StatusContent extends React.PureComponent { } } + onStatusUrlClick = (statusId, e) => { + if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + this.context.router.history.push(`/statuses/${statusId}`); + } + } + onQuoteClick = (statusId, e) => { let statusUrl = `/statuses/${statusId}`; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 2814e0c62..eed53f4eb 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -510,6 +510,7 @@ "status.open": "Expand this post", "status.pin": "Pin on profile", "status.pinned": "Pinned post", + "status.post_by_acct": "Post by @{acct}", "status.quote": "Quote", "status.read_more": "Read more", "status.reblog": "Boost", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 00f456799..a238469be 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -511,6 +511,7 @@ "status.open": "詳細を表示", "status.pin": "プロフィールに固定表示", "status.pinned": "固定された投稿", + "status.post_by_acct": "@{acct}による投稿", "status.quote": "引用", "status.read_more": "もっと見る", "status.reblog": "ブースト", diff --git a/app/lib/entity_cache.rb b/app/lib/entity_cache.rb index 72080e23f..bbc8d46ca 100644 --- a/app/lib/entity_cache.rb +++ b/app/lib/entity_cache.rb @@ -6,6 +6,7 @@ class EntityCache include Singleton MAX_EXPIRATION = 7.days.freeze + MIN_EXPIRATION = 60.seconds.freeze def status(url) Rails.cache.fetch(to_key(:status, url), expires_in: MAX_EXPIRATION) { FetchRemoteStatusService.new.call(url) } @@ -34,6 +35,26 @@ class EntityCache shortcodes.filter_map { |shortcode| cached[to_key(:emoji, shortcode, domain)] || uncached[shortcode] } end + def holding_status_and_account(url) + return Rails.cache.read(to_key(:holding_status, url)) if Rails.cache.exist?(to_key(:holding_status, url)) + + status = begin + if ActivityPub::TagManager.instance.local_uri?(url) + StatusFinder.new(url).status + else + Status.where(uri: url).or(Status.where(url: url)).first + end + rescue ActiveRecord::RecordNotFound + nil + end + + account = status&.account + + Rails.cache.write(to_key(:holding_status, url), [status, account], expires_in: account.nil? ? MIN_EXPIRATION : MAX_EXPIRATION) + + [status, account] + end + def to_key(type, *ids) "#{type}:#{ids.compact.map(&:downcase).join(':')}" end diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 1dfe5d4f3..2b7303405 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -26,6 +26,7 @@ class Formatter unless status.local? html = reformat(raw_content) + html = apply_inner_link(html) html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify] return html.html_safe # rubocop:disable Rails/OutputSafety end @@ -52,7 +53,7 @@ class Formatter html.sub!(/^

(.+)<\/p>$/, '\1') html = Sanitize.clean(html).delete("\n").truncate(150) html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify] - html.html_safe + html.html_safe # rubocop:disable Rails/OutputSafety end def reformat(html) @@ -248,11 +249,42 @@ class Formatter html_attrs[:rel] = "me #{html_attrs[:rel]}" if options[:me] + status, account = url_to_holding_status_and_account(url.normalize.to_s) + + if account.present? + html_attrs[:class] = 'status-url-link' + html_attrs[:'data-status-id'] = status.id + html_attrs[:'data-status-account-acct'] = account.acct + end + Twitter::TwitterText::Autolink.send(:link_to_text, entity, link_html(entity[:url]), url, html_attrs) rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError encode(entity[:url]) end + def apply_inner_link(html) + doc = Nokogiri::HTML.parse(html, nil, 'utf-8') + doc.css('a').map do |x| + status, account = url_to_holding_status_and_account(x['href']) + + if account.present? + x.add_class('status-url-link') + x['data-status-id'] = status.id + x['data-status-account-acct'] = account.acct + end + end + html = doc.css('body')[0]&.inner_html || '' + html.html_safe # rubocop:disable Rails/OutputSafety + end + + def url_to_holding_status_and_account(url) + url = url.split('#').first + + return if url.nil? + + EntityCache.instance.holding_status_and_account(url) + end + def link_to_mention(entity, linkable_accounts, options = {}) acct = entity[:screen_name]