Add support for FEP-3b86 (Activity Intents) (#38120)

This commit is contained in:
Eugen Rochko
2026-03-10 11:21:24 +01:00
committed by GitHub
parent 7d58ce309c
commit 69b1f60f4e
10 changed files with 119 additions and 42 deletions

View File

@@ -39,18 +39,57 @@ const findLink = (rel: string, data: unknown): JRDLink | undefined => {
} }
}; };
const findTemplateLink = (data: unknown) => const intentParams = (intent: string) => {
findLink('http://ostatus.org/schema/1.0/subscribe', data)?.template; switch (intent) {
case 'follow':
return ['https://w3id.org/fep/3b86/Follow', 'object'] as [string, string];
case 'reblog':
return ['https://w3id.org/fep/3b86/Announce', 'object'] as [
string,
string,
];
case 'favourite':
return ['https://w3id.org/fep/3b86/Like', 'object'] as [string, string];
case 'vote':
case 'reply':
return ['https://w3id.org/fep/3b86/Object', 'object'] as [string, string];
default:
return null;
}
};
const findTemplateLink = (data: unknown, intent: string) => {
const [needle, param] = intentParams(intent) ?? [
'http://ostatus.org/schema/1.0/subscribe',
'uri',
];
const match = findLink(needle, data);
if (match) {
return [match.template, param] as [string, string];
}
const fallback = findLink('http://ostatus.org/schema/1.0/subscribe', data);
if (fallback) {
return [fallback.template, 'uri'] as [string, string];
}
return [null, null];
};
const fetchInteractionURLSuccess = ( const fetchInteractionURLSuccess = (
uri_or_domain: string, uri_or_domain: string,
template: string, template: string,
param: string,
) => { ) => {
window.parent.postMessage( window.parent.postMessage(
{ {
type: 'fetchInteractionURL-success', type: 'fetchInteractionURL-success',
uri_or_domain, uri_or_domain,
template, template,
param,
}, },
window.origin, window.origin,
); );
@@ -74,7 +113,7 @@ const isValidDomain = (value: unknown) => {
}; };
// Attempt to find a remote interaction URL from a domain // Attempt to find a remote interaction URL from a domain
const fromDomain = (domain: string) => { const fromDomain = (domain: string, intent: string) => {
const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`;
axios axios
@@ -82,17 +121,21 @@ const fromDomain = (domain: string) => {
params: { resource: `https://${domain}` }, params: { resource: `https://${domain}` },
}) })
.then(({ data }) => { .then(({ data }) => {
const template = findTemplateLink(data); const [template, param] = findTemplateLink(data, intent);
fetchInteractionURLSuccess(domain, template ?? fallbackTemplate); fetchInteractionURLSuccess(
domain,
template ?? fallbackTemplate,
param ?? 'uri',
);
return; return;
}) })
.catch(() => { .catch(() => {
fetchInteractionURLSuccess(domain, fallbackTemplate); fetchInteractionURLSuccess(domain, fallbackTemplate, 'uri');
}); });
}; };
// Attempt to find a remote interaction URL from an arbitrary URL // Attempt to find a remote interaction URL from an arbitrary URL
const fromURL = (url: string) => { const fromURL = (url: string, intent: string) => {
const domain = new URL(url).host; const domain = new URL(url).host;
const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`; const fallbackTemplate = `https://${domain}/authorize_interaction?uri={uri}`;
@@ -101,17 +144,21 @@ const fromURL = (url: string) => {
params: { resource: url }, params: { resource: url },
}) })
.then(({ data }) => { .then(({ data }) => {
const template = findTemplateLink(data); const [template, param] = findTemplateLink(data, intent);
fetchInteractionURLSuccess(url, template ?? fallbackTemplate); fetchInteractionURLSuccess(
url,
template ?? fallbackTemplate,
param ?? 'uri',
);
return; return;
}) })
.catch(() => { .catch(() => {
fromDomain(domain); fromDomain(domain, intent);
}); });
}; };
// Attempt to find a remote interaction URL from a `user@domain` string // Attempt to find a remote interaction URL from a `user@domain` string
const fromAcct = (acct: string) => { const fromAcct = (acct: string, intent: string) => {
acct = acct.replace(/^@/, ''); acct = acct.replace(/^@/, '');
const segments = acct.split('@'); const segments = acct.split('@');
@@ -134,25 +181,29 @@ const fromAcct = (acct: string) => {
params: { resource: `acct:${acct}` }, params: { resource: `acct:${acct}` },
}) })
.then(({ data }) => { .then(({ data }) => {
const template = findTemplateLink(data); const [template, param] = findTemplateLink(data, intent);
fetchInteractionURLSuccess(acct, template ?? fallbackTemplate); fetchInteractionURLSuccess(
acct,
template ?? fallbackTemplate,
param ?? 'uri',
);
return; return;
}) })
.catch(() => { .catch(() => {
// TODO: handle host-meta? // TODO: handle host-meta?
fromDomain(domain); fromDomain(domain, intent);
}); });
}; };
const fetchInteractionURL = (uri_or_domain: string) => { const fetchInteractionURL = (uri_or_domain: string, intent: string) => {
if (uri_or_domain === '') { if (uri_or_domain === '') {
fetchInteractionURLFailure(); fetchInteractionURLFailure();
} else if (/^https?:\/\//.test(uri_or_domain)) { } else if (/^https?:\/\//.test(uri_or_domain)) {
fromURL(uri_or_domain); fromURL(uri_or_domain, intent);
} else if (uri_or_domain.includes('@')) { } else if (uri_or_domain.includes('@')) {
fromAcct(uri_or_domain); fromAcct(uri_or_domain, intent);
} else { } else {
fromDomain(uri_or_domain); fromDomain(uri_or_domain, intent);
} }
}; };
@@ -172,8 +223,10 @@ window.addEventListener('message', (event: MessageEvent<unknown>) => {
'type' in event.data && 'type' in event.data &&
event.data.type === 'fetchInteractionURL' && event.data.type === 'fetchInteractionURL' &&
'uri_or_domain' in event.data && 'uri_or_domain' in event.data &&
typeof event.data.uri_or_domain === 'string' typeof event.data.uri_or_domain === 'string' &&
'intent' in event.data &&
typeof event.data.intent === 'string'
) { ) {
fetchInteractionURL(event.data.uri_or_domain); fetchInteractionURL(event.data.uri_or_domain, event.data.intent);
} }
}); });

View File

@@ -92,6 +92,7 @@ export const FollowButton: React.FC<{
openModal({ openModal({
modalType: 'INTERACTION', modalType: 'INTERACTION',
modalProps: { modalProps: {
intent: 'follow',
accountId: accountId, accountId: accountId,
url: account?.url, url: account?.url,
}, },

View File

@@ -110,6 +110,7 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
openModal({ openModal({
modalType: 'INTERACTION', modalType: 'INTERACTION',
modalProps: { modalProps: {
intent: 'vote',
accountId: status.getIn(['account', 'id']), accountId: status.getIn(['account', 'id']),
url: status.get('uri'), url: status.get('uri'),
}, },

View File

@@ -47,6 +47,7 @@ const StandaloneBoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
openModal({ openModal({
modalType: 'INTERACTION', modalType: 'INTERACTION',
modalProps: { modalProps: {
intent: 'reblog',
accountId: status.getIn(['account', 'id']), accountId: status.getIn(['account', 'id']),
url: status.get('uri'), url: status.get('uri'),
}, },
@@ -120,6 +121,7 @@ const BoostOrQuoteMenu: FC<ReblogButtonProps> = ({ status, counters }) => {
openModal({ openModal({
modalType: 'INTERACTION', modalType: 'INTERACTION',
modalProps: { modalProps: {
intent: 'reblog',
accountId: status.getIn(['account', 'id']), accountId: status.getIn(['account', 'id']),
url: status.get('uri'), url: status.get('uri'),
}, },

View File

@@ -124,7 +124,7 @@ class StatusActionBar extends ImmutablePureComponent {
if (signedIn) { if (signedIn) {
this.props.onReply(this.props.status); this.props.onReply(this.props.status);
} else { } else {
this.props.onInteractionModal(this.props.status); this.props.onInteractionModal(this.props.status, 'reply');
} }
}; };
@@ -146,7 +146,7 @@ class StatusActionBar extends ImmutablePureComponent {
if (signedIn) { if (signedIn) {
this.props.onFavourite(this.props.status); this.props.onFavourite(this.props.status);
} else { } else {
this.props.onInteractionModal(this.props.status); this.props.onInteractionModal(this.props.status, 'favourite');
} }
}; };
@@ -382,7 +382,7 @@ class StatusActionBar extends ImmutablePureComponent {
const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark); const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark);
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite); const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']); const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
const shouldShowQuoteRemovalHint = isQuotingMe && contextType === 'notifications'; const shouldShowQuoteRemovalHint = isQuotingMe && contextType === 'notifications';
return ( return (

View File

@@ -77,7 +77,7 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
onReblog (status, e) { onReblog (status, e) {
dispatch(toggleReblog(status.get('id'), e.shiftKey)); dispatch(toggleReblog(status.get('id'), e.shiftKey));
}, },
onQuote (status) { onQuote (status) {
dispatch(quoteComposeById(status.get('id'))); dispatch(quoteComposeById(status.get('id')));
}, },
@@ -231,10 +231,11 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
dispatch(deployPictureInPicture({statusId: status.get('id'), accountId: status.getIn(['account', 'id']), playerType: type, props: mediaProps})); dispatch(deployPictureInPicture({statusId: status.get('id'), accountId: status.getIn(['account', 'id']), playerType: type, props: mediaProps}));
}, },
onInteractionModal (status) { onInteractionModal (status, intent) {
dispatch(openModal({ dispatch(openModal({
modalType: 'INTERACTION', modalType: 'INTERACTION',
modalProps: { modalProps: {
intent,
accountId: status.getIn(['account', 'id']), accountId: status.getIn(['account', 'id']),
url: status.get('uri'), url: status.get('uri'),
}, },

View File

@@ -25,6 +25,8 @@ const messages = defineMessages({
}, },
}); });
type InteractionIntent = 'follow' | 'reblog' | 'favourite' | 'reply' | 'vote';
interface LoginFormMessage { interface LoginFormMessage {
type: type:
| 'fetchInteractionURL' | 'fetchInteractionURL'
@@ -32,6 +34,8 @@ interface LoginFormMessage {
| 'fetchInteractionURL-success'; | 'fetchInteractionURL-success';
uri_or_domain: string; uri_or_domain: string;
template?: string; template?: string;
param?: string;
intent?: InteractionIntent;
} }
const PERSISTENCE_KEY = 'mastodon_home'; const PERSISTENCE_KEY = 'mastodon_home';
@@ -110,7 +114,11 @@ const isValueValid = (value: string) => {
} }
}; };
const sendToFrame = (frame: HTMLIFrameElement | null, value: string): void => { const sendToFrame = (
frame: HTMLIFrameElement | null,
value: string,
intent: string,
): void => {
if (valueToDomain(value.trim()) === localDomain) { if (valueToDomain(value.trim()) === localDomain) {
window.location.href = '/auth/sign_in'; window.location.href = '/auth/sign_in';
return; return;
@@ -120,6 +128,7 @@ const sendToFrame = (frame: HTMLIFrameElement | null, value: string): void => {
{ {
type: 'fetchInteractionURL', type: 'fetchInteractionURL',
uri_or_domain: value.trim(), uri_or_domain: value.trim(),
intent,
}, },
window.origin, window.origin,
); );
@@ -127,7 +136,8 @@ const sendToFrame = (frame: HTMLIFrameElement | null, value: string): void => {
const LoginForm: React.FC<{ const LoginForm: React.FC<{
resourceUrl: string; resourceUrl: string;
}> = ({ resourceUrl }) => { intent: string;
}> = ({ resourceUrl, intent }) => {
const intl = useIntl(); const intl = useIntl();
const [value, setValue] = useState( const [value, setValue] = useState(
localStorage.getItem(PERSISTENCE_KEY) ?? '', localStorage.getItem(PERSISTENCE_KEY) ?? '',
@@ -161,7 +171,7 @@ const LoginForm: React.FC<{
try { try {
const url = new URL( const url = new URL(
event.data.template.replace( event.data.template.replace(
'{uri}', `{${event.data.param}}`,
encodeURIComponent(resourceUrl), encodeURIComponent(resourceUrl),
), ),
); );
@@ -242,8 +252,8 @@ const LoginForm: React.FC<{
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
setIsSubmitting(true); setIsSubmitting(true);
sendToFrame(iframeRef.current, value); sendToFrame(iframeRef.current, value, intent);
}, [setIsSubmitting, value]); }, [setIsSubmitting, value, intent]);
const handleFocus = useCallback(() => { const handleFocus = useCallback(() => {
setExpanded(true); setExpanded(true);
@@ -287,7 +297,7 @@ const LoginForm: React.FC<{
setError(false); setError(false);
setValue(selectedOptionValue); setValue(selectedOptionValue);
setIsSubmitting(true); setIsSubmitting(true);
sendToFrame(iframeRef.current, selectedOptionValue); sendToFrame(iframeRef.current, selectedOptionValue, intent);
} }
break; break;
@@ -300,6 +310,7 @@ const LoginForm: React.FC<{
setValue, setValue,
selectedOption, selectedOption,
options, options,
intent,
], ],
); );
@@ -318,9 +329,9 @@ const LoginForm: React.FC<{
setValue(option); setValue(option);
setError(false); setError(false);
setIsSubmitting(true); setIsSubmitting(true);
sendToFrame(iframeRef.current, option); sendToFrame(iframeRef.current, option, intent);
}, },
[options, setSelectedOption, setValue, setError], [options, setSelectedOption, setValue, setError, intent],
); );
const domain = (valueToDomain(value) ?? '').trim(); const domain = (valueToDomain(value) ?? '').trim();
@@ -404,7 +415,8 @@ const LoginForm: React.FC<{
const InteractionModal: React.FC<{ const InteractionModal: React.FC<{
accountId: string; accountId: string;
url: string; url: string;
}> = ({ accountId, url }) => { intent: string;
}> = ({ accountId, url, intent }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const signupUrl = useAppSelector( const signupUrl = useAppSelector(
(state) => (state) =>
@@ -479,7 +491,7 @@ const InteractionModal: React.FC<{
</p> </p>
</div> </div>
<LoginForm resourceUrl={url} /> <LoginForm resourceUrl={url} intent={intent} />
<p> <p>
<FormattedMessage <FormattedMessage

View File

@@ -86,6 +86,7 @@ export const Footer: React.FC<{
openModal({ openModal({
modalType: 'INTERACTION', modalType: 'INTERACTION',
modalProps: { modalProps: {
intent: 'reply',
accountId: status.getIn(['account', 'id']), accountId: status.getIn(['account', 'id']),
url: status.get('uri'), url: status.get('uri'),
}, },
@@ -106,6 +107,7 @@ export const Footer: React.FC<{
openModal({ openModal({
modalType: 'INTERACTION', modalType: 'INTERACTION',
modalProps: { modalProps: {
intent: 'favourite',
accountId: status.getIn(['account', 'id']), accountId: status.getIn(['account', 'id']),
url: status.get('uri'), url: status.get('uri'),
}, },

View File

@@ -190,6 +190,7 @@ class Status extends ImmutablePureComponent {
dispatch(openModal({ dispatch(openModal({
modalType: 'INTERACTION', modalType: 'INTERACTION',
modalProps: { modalProps: {
intent: 'favourite',
accountId: status.getIn(['account', 'id']), accountId: status.getIn(['account', 'id']),
url: status.get('uri'), url: status.get('uri'),
}, },
@@ -219,6 +220,7 @@ class Status extends ImmutablePureComponent {
dispatch(openModal({ dispatch(openModal({
modalType: 'INTERACTION', modalType: 'INTERACTION',
modalProps: { modalProps: {
intent: 'reply',
accountId: status.getIn(['account', 'id']), accountId: status.getIn(['account', 'id']),
url: status.get('uri'), url: status.get('uri'),
}, },
@@ -236,6 +238,7 @@ class Status extends ImmutablePureComponent {
dispatch(openModal({ dispatch(openModal({
modalType: 'INTERACTION', modalType: 'INTERACTION',
modalProps: { modalProps: {
intent: 'reblog',
accountId: status.getIn(['account', 'id']), accountId: status.getIn(['account', 'id']),
url: status.get('uri'), url: status.get('uri'),
}, },
@@ -274,13 +277,13 @@ class Status extends ImmutablePureComponent {
// Error handling - could show error message // Error handling - could show error message
}); });
} else { } else {
dispatch(openModal({ dispatch(openModal({
modalType: 'CONFIRM_DELETE_STATUS', modalType: 'CONFIRM_DELETE_STATUS',
modalProps: { modalProps: {
statusId: status.get('id'), statusId: status.get('id'),
withRedraft, withRedraft,
onDeleteSuccess: handleDeleteSuccess onDeleteSuccess: handleDeleteSuccess
} }
})); }));
} }
}; };
@@ -498,7 +501,7 @@ class Status extends ImmutablePureComponent {
// Only highlight replies after the initial load // Only highlight replies after the initial load
if (prevProps.descendantsIds.length && isSameStatus) { if (prevProps.descendantsIds.length && isSameStatus) {
const newRepliesIds = difference(descendantsIds, prevProps.descendantsIds); const newRepliesIds = difference(descendantsIds, prevProps.descendantsIds);
if (newRepliesIds.length) { if (newRepliesIds.length) {
this.setState({newRepliesIds}); this.setState({newRepliesIds});
} }
@@ -631,7 +634,7 @@ class Status extends ImmutablePureComponent {
</Hotkeys> </Hotkeys>
{descendants} {descendants}
<RefreshController <RefreshController
isLocal={isLocal} isLocal={isLocal}
statusId={status.get('id')} statusId={status.get('id')}

View File

@@ -22,6 +22,8 @@ class WebfingerSerializer < ActiveModel::Serializer
{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profile_page_href }, { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profile_page_href },
{ rel: 'self', type: 'application/activity+json', href: self_href }, { rel: 'self', type: 'application/activity+json', href: self_href },
{ rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" }, { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
{ rel: 'https://w3id.org/fep/3b86/Create', template: "#{share_url}?text={content}" },
{ rel: 'https://w3id.org/fep/3b86/Object', template: "#{authorize_interaction_url}?uri={object}" },
].tap do |x| ].tap do |x|
x << { rel: 'http://webfinger.net/rel/avatar', type: object.avatar.content_type, href: full_asset_url(object.avatar_original_url) } if show_avatar? x << { rel: 'http://webfinger.net/rel/avatar', type: object.avatar.content_type, href: full_asset_url(object.avatar_original_url) } if show_avatar?
end end