From 390341d25e9caa0803ff7644d1c14860513cf4ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicole=20Miko=C5=82ajczyk?= Date: Wed, 16 Apr 2025 22:31:34 +0200 Subject: [PATCH] pl-fe: Allow checking whether remote instances support emoji reactions when reacting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Nicole Mikołajczyk --- packages/pl-fe/src/actions/emoji-reacts.ts | 21 ++++- packages/pl-fe/src/actions/instance.ts | 16 +++- .../src/components/status-action-bar.tsx | 7 +- .../src/components/status-reactions-bar.tsx | 4 +- .../pl-fe/src/features/preferences/index.tsx | 12 +++ packages/pl-fe/src/features/ui/index.tsx | 2 +- packages/pl-fe/src/init/pl-fe-load.tsx | 22 ++--- packages/pl-fe/src/locales/en.json | 3 + packages/pl-fe/src/reducers/meta.ts | 9 +- packages/pl-fe/src/schemas/pl-fe/settings.ts | 2 + .../src/utils/check-instance-capability.ts | 83 +++++++++++++++++++ 11 files changed, 156 insertions(+), 25 deletions(-) create mode 100644 packages/pl-fe/src/utils/check-instance-capability.ts diff --git a/packages/pl-fe/src/actions/emoji-reacts.ts b/packages/pl-fe/src/actions/emoji-reacts.ts index d78b9ccba..97e70612c 100644 --- a/packages/pl-fe/src/actions/emoji-reacts.ts +++ b/packages/pl-fe/src/actions/emoji-reacts.ts @@ -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 doesn’t 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)); }); diff --git a/packages/pl-fe/src/actions/instance.ts b/packages/pl-fe/src/actions/instance.ts index 4363b121e..a4be5b9ca 100644 --- a/packages/pl-fe/src/actions/instance.ts +++ b/packages/pl-fe/src/actions/instance.ts @@ -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({ type: STANDALONE_CHECK_SUCCESS, ok })) + .catch((err) => dispatch({ 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, }; diff --git a/packages/pl-fe/src/components/status-action-bar.tsx b/packages/pl-fe/src/components/status-action-bar.tsx index b98132214..468938a49 100644 --- a/packages/pl-fe/src/components/status-action-bar.tsx +++ b/packages/pl-fe/src/components/status-action-bar.tsx @@ -490,13 +490,13 @@ const WrenchButton: React.FC = ({ 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 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 && ( diff --git a/packages/pl-fe/src/components/status-reactions-bar.tsx b/packages/pl-fe/src/components/status-reactions-bar.tsx index 29b3c97f1..91549affa 100644 --- a/packages/pl-fe/src/components/status-reactions-bar.tsx +++ b/packages/pl-fe/src/components/status-reactions-bar.tsx @@ -65,7 +65,7 @@ const StatusReaction: React.FC = ({ 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 = ({ 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; diff --git a/packages/pl-fe/src/features/preferences/index.tsx b/packages/pl-fe/src/features/preferences/index.tsx index b9509e6e7..fc1e84d73 100644 --- a/packages/pl-fe/src/features/preferences/index.tsx +++ b/packages/pl-fe/src/features/preferences/index.tsx @@ -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 = () => { )} + + {features.emojiReacts && standalone && ( + } + hint={} + > + + + )} diff --git a/packages/pl-fe/src/features/ui/index.tsx b/packages/pl-fe/src/features/ui/index.tsx index 952a5733a..04a363703 100644 --- a/packages/pl-fe/src/features/ui/index.tsx +++ b/packages/pl-fe/src/features/ui/index.tsx @@ -174,7 +174,7 @@ const SwitchingColumnsArea: React.FC = React.memo(({ chil // Ex: use /login instead of /auth, but redirect /auth to /login return ( - {standalone && } + {standalone && !isLoggedIn && } diff --git a/packages/pl-fe/src/init/pl-fe-load.tsx b/packages/pl-fe/src/init/pl-fe-load.tsx index cfcc6be64..cb1d54aec 100644 --- a/packages/pl-fe/src/init/pl-fe-load.tsx +++ b/packages/pl-fe/src/init/pl-fe-load.tsx @@ -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 { diff --git a/packages/pl-fe/src/locales/en.json b/packages/pl-fe/src/locales/en.json index dfa4aa04c..c96d7d699 100644 --- a/packages/pl-fe/src/locales/en.json +++ b/packages/pl-fe/src/locales/en.json @@ -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 doesn’t 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 you’re 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", diff --git a/packages/pl-fe/src/reducers/meta.ts b/packages/pl-fe/src/reducers/meta.ts index 78e5e9f3b..3e8b47abd 100644 --- a/packages/pl-fe/src/reducers/meta.ts +++ b/packages/pl-fe/src/reducers/meta.ts @@ -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; } diff --git a/packages/pl-fe/src/schemas/pl-fe/settings.ts b/packages/pl-fe/src/schemas/pl-fe/settings.ts index d94cd6196..db1d1920f 100644 --- a/packages/pl-fe/src/schemas/pl-fe/settings.ts +++ b/packages/pl-fe/src/schemas/pl-fe/settings.ts @@ -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; diff --git a/packages/pl-fe/src/utils/check-instance-capability.ts b/packages/pl-fe/src/utils/check-instance-capability.ts new file mode 100644 index 000000000..11cee7001 --- /dev/null +++ b/packages/pl-fe/src/utils/check-instance-capability.ts @@ -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; +} + +let capabilities: Capabilities = { + _version: 1, + domains: {}, +}; + +const getCapabilitiesFromMemory = () => + KVStore.getItem('instanceCapabilities').then((capabilitiesFromMemory) => { + if (capabilitiesFromMemory) { + capabilities = capabilitiesFromMemory; + } + }).catch(() => { }); + +const checkEmojiReactsSupport = (instance: Record) => 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 => { + 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 };