Files
Mastodon/app/javascript/mastodon/components/status/hooks.ts
T

263 lines
6.8 KiB
TypeScript

import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router';
import { mentionComposeById } from '@/mastodon/actions/compose';
import type { StatusInteractionIntent } from '@/mastodon/actions/interactions_typed';
import { statusInteraction } from '@/mastodon/actions/interactions_typed';
import { openModal } from '@/mastodon/actions/modal';
import { toggleStatusSpoilers } from '@/mastodon/actions/statuses';
import { useExpandedStatus } from '@/mastodon/hooks/useStatus';
import { useToggle } from '@/mastodon/hooks/useToggle';
import type { ExpandedStatusShape } from '@/mastodon/models/status';
import { selectStatusFilters } from '@/mastodon/selectors/filters';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import { FOCUS_TARGET } from '../navigation_focus_target';
import type { StatusContextType } from './types';
const messages = defineMessages({
quote_noun: {
id: 'status.quote_noun',
defaultMessage: 'Quote',
description: 'Quote as a noun',
},
contains_quote: {
id: 'status.contains_quote',
defaultMessage: 'Contains quote',
},
boosted: { id: 'status.reblogged_by', defaultMessage: '{name} boosted' },
});
export function useStatusHandlers({
status,
contextType,
onClick,
}: {
status?: ExpandedStatusShape;
contextType?: StatusContextType;
onClick?: () => void;
}) {
const matchedFilters = useAppSelector((state) =>
selectStatusFilters(state, {}),
);
const [showDespiteFilter, { onToggle: onFilterToggle }] = useToggle(false);
const dispatch = useAppDispatch();
const statusId = status?.id;
// Display handlers
const onExpandedToggle = useCallback(() => {
dispatch(toggleStatusSpoilers(statusId));
}, [dispatch, statusId]);
const onToggleHidden = useCallback(() => {
if (!status) {
return;
}
if (!matchedFilters.length || showDespiteFilter) {
dispatch(toggleStatusSpoilers(status.id));
}
if (!status.hidden || !status.spoiler_text) {
onFilterToggle();
}
}, [
dispatch,
matchedFilters.length,
onFilterToggle,
showDespiteFilter,
status,
]);
// Interaction handlers
const handlerFactory = useCallback(
(intent: StatusInteractionIntent) => {
return () => {
dispatch(statusInteraction({ statusId, intent, contextType }));
};
},
[contextType, dispatch, statusId],
);
const accountId = status?.account.id;
const onMention = useCallback(() => {
dispatch(mentionComposeById(accountId));
}, [dispatch, accountId]);
// Navigation handlers
const history = useHistory();
const onOpen = useCallback(
(newTab = false) => {
if (onClick || !status) {
onClick?.();
return;
}
const path = `/@${status.account.acct}/${status.id}`;
if (newTab) {
window.open(path, '_blank', 'noopener');
} else if (history.location.pathname.replace('/deck/', '/') === path) {
history.replace(path, { focusTarget: FOCUS_TARGET.POST });
} else {
history.push(path, { focusTarget: FOCUS_TARGET.POST });
}
},
[history, onClick, status],
);
const onOpenClick: React.MouseEventHandler = useCallback(
(event) => {
event.preventDefault();
if (event.button === 0 && !(event.ctrlKey || event.metaKey)) {
onOpen();
} else if (
event.button === 1 ||
(event.button === 0 && (event.ctrlKey || event.metaKey))
) {
onOpen(true);
}
},
[onOpen],
);
const onHeaderClick: React.MouseEventHandler = useCallback(
(event) => {
// Only handle clicks on the empty space above the content
if (event.target !== event.currentTarget && event.detail >= 1) {
return;
}
onOpenClick(event);
},
[onOpenClick],
);
const acct = status?.account.acct;
const onOpenProfile = useCallback(() => {
if (acct) {
history.push(`/@${acct}`);
}
}, [history, acct]);
const onOpenMedia = useCallback(() => {
const attachment = status?.media_attachments[0];
if (!attachment) {
return;
}
const lang = status.translation?.language ?? status.language;
if (attachment.type === 'video') {
dispatch(
openModal({
modalType: 'VIDEO',
modalProps: {
statusId: status.id,
media: attachment,
lang,
options: { startTime: 0 },
},
}),
);
} else {
dispatch(
openModal({
modalType: 'MEDIA',
modalProps: {
statusId: status.id,
media: status.media_attachments,
lang,
index: 0,
},
}),
);
}
}, [dispatch, status]);
return useMemo(
() => ({
showDespiteFilter,
onOpenClick,
onExpandedToggle,
onFilterToggle,
onHeaderClick,
onMention,
onOpen,
onOpenMedia,
onOpenProfile,
onToggleHidden,
onReply: handlerFactory('reply'),
onFavourite: handlerFactory('favourite'),
onBoost: handlerFactory('reblog'),
onQuote: handlerFactory('quote'),
onTranslate: handlerFactory('translate'),
}),
[
handlerFactory,
onExpandedToggle,
onFilterToggle,
onHeaderClick,
onMention,
onOpen,
onOpenClick,
onOpenMedia,
onOpenProfile,
onToggleHidden,
showDespiteFilter,
],
);
}
export type StatusHandlers = ReturnType<typeof useStatusHandlers>;
const domParser = new DOMParser();
export function useTextForScreenReader({
statusId,
reblogAcct,
isQuote = false,
}: {
statusId?: string | null;
reblogAcct?: string;
isQuote?: boolean;
}) {
const intl = useIntl();
const status = useExpandedStatus(statusId);
return useMemo(() => {
if (!status) {
return '';
}
const displayName = status.account.display_name;
const spoilerText = status.translation?.spoiler_text ?? status.spoiler_text;
const contentHtml = status.translation?.contentHtml ?? status.contentHtml;
const contentText = domParser.parseFromString(contentHtml, 'text/html')
.documentElement.textContent;
const values = [
isQuote ? intl.formatMessage(messages.quote_noun) : undefined,
displayName.length === 0
? status.account.acct.split('@')[0]
: displayName,
spoilerText && status.hidden ? spoilerText : contentText,
status.quote ? intl.formatMessage(messages.contains_quote) : undefined,
intl.formatDate(status.created_at, {
hour: '2-digit',
minute: '2-digit',
month: 'short',
day: 'numeric',
}),
status.account.acct,
reblogAcct
? intl.formatMessage(messages.boosted, { name: reblogAcct })
: false,
].filter((val) => !!val);
return values.join(', ');
}, [intl, isQuote, reblogAcct, status]);
}