1
0
Fork 0
mirror of https://github.com/mastodon/mastodon.git synced 2024-08-20 21:08:15 -07:00

Compare commits

...

5 commits

Author SHA1 Message Date
David Roetzel
6a9f84f56c
Merge 652ed971e2 into a50c8e951f 2024-07-31 14:07:13 +00:00
Claire
a50c8e951f
Fix issue with grouped notifications UI due to recent API change (#31224) 2024-07-31 13:23:08 +00:00
Claire
2c1e75727d
Change filtered notification banner design to take up less space (#31222) 2024-07-31 12:36:08 +00:00
David Roetzel
652ed971e2
Fix typo. 2024-07-23 09:23:54 +02:00
David Roetzel
ce7c3ffb0a
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.
2024-07-22 16:21:34 +02:00
10 changed files with 140 additions and 57 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: { 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>(

View file

@ -60,7 +60,7 @@ export interface BaseNotificationGroupJSON {
interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON {
type: NotificationWithStatusType; type: NotificationWithStatusType;
status: ApiStatusJSON; status_id: string;
} }
interface NotificationWithStatusJSON extends BaseNotificationJSON { interface NotificationWithStatusJSON extends BaseNotificationJSON {

View file

@ -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, 'noreferrer'),
},
}));
},
});
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))));

View file

@ -49,22 +49,15 @@ export const FilteredNotificationsBanner: React.FC = () => {
<span> <span>
<FormattedMessage <FormattedMessage
id='filtered_notifications_banner.pending_requests' id='filtered_notifications_banner.pending_requests'
defaultMessage='Notifications from {count, plural, =0 {no one} one {one person} other {# people}} you may know' defaultMessage='From {count, plural, =0 {no one} one {one person} other {# people}} you may know'
values={{ count: policy.summary.pending_requests_count }} values={{ count: policy.summary.pending_requests_count }}
/> />
</span> </span>
</div> </div>
<div className='filtered-notifications-banner__badge'> <div className='filtered-notifications-banner__badge'>
<div className='filtered-notifications-banner__badge__badge'>
{toCappedNumber(policy.summary.pending_notifications_count)} {toCappedNumber(policy.summary.pending_notifications_count)}
</div> </div>
<FormattedMessage
id='filtered_notifications_banner.mentions'
defaultMessage='{count, plural, one {mention} other {mentions}}'
values={{ count: policy.summary.pending_notifications_count }}
/>
</div>
</Link> </Link>
); );
}; };

View file

@ -300,8 +300,7 @@
"filter_modal.select_filter.subtitle": "Use an existing category or create a new one", "filter_modal.select_filter.subtitle": "Use an existing category or create a new one",
"filter_modal.select_filter.title": "Filter this post", "filter_modal.select_filter.title": "Filter this post",
"filter_modal.title.status": "Filter a post", "filter_modal.title.status": "Filter a post",
"filtered_notifications_banner.mentions": "{count, plural, one {mention} other {mentions}}", "filtered_notifications_banner.pending_requests": "From {count, plural, =0 {no one} one {one person} other {# people}} you may know",
"filtered_notifications_banner.pending_requests": "Notifications from {count, plural, =0 {no one} one {one person} other {# people}} you may know",
"filtered_notifications_banner.title": "Filtered notifications", "filtered_notifications_banner.title": "Filtered notifications",
"firehose.all": "All", "firehose.all": "All",
"firehose.local": "This server", "firehose.local": "This server",

View file

@ -124,9 +124,9 @@ export function createNotificationGroupFromJSON(
case 'mention': case 'mention':
case 'poll': case 'poll':
case 'update': { case 'update': {
const { status, ...groupWithoutStatus } = group; const { status_id: statusId, ...groupWithoutStatus } = group;
return { return {
statusId: status.id, statusId,
sampleAccountIds, sampleAccountIds,
...groupWithoutStatus, ...groupWithoutStatus,
}; };

View file

@ -10170,20 +10170,6 @@ noscript {
} }
} }
&__badge {
display: flex;
align-items: center;
border-radius: 999px;
background: var(--background-border-color);
color: $darker-text-color;
padding: 4px;
padding-inline-end: 8px;
gap: 6px;
font-weight: 500;
font-size: 11px;
line-height: 16px;
word-break: keep-all;
&__badge { &__badge {
background: $ui-button-background-color; background: $ui-button-background-color;
color: $white; color: $white;
@ -10191,7 +10177,6 @@ noscript {
padding: 2px 8px; padding: 2px 8px;
} }
} }
}
.notification-request { .notification-request {
display: flex; display: flex;

View file

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

View file

@ -311,6 +311,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]