mirror of
https://github.com/mastodon/mastodon.git
synced 2024-08-20 21:08:15 -07:00
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.
This commit is contained in:
parent
adadfdbc03
commit
ce7c3ffb0a
5 changed files with 130 additions and 24 deletions
31
app/controllers/api/v2/resolved_urls_controller.rb
Normal file
31
app/controllers/api/v2/resolved_urls_controller.rb
Normal file
|
@ -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: {
|
args: {
|
||||||
params?: RequestParamsOrData;
|
params?: RequestParamsOrData;
|
||||||
data?: RequestParamsOrData;
|
data?: RequestParamsOrData;
|
||||||
|
timeout?: number;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const { data } = await api().request<ApiResponse>({
|
const { data } = await api().request<ApiResponse>({
|
||||||
|
@ -81,8 +82,9 @@ export async function apiRequest<ApiResponse = unknown>(
|
||||||
export async function apiRequestGet<ApiResponse = unknown>(
|
export async function apiRequestGet<ApiResponse = unknown>(
|
||||||
url: string,
|
url: string,
|
||||||
params?: RequestParamsOrData,
|
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>(
|
export async function apiRequestPost<ApiResponse = unknown>(
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import { Link, withRouter } from 'react-router-dom';
|
import { Link, withRouter } from 'react-router-dom';
|
||||||
|
@ -10,6 +10,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
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 { Icon } from 'mastodon/components/icon';
|
||||||
import PollContainer from 'mastodon/containers/poll_container';
|
import PollContainer from 'mastodon/containers/poll_container';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
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 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
|
* @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 => ({
|
const mapStateToProps = state => ({
|
||||||
languages: state.getIn(['server', 'translationLanguages', 'items']),
|
languages: state.getIn(['server', 'translationLanguages', 'items']),
|
||||||
});
|
});
|
||||||
|
@ -84,7 +104,8 @@ class StatusContent extends PureComponent {
|
||||||
// from react-router
|
// from react-router
|
||||||
match: PropTypes.object.isRequired,
|
match: PropTypes.object.isRequired,
|
||||||
location: PropTypes.object.isRequired,
|
location: PropTypes.object.isRequired,
|
||||||
history: PropTypes.object.isRequired
|
history: PropTypes.object.isRequired,
|
||||||
|
openExternalLink: PropTypes.func.isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
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] === '#')) {
|
} 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.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||||
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
|
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
|
||||||
} else {
|
} else if (status.get('uri') === link.href) {
|
||||||
link.setAttribute('title', link.href);
|
link.setAttribute('title', link.href);
|
||||||
link.classList.add('unhandled-link');
|
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) => {
|
handleMouseDown = (e) => {
|
||||||
this.startXY = [e.clientX, e.clientY];
|
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}
|
USERNAME_STATUS_RE = %r{/@(?<username>#{Account::USERNAME_RE})/(?<status_id>[0-9]+)\Z}
|
||||||
|
|
||||||
def call(url, on_behalf_of: nil)
|
def call(url, on_behalf_of: nil, allow_caching: false)
|
||||||
@url = url
|
@url = url
|
||||||
@on_behalf_of = on_behalf_of
|
@on_behalf_of = on_behalf_of
|
||||||
|
@caching_allowed = allow_caching
|
||||||
|
|
||||||
if local_url?
|
if local_url?
|
||||||
process_local_url
|
process_local_url
|
||||||
|
elsif allow_caching && (resource = known_resource)
|
||||||
|
resource
|
||||||
elsif !fetched_resource.nil?
|
elsif !fetched_resource.nil?
|
||||||
process_url
|
process_url
|
||||||
else
|
else
|
||||||
|
@ -37,23 +40,9 @@ class ResolveURLService < BaseService
|
||||||
return account unless account.nil?
|
return account unless account.nil?
|
||||||
end
|
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,
|
find_remote_status_in_local_db
|
||||||
# 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
|
|
||||||
rescue Mastodon::NotPermittedError
|
rescue Mastodon::NotPermittedError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
@ -114,6 +103,13 @@ class ResolveURLService < BaseService
|
||||||
end
|
end
|
||||||
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)
|
def check_local_status(status)
|
||||||
return if status.nil?
|
return if status.nil?
|
||||||
|
|
||||||
|
@ -122,4 +118,21 @@ class ResolveURLService < BaseService
|
||||||
rescue Mastodon::NotPermittedError
|
rescue Mastodon::NotPermittedError
|
||||||
nil
|
nil
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -310,6 +310,7 @@ namespace :api, format: false do
|
||||||
end
|
end
|
||||||
|
|
||||||
namespace :v2 do
|
namespace :v2 do
|
||||||
|
get '/resolved_url', to: 'resolved_urls#show', as: :resolved_url
|
||||||
get '/search', to: 'search#index', as: :search
|
get '/search', to: 'search#index', as: :search
|
||||||
|
|
||||||
resources :media, only: [:create]
|
resources :media, only: [:create]
|
||||||
|
|
Loading…
Reference in a new issue