mirror of https://github.com/tootsuite/mastodon
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.spike/resolve-urls-on-click
parent
adadfdbc03
commit
ce7c3ffb0a
|
@ -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
|
|
@ -67,6 +67,7 @@ export async function apiRequest<ApiResponse = unknown>(
|
|||
args: {
|
||||
params?: RequestParamsOrData;
|
||||
data?: RequestParamsOrData;
|
||||
timeout?: number;
|
||||
} = {},
|
||||
) {
|
||||
const { data } = await api().request<ApiResponse>({
|
||||
|
@ -81,8 +82,9 @@ export async function apiRequest<ApiResponse = unknown>(
|
|||
export async function apiRequestGet<ApiResponse = unknown>(
|
||||
url: string,
|
||||
params?: RequestParamsOrData,
|
||||
timeout?: number
|
||||
) {
|
||||
return apiRequest<ApiResponse>('GET', url, { params });
|
||||
return apiRequest<ApiResponse>('GET', url, {params: params, timeout: timeout });
|
||||
}
|
||||
|
||||
export async function apiRequestPost<ApiResponse = unknown>(
|
||||
|
|
|
@ -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))));
|
||||
|
|
|
@ -6,12 +6,15 @@ class ResolveURLService < BaseService
|
|||
|
||||
USERNAME_STATUS_RE = %r{/@(?<username>#{Account::USERNAME_RE})/(?<status_id>[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
|
||||
|
|
|
@ -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]
|
||||
|
|
Loading…
Reference in New Issue