pl-fe: Allow checking whether remote instances support emoji reactions when reacting

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-04-16 22:31:34 +02:00
parent 30892e44d1
commit 390341d25e
11 changed files with 156 additions and 25 deletions

View File

@ -1,4 +1,9 @@
import { defineMessages, IntlShape } from 'react-intl';
import { useSettingsStore } from 'pl-fe/stores/settings';
import toast from 'pl-fe/toast';
import { isLoggedIn } from 'pl-fe/utils/auth';
import { supportsEmojiReacts } from 'pl-fe/utils/check-instance-capability';
import { getClient } from '../api';
@ -11,9 +16,13 @@ const EMOJI_REACT_FAIL = 'EMOJI_REACT_FAIL' as const;
const UNEMOJI_REACT_REQUEST = 'UNEMOJI_REACT_REQUEST' as const;
const messages = defineMessages({
unsupported: { id: 'emoji_reactions.unsupported_by_remote', defaultMessage: '@{acct}s instance most likely doesnt understand emoji reactions. The user will not get notified of the reaction.' },
});
const noOp = () => () => new Promise(f => f(undefined));
const emojiReact = (statusId: string, emoji: string, custom?: string) =>
const emojiReact = (statusId: string, emoji: string, custom: string | undefined = undefined, intl: IntlShape) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return dispatch(noOp());
@ -21,6 +30,16 @@ const emojiReact = (statusId: string, emoji: string, custom?: string) =>
return getClient(getState).statuses.createStatusReaction(statusId, emoji).then((response) => {
dispatch(importEntities({ statuses: [response] }));
const checkEmojiReactsSupport = !response.account.local && useSettingsStore.getState().settings.checkEmojiReactsSupport;
if (checkEmojiReactsSupport) {
supportsEmojiReacts(response.account.ap_id || response.account.url).then((result) => {
if (result === 'false') {
toast.info(intl.formatMessage(messages.unsupported, { acct: response.account.acct }));
}
}).catch((e) => {});
}
}).catch((error) => {
dispatch(emojiReactFail(statusId, emoji, error));
});

View File

@ -1,12 +1,13 @@
import { getAuthUserUrl, getMeUrl } from 'pl-fe/utils/auth';
import { getClient } from '../api';
import { getClient, staticFetch } from '../api';
import type { Instance } from 'pl-api';
import type { AppDispatch, RootState } from 'pl-fe/store';
const INSTANCE_FETCH_SUCCESS = 'INSTANCE_FETCH_SUCCESS' as const;
const INSTANCE_FETCH_FAIL = 'INSTANCE_FETCH_FAIL' as const;
const STANDALONE_CHECK_SUCCESS = 'STANDALONE_CHECK_SUCCESS' as const;
/** Figure out the appropriate instance to fetch depending on the state */
const getHost = (state: RootState) => {
@ -39,14 +40,27 @@ const fetchInstance = () => async (dispatch: AppDispatch, getState: () => RootSt
}
};
interface StandaloneCheckSuccessAction {
type: typeof STANDALONE_CHECK_SUCCESS;
ok: boolean;
}
const checkIfStandalone = () => (dispatch: AppDispatch) =>
staticFetch('/api/v1/instance')
.then(({ ok }) => dispatch<StandaloneCheckSuccessAction>({ type: STANDALONE_CHECK_SUCCESS, ok }))
.catch((err) => dispatch<StandaloneCheckSuccessAction>({ type: STANDALONE_CHECK_SUCCESS, ok: err.response?.ok }));
type InstanceAction =
InstanceFetchSuccessAction
| InstanceFetchFailAction
| StandaloneCheckSuccessAction
export {
INSTANCE_FETCH_SUCCESS,
INSTANCE_FETCH_FAIL,
STANDALONE_CHECK_SUCCESS,
getHost,
fetchInstance,
checkIfStandalone,
type InstanceAction,
};

View File

@ -490,13 +490,13 @@ const WrenchButton: React.FC<IActionButton> = ({
if (wrenches?.me) {
dispatch(unEmojiReact(status.id, '🔧'));
} else {
dispatch(emojiReact(status.id, '🔧'));
dispatch(emojiReact(status.id, '🔧', undefined, intl));
}
};
const handleWrenchLongPress = () => {
if (features.customEmojiReacts && hasLongerWrench) {
dispatch(emojiReact(status.id, hasLongerWrench.shortcode, hasLongerWrench.url));
dispatch(emojiReact(status.id, hasLongerWrench.shortcode, hasLongerWrench.url, intl));
} else if (wrenches?.count) {
openModal('REACTIONS', { statusId: status.id, reaction: wrenches.name });
}
@ -524,11 +524,12 @@ const EmojiPickerButton: React.FC<Omit<IActionButton, 'onOpenUnauthorizedModal'>
me,
}) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
const handlePickEmoji = (emoji: EmojiType) => {
dispatch(emojiReact(status.id, emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined));
dispatch(emojiReact(status.id, emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined, intl));
};
return me && !withLabels && features.emojiReacts && (

View File

@ -65,7 +65,7 @@ const StatusReaction: React.FC<IStatusReaction> = ({ reaction, statusId, obfusca
} else if (reaction.me) {
dispatch(unEmojiReact(statusId, reaction.name));
} else {
dispatch(emojiReact(statusId, reaction.name, reaction.url));
dispatch(emojiReact(statusId, reaction.name, reaction.url, intl));
}
};
@ -112,7 +112,7 @@ const StatusReactionsBar: React.FC<IStatusReactionsBar> = ({ status, collapsed }
const features = useFeatures();
const handlePickEmoji = (emoji: EmojiType) => {
dispatch(emojiReact(status.id, emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined));
dispatch(emojiReact(status.id, emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined, intl));
};
if ((demetricator || status.emoji_reactions.length === 0) && collapsed) return null;

View File

@ -10,11 +10,13 @@ import HStack from 'pl-fe/components/ui/hstack';
import { Mutliselect, SelectDropdown } from 'pl-fe/features/forms';
import SettingToggle from 'pl-fe/features/settings/components/setting-toggle';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useFeatures } from 'pl-fe/hooks/use-features';
import { useInstance } from 'pl-fe/hooks/use-instance';
import { usePlFeConfig } from 'pl-fe/hooks/use-pl-fe-config';
import { useSettings } from 'pl-fe/hooks/use-settings';
import colors from 'pl-fe/utils/colors';
import { isStandalone } from 'pl-fe/utils/state';
import { PaletteListItem } from '../theme-editor';
import ThemeToggle from '../ui/components/theme-toggle';
@ -114,6 +116,7 @@ const Preferences = () => {
const settings = useSettings();
const plFeConfig = usePlFeConfig();
const instance = useInstance();
const standalone = useAppSelector(isStandalone);
const brandColor = settings.theme?.brandColor || plFeConfig.brandColor || '#d80482';
@ -270,6 +273,15 @@ const Preferences = () => {
<SettingToggle settings={settings} settingPath={['showWrenchButton']} onChange={onToggleChange} />
</ListItem>
)}
{features.emojiReacts && standalone && (
<ListItem
label={<FormattedMessage id='preferences.fields.check_emoji_react_supports_label' defaultMessage='Check whether remote hosts support emoji reactions when reacting' />}
hint={<FormattedMessage id='preferences.fields.check_emoji_react_supports_hint' defaultMessage='This will expose your IP address to the instances youre interacting with.' />}
>
<SettingToggle settings={settings} settingPath={['checkEmojiReactsSupport']} onChange={onToggleChange} />
</ListItem>
)}
</List>
<List>

View File

@ -174,7 +174,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = React.memo(({ chil
// Ex: use /login instead of /auth, but redirect /auth to /login
return (
<Switch>
{standalone && <Redirect from='/' to='/login/external' exact />}
{standalone && !isLoggedIn && <Redirect from='/' to='/login/external' exact />}
<WrappedRoute path='/logout' layout={EmptyLayout} component={LogoutPage} publicRoute exact />

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { IntlProvider } from 'react-intl';
import { fetchInstance } from 'pl-fe/actions/instance';
import { checkIfStandalone, fetchInstance } from 'pl-fe/actions/instance';
import { fetchMe } from 'pl-fe/actions/me';
import { loadPlFeConfig } from 'pl-fe/actions/pl-fe';
import LoadingScreen from 'pl-fe/components/loading-screen';
@ -11,17 +11,17 @@ import { useLocale } from 'pl-fe/hooks/use-locale';
import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
import MESSAGES from 'pl-fe/messages';
import type { AppDispatch } from 'pl-fe/store';
/** Load initial data from the backend */
const loadInitial = () => {
// @ts-ignore
return async(dispatch, getState) => {
// Await for authenticated fetch
await dispatch(fetchMe());
// Await for feature detection
await dispatch(fetchInstance());
// Await for configuration
await dispatch(loadPlFeConfig());
};
const loadInitial = () => async(dispatch: AppDispatch) => {
dispatch(checkIfStandalone());
// Await for authenticated fetch
await dispatch(fetchMe());
// Await for feature detection
await dispatch(fetchInstance());
// Await for configuration
await dispatch(loadPlFeConfig());
};
interface IPlFeLoad {

View File

@ -710,6 +710,7 @@
"emoji_button.symbols": "Symbols",
"emoji_button.travel": "Travel & Places",
"emoji_button.uncategorized": "Uncategorized",
"emoji_reactions.unsupported_by_remote": "@{acct}s instance most likely doesnt understand emoji reactions. The user will not get notified of the reaction.",
"empty_column.account_blocked": "You are blocked by @{accountUsername}.",
"empty_column.account_favourited_statuses": "This user doesn't have any liked posts yet.",
"empty_column.account_timeline": "No posts here!",
@ -1275,6 +1276,8 @@
"preferences.fields.autoload_more_label": "Automatically load more items when scrolled to the bottom of the page",
"preferences.fields.autoload_timelines_label": "Automatically load new posts when scrolled to the top of the page",
"preferences.fields.boost_modal_label": "Show confirmation dialog before reposting",
"preferences.fields.check_emoji_react_supports_hint": "This will expose your IP address to the instances youre interacting with.",
"preferences.fields.check_emoji_react_supports_label": "Check whether remote hosts support emoji reactions when reacting",
"preferences.fields.content_type_label": "Default post format",
"preferences.fields.delete_modal_label": "Show confirmation dialog before deleting a post",
"preferences.fields.demetricator_label": "Hide social media counters",

View File

@ -1,4 +1,4 @@
import { INSTANCE_FETCH_FAIL, type InstanceAction } from 'pl-fe/actions/instance';
import { STANDALONE_CHECK_SUCCESS, type InstanceAction } from 'pl-fe/actions/instance';
const initialState = {
/** Whether /api/v1/instance 404'd (and we should display the external auth form). */
@ -7,11 +7,8 @@ const initialState = {
const meta = (state = initialState, action: InstanceAction): typeof initialState => {
switch (action.type) {
case INSTANCE_FETCH_FAIL:
if ((action.error as any)?.response?.status === 404) {
return { instance_fetch_failed: true };
}
return state;
case STANDALONE_CHECK_SUCCESS:
return { instance_fetch_failed: !action.ok };
default:
return state;
}

View File

@ -48,6 +48,7 @@ const settingsSchema = v.object({
redirectServicesUrl: v.fallback(v.string(), ''),
redirectServices: v.fallback(v.record(v.string(), v.string()), {}),
}),
checkEmojiReactsSupport: v.fallback(v.boolean(), false),
theme: v.fallback(v.optional(v.object({
brandColor: v.fallback(v.string(), ''),
@ -99,6 +100,7 @@ const settingsSchema = v.object({
saved: v.fallback(v.boolean(), true),
demo: v.fallback(v.boolean(), false),
});
type Settings = v.InferOutput<typeof settingsSchema>;

View File

@ -0,0 +1,83 @@
import KVStore from 'pl-fe/storage/kv-store';
type DomainCapabilities = {
lastChecked: number;
} & ({
failed: true;
} | {
failed: false;
emojiReacts: boolean;
});
interface Capabilities {
_version: number;
domains: Record<string, DomainCapabilities>;
}
let capabilities: Capabilities = {
_version: 1,
domains: {},
};
const getCapabilitiesFromMemory = () =>
KVStore.getItem<Capabilities>('instanceCapabilities').then((capabilitiesFromMemory) => {
if (capabilitiesFromMemory) {
capabilities = capabilitiesFromMemory;
}
}).catch(() => { });
const checkEmojiReactsSupport = (instance: Record<string, any>) => instance.configuration?.reactions?.max_reactions > 0 || instance.pleroma?.metadata?.features?.includes('pleroma_emoji_reactions');
const setDomainCapabilities = async (domain: string, domainCapabilities: DomainCapabilities) => {
await getCapabilitiesFromMemory();
capabilities.domains[domain] = domainCapabilities;
await KVStore.setItem('instanceCapabilities', capabilities);
};
const fetchCapabilities = async (domain: string): Promise<DomainCapabilities> => {
try {
const response = await fetch(`https://${domain}/api/v1/instance`, {
headers: {
'Content-Type': 'application/json',
},
}).then((res) => res.json());
const emojiReacts = checkEmojiReactsSupport(response);
const domainCapabilities: DomainCapabilities = {
lastChecked: Date.now(),
failed: false,
emojiReacts,
};
await setDomainCapabilities(domain, domainCapabilities);
return domainCapabilities;
} catch (e) {
const domainCapabilities: DomainCapabilities = {
lastChecked: Date.now(),
failed: true,
};
await setDomainCapabilities(domain, domainCapabilities);
return domainCapabilities;
}
};
const supportsEmojiReacts = async (accountUrl: string): Promise<'true' | 'false' | 'unknown'> => {
const domain = new URL(accountUrl).hostname;
let domainCapabilities = capabilities.domains[domain];
if (!domainCapabilities || domainCapabilities.lastChecked > Date.now() - 1000 * 60 * 60 * 24 * (domainCapabilities.failed ? 1 : 7)) {
domainCapabilities = await fetchCapabilities(domain);
}
if (domainCapabilities.failed) {
return 'unknown';
}
if (domainCapabilities.emojiReacts) {
return 'true';
}
return 'false';
};
getCapabilitiesFromMemory();
export { supportsEmojiReacts };