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
David Roetzel 2024-07-22 16:21:34 +02:00
parent adadfdbc03
commit ce7c3ffb0a
No known key found for this signature in database
5 changed files with 130 additions and 24 deletions

View 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

View File

@ -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>(

View File

@ -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))));

View File

@ -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

View File

@ -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]