From ce7c3ffb0abc97b7b92f2f53af5a0b2b234f711f Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Mon, 22 Jul 2024 16:21:34 +0200 Subject: [PATCH] Open status links in-app if possible. Adds a new API endpoint to resolve URLs quicker than with the existing search API. Sets a timeout so that the browser's pop-up blocking is not triggered. --- .../api/v2/resolved_urls_controller.rb | 31 +++++++++ app/javascript/mastodon/api.ts | 4 +- .../mastodon/components/status_content.jsx | 67 +++++++++++++++++-- app/services/resolve_url_service.rb | 51 ++++++++------ config/routes/api.rb | 1 + 5 files changed, 130 insertions(+), 24 deletions(-) create mode 100644 app/controllers/api/v2/resolved_urls_controller.rb diff --git a/app/controllers/api/v2/resolved_urls_controller.rb b/app/controllers/api/v2/resolved_urls_controller.rb new file mode 100644 index 00000000000..e2de095dec6 --- /dev/null +++ b/app/controllers/api/v2/resolved_urls_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Api::V2::ResolvedUrlsController < Api::BaseController + include Authorization + + before_action :set_url + before_action :set_resource + + def show + expires_in(1.day, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in? + + case @resource + when Account + render json: { 'resolvedPath' => "/@#{@resource.pretty_acct}" } + when Status + render json: { 'resolvedPath' => "/@#{@resource.account.pretty_acct}/#{@resource.id}" } + else + render json: {} + end + end + + private + + def set_url + @url = params.require(:url) + end + + def set_resource + @resource = ResolveURLService.new.call(@url, on_behalf_of: current_user, allow_caching: true) if user_signed_in? + end +end diff --git a/app/javascript/mastodon/api.ts b/app/javascript/mastodon/api.ts index 24672290c74..0963cd49d73 100644 --- a/app/javascript/mastodon/api.ts +++ b/app/javascript/mastodon/api.ts @@ -67,6 +67,7 @@ export async function apiRequest( args: { params?: RequestParamsOrData; data?: RequestParamsOrData; + timeout?: number; } = {}, ) { const { data } = await api().request({ @@ -81,8 +82,9 @@ export async function apiRequest( export async function apiRequestGet( url: string, params?: RequestParamsOrData, + timeout?: number ) { - return apiRequest('GET', url, { params }); + return apiRequest('GET', url, {params: params, timeout: timeout }); } export async function apiRequestPost( diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 96452374dcc..243560edbd6 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import { PureComponent } from 'react'; -import { FormattedMessage, injectIntl } from 'react-intl'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; import classnames from 'classnames'; import { Link, withRouter } from 'react-router-dom'; @@ -10,6 +10,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; +import { openModal } from 'mastodon/actions/modal'; +import { apiRequestGet } from 'mastodon/api'; import { Icon } from 'mastodon/components/icon'; import PollContainer from 'mastodon/containers/poll_container'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; @@ -18,6 +20,10 @@ import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_s const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) +const messages = defineMessages({ + openExternalLink: { id: 'status_content.external_link.open', defaultMessage: 'You are now leaving mastodon'}, + openExternalLinkConfirm: { id: 'status_content.external_link.confirm', defaultMessage: 'Open link'}, +}); /** * * @param {any} status @@ -64,6 +70,20 @@ class TranslateButton extends PureComponent { } +const mapDispatchToProps = (dispatch, { intl }) => ({ + openExternalLink(url) { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.openExternalLink), + confirm: intl.formatMessage(messages.openExternalLinkConfirm), + closeWhenConfirm: true, + onConfirm: () => window.open(url, null, 'norefferer'), + }, + })); + }, +}); + const mapStateToProps = state => ({ languages: state.getIn(['server', 'translationLanguages', 'items']), }); @@ -84,7 +104,8 @@ class StatusContent extends PureComponent { // from react-router match: PropTypes.object.isRequired, location: PropTypes.object.isRequired, - history: PropTypes.object.isRequired + history: PropTypes.object.isRequired, + openExternalLink: PropTypes.func.isRequired }; state = { @@ -122,9 +143,12 @@ class StatusContent extends PureComponent { } 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); link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`); - } else { + } else if (status.get('uri') === link.href) { link.setAttribute('title', link.href); link.classList.add('unhandled-link'); + } else { + link.setAttribute('title', link.href); + link.addEventListener('click', this.onLinkClick.bind(this, link), false); } } @@ -191,6 +215,41 @@ class StatusContent extends PureComponent { } }; + onLinkClick = (anchor, e) => { + if (anchor.getAttribute('search-not-found')) { + return; + } + const url = anchor?.href; + if (!url || !(this.props && e.button === 0 && !(e.ctrlKey || e.metaKey))) { + return; + } + e.preventDefault(); + if (url.startsWith("/")) { + this.props.history.push(url); + return; + } + if (url.startsWith(window.location.origin)) { + this.props.history.push(url.slice(window.location.origin.length)); + return; + } + const query = new URLSearchParams(); + query.set("url", url); + apiRequestGet(`/v2/resolved_url?${query}`, null, 1000) + .then((result) => { + let resolvedPath = result.resolvedPath; + + if (resolvedPath) { + this.props.history.push(resolvedPath); + } else { + anchor.setAttribute('search-not-found', 'true'); + window.open(url, null, 'noreferrer'); + } + }) + .catch(() => { + this.props.openExternalLink(url); + }); + }; + handleMouseDown = (e) => { this.startXY = [e.clientX, e.clientY]; }; @@ -327,4 +386,4 @@ class StatusContent extends PureComponent { } -export default withRouter(withIdentity(connect(mapStateToProps)(injectIntl(StatusContent)))); +export default withRouter(withIdentity(injectIntl(connect(mapStateToProps, mapDispatchToProps)(StatusContent)))); diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb index 19a94e77ad1..d7d91ef5fa1 100644 --- a/app/services/resolve_url_service.rb +++ b/app/services/resolve_url_service.rb @@ -6,12 +6,15 @@ class ResolveURLService < BaseService USERNAME_STATUS_RE = %r{/@(?#{Account::USERNAME_RE})/(?[0-9]+)\Z} - def call(url, on_behalf_of: nil) - @url = url - @on_behalf_of = on_behalf_of + def call(url, on_behalf_of: nil, allow_caching: false) + @url = url + @on_behalf_of = on_behalf_of + @caching_allowed = allow_caching if local_url? process_local_url + elsif allow_caching && (resource = known_resource) + resource elsif !fetched_resource.nil? process_url else @@ -37,23 +40,9 @@ class ResolveURLService < BaseService return account unless account.nil? end - return unless @on_behalf_of.present? && [401, 403, 404].include?(fetch_resource_service.response_code) + return unless !@caching_allowed && @on_behalf_of.present? && [401, 403, 404].include?(fetch_resource_service.response_code) - # It may happen that the resource is a private toot, and thus not fetchable, - # but we can return the toot if we already know about it. - scope = Status.where(uri: @url) - - # We don't have an index on `url`, so try guessing the `uri` from `url` - parsed_url = Addressable::URI.parse(@url) - parsed_url.path.match(USERNAME_STATUS_RE) do |matched| - parsed_url.path = "/users/#{matched[:username]}/statuses/#{matched[:status_id]}" - scope = scope.or(Status.where(uri: parsed_url.to_s, url: @url)) - end - - status = scope.first - - authorize_with @on_behalf_of, status, :show? unless status.nil? - status + find_remote_status_in_local_db rescue Mastodon::NotPermittedError nil end @@ -114,6 +103,13 @@ class ResolveURLService < BaseService end end + def known_resource + status = find_remote_status_in_local_db + return status unless status.nil? + + Account.where(uri: @url).or(Account.where(url: @url)).first + end + def check_local_status(status) return if status.nil? @@ -122,4 +118,21 @@ class ResolveURLService < BaseService rescue Mastodon::NotPermittedError nil end + + def find_remote_status_in_local_db + # It may happen that the resource is a private toot, and thus not fetchable, + # but we can return the toot if we already know about it. + scope = Status.where(uri: @url) + + # We don't have an index on `url`, so try guessing the `uri` from `url` + parsed_url = Addressable::URI.parse(@url) + parsed_url.path.match(USERNAME_STATUS_RE) do |matched| + parsed_url.path = "/users/#{matched[:username]}/statuses/#{matched[:status_id]}" + scope = scope.or(Status.where(uri: parsed_url.to_s, url: @url)) + end + + status = scope.first + + check_local_status(status) + end end diff --git a/config/routes/api.rb b/config/routes/api.rb index 90119166974..fe1a21f9597 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -310,6 +310,7 @@ namespace :api, format: false do end namespace :v2 do + get '/resolved_url', to: 'resolved_urls#show', as: :resolved_url get '/search', to: 'search#index', as: :search resources :media, only: [:create]