pl-fe: migrate status translations to tanstack query

Signed-off-by: mkljczk <git@mkljczk.pl>
This commit is contained in:
mkljczk 2024-11-30 21:01:30 +01:00
parent a99c61d454
commit f0542c2a62
8 changed files with 134 additions and 145 deletions

View file

@ -9,7 +9,7 @@ import { setComposeToStatus } from './compose';
import { importEntities } from './importer';
import { deleteFromTimelines } from './timelines';
import type { CreateStatusParams, Status as BaseStatus, ScheduledStatus, Translation } from 'pl-api';
import type { CreateStatusParams, Status as BaseStatus, ScheduledStatus } from 'pl-api';
import type { Status } from 'pl-fe/normalizers/status';
import type { AppDispatch, RootState } from 'pl-fe/store';
import type { IntlShape } from 'react-intl';
@ -48,11 +48,6 @@ const STATUS_HIDE_MEDIA = 'STATUS_HIDE_MEDIA' as const;
const STATUS_EXPAND_SPOILER = 'STATUS_EXPAND_SPOILER' as const;
const STATUS_COLLAPSE_SPOILER = 'STATUS_COLLAPSE_SPOILER' as const;
const STATUS_TRANSLATE_REQUEST = 'STATUS_TRANSLATE_REQUEST' as const;
const STATUS_TRANSLATE_SUCCESS = 'STATUS_TRANSLATE_SUCCESS' as const;
const STATUS_TRANSLATE_FAIL = 'STATUS_TRANSLATE_FAIL' as const;
const STATUS_TRANSLATE_UNDO = 'STATUS_TRANSLATE_UNDO' as const;
const STATUS_UNFILTER = 'STATUS_UNFILTER' as const;
const STATUS_LANGUAGE_CHANGE = 'STATUS_LANGUAGE_CHANGE' as const;
@ -269,75 +264,54 @@ const expandStatusSpoiler = (statusIds: string[] | string) => {
};
};
let TRANSLATIONS_QUEUE: Set<string> = new Set();
let TRANSLATIONS_TIMEOUT: NodeJS.Timeout | null = null;
// let TRANSLATIONS_QUEUE: Set<string> = new Set();
// let TRANSLATIONS_TIMEOUT: NodeJS.Timeout | null = null;
const translateStatus = (statusId: string, targetLanguage: string, lazy?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const client = getClient(getState);
const features = client.features;
// const translateStatus = (statusId: string, targetLanguage: string, lazy?: boolean) =>
// (dispatch: AppDispatch, getState: () => RootState) => {
// const client = getClient(getState);
// const features = client.features;
dispatch<StatusesAction>({ type: STATUS_TRANSLATE_REQUEST, statusId });
// const handleTranslateMany = () => {
// const copy = [...TRANSLATIONS_QUEUE];
// TRANSLATIONS_QUEUE = new Set();
// if (TRANSLATIONS_TIMEOUT) clearTimeout(TRANSLATIONS_TIMEOUT);
const handleTranslateMany = () => {
const copy = [...TRANSLATIONS_QUEUE];
TRANSLATIONS_QUEUE = new Set();
if (TRANSLATIONS_TIMEOUT) clearTimeout(TRANSLATIONS_TIMEOUT);
// return client.statuses.translateStatuses(copy, targetLanguage).then((response) => {
// response.forEach((translation) => {
// dispatch<StatusesAction>({
// type: STATUS_TRANSLATE_SUCCESS,
// statusId: translation.id,
// translation: translation,
// });
return client.statuses.translateStatuses(copy, targetLanguage).then((response) => {
response.forEach((translation) => {
dispatch<StatusesAction>({
type: STATUS_TRANSLATE_SUCCESS,
statusId: translation.id,
translation: translation,
});
// copy
// .filter((statusId) => !response.some(({ id }) => id === statusId))
// .forEach((statusId) => dispatch<StatusesAction>({
// type: STATUS_TRANSLATE_FAIL,
// statusId,
// }));
// });
// }).catch(error => {
// dispatch<StatusesAction>({
// type: STATUS_TRANSLATE_FAIL,
// statusId,
// error,
// });
// });
// };
copy
.filter((statusId) => !response.some(({ id }) => id === statusId))
.forEach((statusId) => dispatch<StatusesAction>({
type: STATUS_TRANSLATE_FAIL,
statusId,
}));
});
}).catch(error => {
dispatch<StatusesAction>({
type: STATUS_TRANSLATE_FAIL,
statusId,
error,
});
});
};
// if (features.lazyTranslations && lazy) {
// TRANSLATIONS_QUEUE.add(statusId);
if (features.lazyTranslations && lazy) {
TRANSLATIONS_QUEUE.add(statusId);
// if (TRANSLATIONS_TIMEOUT) clearTimeout(TRANSLATIONS_TIMEOUT);
// TRANSLATIONS_TIMEOUT = setTimeout(() => handleTranslateMany(), 3000);
// } else if (features.lazyTranslations && TRANSLATIONS_QUEUE.size) {
// TRANSLATIONS_QUEUE.add(statusId);
if (TRANSLATIONS_TIMEOUT) clearTimeout(TRANSLATIONS_TIMEOUT);
TRANSLATIONS_TIMEOUT = setTimeout(() => handleTranslateMany(), 3000);
} else if (features.lazyTranslations && TRANSLATIONS_QUEUE.size) {
TRANSLATIONS_QUEUE.add(statusId);
handleTranslateMany();
} else {
return client.statuses.translateStatus(statusId, targetLanguage).then(response => {
dispatch<StatusesAction>({
type: STATUS_TRANSLATE_SUCCESS,
statusId,
translation: response,
});
}).catch(error => {
dispatch<StatusesAction>({
type: STATUS_TRANSLATE_FAIL,
statusId,
error,
});
});
}
};
const undoStatusTranslation = (statusId: string) => ({
type: STATUS_TRANSLATE_UNDO,
statusId,
});
// handleTranslateMany();
// }
// };
const unfilterStatus = (statusId: string) => ({
type: STATUS_UNFILTER,
@ -376,10 +350,6 @@ type StatusesAction =
| ReturnType<typeof revealStatusMedia>
| ReturnType<typeof collapseStatusSpoiler>
| ReturnType<typeof expandStatusSpoiler>
| { type: typeof STATUS_TRANSLATE_REQUEST; statusId: string }
| { type: typeof STATUS_TRANSLATE_SUCCESS; statusId: string | null; translation: Translation }
| { type: typeof STATUS_TRANSLATE_FAIL; statusId: string; error?: unknown }
| ReturnType<typeof undoStatusTranslation>
| ReturnType<typeof unfilterStatus>
| ReturnType<typeof changeStatusLanguage>;
@ -409,10 +379,6 @@ export {
STATUS_HIDE_MEDIA,
STATUS_EXPAND_SPOILER,
STATUS_COLLAPSE_SPOILER,
STATUS_TRANSLATE_REQUEST,
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_FAIL,
STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER,
STATUS_LANGUAGE_CHANGE,
createStatus,
@ -430,8 +396,6 @@ export {
toggleStatusMediaHidden,
expandStatusSpoiler,
collapseStatusSpoiler,
translateStatus,
undoStatusTranslation,
unfilterStatus,
changeStatusLanguage,
type StatusesAction,

View file

@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'pl-fe/hooks/use-client';
import type { Translation } from 'pl-api';
const useStatusTranslation = (statusId: string, targetLanguage?: string) => {
const client = useClient();
return useQuery<Translation | false>({
queryKey: ['statuses', 'translations', statusId, targetLanguage],
queryFn: () => client.statuses.translateStatus(statusId, targetLanguage)
.then(translation => translation).catch(() => false),
enabled: !!targetLanguage,
});
};
export { useStatusTranslation };

View file

@ -11,7 +11,7 @@ import { toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog
import { deleteStatusModal, toggleStatusSensitivityModal } from 'pl-fe/actions/moderation';
import { initReport, ReportableEntities } from 'pl-fe/actions/reports';
import { changeSetting } from 'pl-fe/actions/settings';
import { deleteStatus, editStatus, toggleMuteStatus, translateStatus, undoStatusTranslation } from 'pl-fe/actions/statuses';
import { deleteStatus, editStatus, toggleMuteStatus } from 'pl-fe/actions/statuses';
import { deleteFromTimelines } from 'pl-fe/actions/timelines';
import { useBlockGroupMember } from 'pl-fe/api/hooks/groups/use-block-group-member';
import { useDeleteGroupStatus } from 'pl-fe/api/hooks/groups/use-delete-group-status';
@ -33,6 +33,7 @@ import { useSettings } from 'pl-fe/hooks/use-settings';
import { useChats } from 'pl-fe/queries/chats';
import { RootState } from 'pl-fe/store';
import { useModalsStore } from 'pl-fe/stores/modals';
import { useStatusMetaStore } from 'pl-fe/stores/status-meta';
import toast from 'pl-fe/toast';
import copy from 'pl-fe/utils/copy';
@ -574,7 +575,6 @@ interface IMenuButton extends IActionButton {
const MenuButton: React.FC<IMenuButton> = ({
status,
statusActionButtonTheme,
withLabels,
me,
expandable,
fromBookmarks,
@ -585,6 +585,8 @@ const MenuButton: React.FC<IMenuButton> = ({
const match = useRouteMatch<{ groupId: string }>('/groups/:groupId');
const { boostModal } = useSettings();
const { statuses: statusesMeta, fetchTranslation, hideTranslation } = useStatusMetaStore();
const targetLanguage = statusesMeta[status.id]?.targetLanguage;
const { openModal } = useModalsStore();
const { group } = useGroup((status.group as Group)?.id as string);
const deleteGroupStatus = useDeleteGroupStatus(group as Group, status.id);
@ -774,10 +776,10 @@ const MenuButton: React.FC<IMenuButton> = ({
};
const handleTranslate = () => {
if (status.translation) {
dispatch(undoStatusTranslation(status.id));
if (targetLanguage) {
hideTranslation(status.id);
} else {
dispatch(translateStatus(status.id, intl.locale));
fetchTranslation(status.id, intl.locale);
}
};
@ -941,7 +943,7 @@ const MenuButton: React.FC<IMenuButton> = ({
}
if (autoTranslating) {
if (status.translation) {
if (targetLanguage) {
menu.push({
text: intl.formatMessage(messages.hideTranslation),
action: handleTranslate,

View file

@ -3,6 +3,7 @@ import React, { useState, useRef, useLayoutEffect, useMemo, useEffect } from 're
import { FormattedMessage } from 'react-intl';
import { collapseStatusSpoiler, expandStatusSpoiler } from 'pl-fe/actions/statuses';
import { useStatusTranslation } from 'pl-fe/api/hooks/statuses/use-status-translation';
import Icon from 'pl-fe/components/icon';
import Button from 'pl-fe/components/ui/button';
import Stack from 'pl-fe/components/ui/stack';
@ -11,6 +12,7 @@ import Emojify from 'pl-fe/features/emoji/emojify';
import QuotedStatus from 'pl-fe/features/status/containers/quoted-status-container';
import { useAppDispatch } from 'pl-fe/hooks/use-app-dispatch';
import { useSettings } from 'pl-fe/hooks/use-settings';
import { useStatusMetaStore } from 'pl-fe/stores/status-meta';
import { onlyEmoji as isOnlyEmoji } from 'pl-fe/utils/rich-content';
import { getTextDirection } from '../utils/rtl';
@ -91,6 +93,9 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
const node = useRef<HTMLDivElement>(null);
const spoilerNode = useRef<HTMLSpanElement>(null);
const { statuses: statusesMeta } = useStatusMetaStore();
const { data: translation } = useStatusTranslation(status.id, statusesMeta[status.id]?.targetLanguage);
const maybeSetCollapsed = (): void => {
if (!node.current) return;
@ -125,12 +130,12 @@ const StatusContent: React.FC<IStatusContent> = React.memo(({
});
const content = useMemo(
(): string => translatable && status.translation
? status.translation.content!
(): string => translation
? translation.content
: (status.content_map && status.currentLanguage)
? (status.content_map[status.currentLanguage] || status.content)
: status.content,
[status.content, status.translation, status.currentLanguage],
[status.content, translation, status.currentLanguage],
);
const { content: parsedContent, hashtags } = useMemo(() => parseContent({

View file

@ -1,26 +1,25 @@
import React, { useEffect } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { translateStatus, undoStatusTranslation } from 'pl-fe/actions/statuses';
import { useTranslationLanguages } from 'pl-fe/api/hooks/instance/use-translation-languages';
import { useStatusTranslation } from 'pl-fe/api/hooks/statuses/use-status-translation';
import HStack from 'pl-fe/components/ui/hstack';
import Icon from 'pl-fe/components/ui/icon';
import Stack from 'pl-fe/components/ui/stack';
import Text from 'pl-fe/components/ui/text';
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 { useSettings } from 'pl-fe/hooks/use-settings';
import { useStatusMetaStore } from 'pl-fe/stores/status-meta';
import type { Status } from 'pl-fe/normalizers/status';
interface ITranslateButton {
status: Pick<Status, 'id' | 'account' | 'content' | 'content_map' | 'language' | 'translating' | 'translation' | 'visibility'>;
status: Pick<Status, 'id' | 'account' | 'content' | 'content_map' | 'language' | 'visibility'>;
}
const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
const instance = useInstance();
@ -30,6 +29,10 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
const me = useAppSelector((state) => state.me);
const { translationLanguages } = useTranslationLanguages();
const { statuses: statusesMeta, fetchTranslation, hideTranslation } = useStatusMetaStore();
const targetLanguage = statusesMeta[status.id]?.targetLanguage;
const translationQuery = useStatusTranslation(status.id, targetLanguage);
const {
allow_remote: allowRemote,
@ -43,45 +46,45 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
const handleTranslate: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
if (status.translation) {
dispatch(undoStatusTranslation(status.id));
if (targetLanguage) {
hideTranslation(status.id);
} else {
dispatch(translateStatus(status.id, intl.locale));
fetchTranslation(status.id, intl.locale);
}
};
useEffect(() => {
if (status.translation === null && settings.autoTranslate && features.translations && renderTranslate && supportsLanguages && status.translation !== false && status.language !== null && !knownLanguages.includes(status.language)) {
dispatch(translateStatus(status.id, intl.locale, true));
if (translationQuery.data === undefined && settings.autoTranslate && features.translations && renderTranslate && supportsLanguages && translationQuery.data !== false && status.language !== null && !knownLanguages.includes(status.language)) {
fetchTranslation(status.id, intl.locale);
}
}, []);
if (!features.translations || !renderTranslate || !supportsLanguages || status.translation === false) return null;
if (!features.translations || !renderTranslate || !supportsLanguages || translationQuery.data === false) return null;
const button = (
<button className='w-fit' onClick={handleTranslate}>
<HStack alignItems='center' space={1} className='text-primary-600 hover:underline dark:text-gray-600'>
<Icon src={require('@tabler/icons/outline/language.svg')} className='size-4' />
<span>
{status.translation ? (
{translationQuery.data ? (
<FormattedMessage id='status.show_original' defaultMessage='Show original' />
) : status.translating ? (
) : translationQuery.isLoading ? (
<FormattedMessage id='status.translating' defaultMessage='Translating…' />
) : (
<FormattedMessage id='status.translate' defaultMessage='Translate' />
)}
</span>
{status.translating && (
{translationQuery.isLoading && (
<Icon src={require('@tabler/icons/outline/loader-2.svg')} className='size-4 animate-spin' />
)}
</HStack>
</button>
);
if (status.translation) {
if (translationQuery.data) {
const languageNames = new Intl.DisplayNames([intl.locale], { type: 'language' });
const languageName = languageNames.of(status.language!);
const provider = status.translation.provider;
const provider = translationQuery.data.provider;
return (
<Stack space={3} alignItems='start'>

View file

@ -3,7 +3,7 @@
* Converts API statuses into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/status/}
*/
import { type Account as BaseAccount, type Status as BaseStatus, type MediaAttachment, mentionSchema, type Translation } from 'pl-api';
import { type Account as BaseAccount, type Status as BaseStatus, type MediaAttachment, mentionSchema } from 'pl-api';
import * as v from 'valibot';
import { unescapeHTML } from 'pl-fe/utils/html';
@ -20,7 +20,6 @@ type CalculatedValues = {
search_index: string;
expanded?: boolean | null;
hidden?: boolean | null;
translation?: Translation | null | false;
currentLanguage?: string;
};
@ -57,11 +56,11 @@ const buildSearchContent = (status: Pick<BaseStatus, 'poll' | 'mentions' | 'spoi
const calculateStatus = (status: BaseStatus, oldStatus?: OldStatus): CalculatedValues => {
if (oldStatus && oldStatus.content === status.content && oldStatus.spoiler_text === status.spoiler_text) {
const {
search_index, hidden, expanded, translation, currentLanguage,
search_index, hidden, expanded, currentLanguage,
} = oldStatus;
return {
search_index, hidden, expanded, translation, currentLanguage,
search_index, hidden, expanded, currentLanguage,
};
} else {
const searchContent = buildSearchContent(status);
@ -129,7 +128,6 @@ const normalizeStatus = (status: BaseStatus & {
reblog_id: status.reblog?.id || null,
poll_id: status.poll?.id || null,
group_id: status.group?.id || null,
translating: false,
expectsCard: false,
showFiltered: null as null | boolean,
...status,
@ -144,7 +142,6 @@ const normalizeStatus = (status: BaseStatus & {
group,
media_attachments,
...calculated,
translation: (status.translation || calculated.translation || null) as Translation | null | false,
};
};

View file

@ -39,10 +39,6 @@ import {
STATUS_HIDE_MEDIA,
STATUS_MUTE_SUCCESS,
STATUS_REVEAL_MEDIA,
STATUS_TRANSLATE_FAIL,
STATUS_TRANSLATE_REQUEST,
STATUS_TRANSLATE_SUCCESS,
STATUS_TRANSLATE_UNDO,
STATUS_UNFILTER,
STATUS_UNMUTE_SUCCESS,
STATUS_LANGUAGE_CHANGE,
@ -52,7 +48,7 @@ import {
} from '../actions/statuses';
import { TIMELINE_DELETE, type TimelineAction } from '../actions/timelines';
import type { Status as BaseStatus, CreateStatusParams, Translation } from 'pl-api';
import type { Status as BaseStatus, CreateStatusParams } from 'pl-api';
type State = Record<string, MinifiedStatus>;
@ -162,18 +158,6 @@ const simulateDislike = (
state[statusId] = updatedStatus;
};
/** Import translation from translation service into the store. */
const importTranslation = (state: State, statusId: string, translation: Translation) => {
if (!state[statusId]) return;
state[statusId].translation = translation;
state[statusId].translating = false;
};
/** Delete translation from the store. */
const deleteTranslation = (state: State, statusId: string) => {
state[statusId].translation = null;
};
const initialState: State = {};
const statuses = (state = initialState, action: EmojiReactsAction | EventsAction | ImporterAction | InteractionsAction | StatusesAction | TimelineAction): State => {
@ -300,7 +284,6 @@ const statuses = (state = initialState, action: EmojiReactsAction | EventsAction
const status = draft[id];
if (status) {
status.expanded = false;
status.translation = false;
}
});
});
@ -308,25 +291,6 @@ const statuses = (state = initialState, action: EmojiReactsAction | EventsAction
return create(state, (draft) => decrementReplyCount(draft, action.params));
case STATUS_DELETE_FAIL:
return create(state, (draft) => incrementReplyCount(draft, action.params));
case STATUS_TRANSLATE_REQUEST:
return create(state, (draft) => {
const status = draft[action.statusId];
if (status) {
status.translating = true;
}
});
case STATUS_TRANSLATE_SUCCESS:
return action.statusId !== null ? create(state, (draft) => importTranslation(draft, action.statusId!, action.translation)) : state;
case STATUS_TRANSLATE_FAIL:
return create(state, (draft) => {
const status = draft[action.statusId];
if (status) {
status.translating = false;
status.translation = false;
}
});
case STATUS_TRANSLATE_UNDO:
return create(state, (draft) => deleteTranslation(draft, action.statusId));
case STATUS_UNFILTER:
return create(state, (draft) => {
const status = draft[action.statusId];

View file

@ -0,0 +1,36 @@
import { create } from 'zustand';
import { mutative } from 'zustand-mutative';
type State = {
statuses: Record<string, { visible?: boolean; targetLanguage?: string }>;
revealStatus: (statusId: string) => void;
hideStatus: (statusId: string) => void;
fetchTranslation: (statusId: string, targetLanguage: string) => void;
hideTranslation: (statusId: string) => void;
};
const useStatusMetaStore = create<State>()(mutative((set) => ({
statuses: {},
revealStatus: (statusId) => set((state: State) => {
if (!state.statuses[statusId]) state.statuses[statusId] = {};
state.statuses[statusId].visible = true;
}),
hideStatus: (statusId) => set((state: State) => {
if (!state.statuses[statusId]) state.statuses[statusId] = {};
state.statuses[statusId].visible = false;
}),
fetchTranslation: (statusId, targetLanguage) => set((state: State) => {
if (!state.statuses[statusId]) state.statuses[statusId] = {};
state.statuses[statusId].targetLanguage = targetLanguage;
}),
hideTranslation: (statusId) => set((state: State) => {
if (!state.statuses[statusId]) state.statuses[statusId] = {};
state.statuses[statusId].targetLanguage = undefined;
}),
})));
export { useStatusMetaStore };