pl-fe: We're not Facebook anymore

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-09-11 15:21:32 +02:00
parent f2a7513d35
commit 584cde8c40
17 changed files with 203 additions and 517 deletions

View file

@ -3,9 +3,8 @@ import { isLoggedIn } from 'pl-fe/utils/auth';
import { getClient } from '../api';
import { importFetchedStatus } from './importer';
import { favourite, unfavourite } from './interactions';
import type { EmojiReaction, Status } from 'pl-api';
import type { Status } from 'pl-api';
import type { AppDispatch, RootState } from 'pl-fe/store';
const EMOJI_REACT_REQUEST = 'EMOJI_REACT_REQUEST' as const;
@ -18,31 +17,6 @@ const UNEMOJI_REACT_FAIL = 'UNEMOJI_REACT_FAIL' as const;
const noOp = () => () => new Promise(f => f(undefined));
const simpleEmojiReact = (status: Pick<Status, 'id' | 'emoji_reactions' | 'favourited'>, emoji: string, custom?: string) =>
(dispatch: AppDispatch) => {
const emojiReacts: Array<EmojiReaction> = status.emoji_reactions || [];
if (emoji === '👍' && status.favourited) return dispatch(unfavourite(status));
const undo = emojiReacts.filter(e => e.me === true && e.name === emoji).length > 0;
if (undo) return dispatch(unEmojiReact(status, emoji));
return Promise.all([
...emojiReacts
.filter((emojiReact) => emojiReact.me === true)
.map(emojiReact => dispatch(unEmojiReact(status, emojiReact.name))),
status.favourited && dispatch(unfavourite(status)),
]).then(() => {
if (emoji === '👍') {
dispatch(favourite(status));
} else {
dispatch(emojiReact(status, emoji, custom));
}
}).catch(err => {
console.error(err);
});
};
const emojiReact = (status: Pick<Status, 'id'>, emoji: string, custom?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return dispatch(noOp());
@ -119,7 +93,6 @@ export {
UNEMOJI_REACT_REQUEST,
UNEMOJI_REACT_SUCCESS,
UNEMOJI_REACT_FAIL,
simpleEmojiReact,
emojiReact,
unEmojiReact,
emojiReactRequest,

View file

@ -39,7 +39,7 @@ const Reaction: React.FC<IReaction> = ({ announcementId, reaction, emojiMap, sty
// @ts-ignore
if (unicodeMapping[shortCode]) {
// @ts-ignore
shortCode = unicodeMapping[shortCode].shortCode;
shortCode = unicodeMapping[shortCode].shortcode;
}
return (

View file

@ -5,6 +5,7 @@ import { useHistory, useRouteMatch } from 'react-router-dom';
import { blockAccount } from 'pl-fe/actions/accounts';
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'pl-fe/actions/compose';
import { emojiReact } from 'pl-fe/actions/emoji-reacts';
import { editEvent } from 'pl-fe/actions/events';
import { toggleBookmark, toggleDislike, toggleFavourite, togglePin, toggleReblog } from 'pl-fe/actions/interactions';
import { openModal } from 'pl-fe/actions/modals';
@ -18,18 +19,18 @@ import { useBlockGroupMember, useGroup, useGroupRelationship, useTranslationLang
import { useDeleteGroupStatus } from 'pl-fe/api/hooks/groups/useDeleteGroupStatus';
import DropdownMenu from 'pl-fe/components/dropdown-menu';
import StatusActionButton from 'pl-fe/components/status-action-button';
import StatusReactionWrapper from 'pl-fe/components/status-reaction-wrapper';
import { HStack } from 'pl-fe/components/ui';
import EmojiPickerDropdown from 'pl-fe/features/emoji/containers/emoji-picker-dropdown-container';
import { languages } from 'pl-fe/features/preferences';
import { useAppDispatch, useAppSelector, useFeatures, useInstance, useOwnAccount, useSettings } from 'pl-fe/hooks';
import { useChats } from 'pl-fe/queries/chats';
import toast from 'pl-fe/toast';
import copy from 'pl-fe/utils/copy';
import { getReactForStatus, reduceEmoji } from 'pl-fe/utils/emoji-reacts';
import GroupPopover from './groups/popover/group-popover';
import type { Menu } from 'pl-fe/components/dropdown-menu';
import type { Emoji as EmojiType } from 'pl-fe/features/emoji';
import type { UnauthorizedModalAction } from 'pl-fe/features/ui/components/modals/unauthorized-modal';
import type { Account, Group } from 'pl-fe/normalizers';
import type { SelectedStatus } from 'pl-fe/selectors';
@ -77,12 +78,6 @@ const messages = defineMessages({
open: { id: 'status.open', defaultMessage: 'Show post details' },
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
quotePost: { id: 'status.quote', defaultMessage: 'Quote post' },
reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' },
reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' },
reactionLaughing: { id: 'status.reactions.laughing', defaultMessage: 'Haha' },
reactionLike: { id: 'status.reactions.like', defaultMessage: 'Like' },
reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' },
reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' },
reblog: { id: 'status.reblog', defaultMessage: 'Repost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' },
redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
@ -99,6 +94,7 @@ const messages = defineMessages({
unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' },
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
viewReactions: { id: 'status.view_reactions', defaultMessage: 'View reactions' },
addKnownLanguage: { id: 'status.add_known_language', defaultMessage: 'Do not auto-translate posts in {language}.' },
translate: { id: 'status.translate', defaultMessage: 'Translate' },
hideTranslation: { id: 'status.hide_translation', defaultMessage: 'Hide translation' },
@ -201,6 +197,10 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
}
};
const handlePickEmoji = (emoji: EmojiType) => {
dispatch(emojiReact(status, emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined));
};
const handleBookmarkClick: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(toggleBookmark(status));
};
@ -307,6 +307,10 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
}));
};
const handleOpenReactionsModal = (): void => {
dispatch(openModal('REACTIONS', { statusId: status.id }));
};
const handleReport: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(initReport(ReportableEntities.STATUS, status.account, { status }));
};
@ -410,6 +414,14 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
}
}
if (status.emoji_reactions.length && features.exposableReactions) {
menu.push({
text: intl.formatMessage(messages.viewReactions),
action: handleOpenReactionsModal,
icon: require('@tabler/icons/outline/mood-happy.svg'),
});
}
if (!me) {
return menu;
}
@ -625,27 +637,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const quoteCount = status.quotes_count;
const favouriteCount = status.favourites_count;
const emojiReactCount = status.emoji_reactions ? reduceEmoji(
status.emoji_reactions,
favouriteCount,
status.favourited,
).reduce((acc, cur) => acc + (cur.count || 0), 0) : undefined;
const meEmojiReact = getReactForStatus(status);
const meEmojiName = meEmojiReact?.name as keyof typeof reactMessages | undefined;
const reactMessages = {
'👍': messages.reactionLike,
'❤️': messages.reactionHeart,
'😆': messages.reactionLaughing,
'😮': messages.reactionOpenMouth,
'😢': messages.reactionCry,
'😩': messages.reactionWeary,
'': messages.favourite,
};
const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiName || ''] || messages.favourite);
const menu = _makeMenu(publicStatus);
let reblogIcon = require('@tabler/icons/outline/repeat.svg');
let replyTitle;
@ -738,33 +729,17 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
reblogButton
)}
{features.emojiReacts ? (
<StatusReactionWrapper statusId={status.id}>
<StatusActionButton
title={meEmojiTitle}
icon={require('@tabler/icons/outline/heart.svg')}
filled
color='accent'
active={Boolean(meEmojiName)}
count={emojiReactCount}
emoji={meEmojiReact}
text={withLabels ? meEmojiTitle : undefined}
theme={statusActionButtonTheme}
/>
</StatusReactionWrapper>
) : (
<StatusActionButton
title={intl.formatMessage(messages.favourite)}
icon={features.statusDislikes ? require('@tabler/icons/outline/thumb-up.svg') : require('@tabler/icons/outline/heart.svg')}
color='accent'
filled
onClick={handleFavouriteClick}
active={status.favourited}
count={favouriteCount}
text={withLabels ? intl.formatMessage(status.favourited ? messages.reactionLike : messages.favourite) : undefined}
theme={statusActionButtonTheme}
/>
)}
<StatusActionButton
title={intl.formatMessage(messages.favourite)}
icon={features.statusDislikes ? require('@tabler/icons/outline/thumb-up.svg') : require('@tabler/icons/outline/heart.svg')}
color='accent'
filled
onClick={handleFavouriteClick}
active={status.favourited}
count={favouriteCount}
text={withLabels ? intl.formatMessage(messages.favourite) : undefined}
theme={statusActionButtonTheme}
/>
{features.statusDislikes && (
<StatusActionButton
@ -780,6 +755,10 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
/>
)}
{expandable && (features.emojiReacts || features.emojiReactsMastodon) && (
<EmojiPickerDropdown onPickEmoji={handlePickEmoji} />
)}
{canShare && (
<StatusActionButton
title={intl.formatMessage(messages.share)}

View file

@ -1,120 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { simpleEmojiReact } from 'pl-fe/actions/emoji-reacts';
import { openModal } from 'pl-fe/actions/modals';
import { EmojiSelector, Portal } from 'pl-fe/components/ui';
import { useAppDispatch, useAppSelector, useOwnAccount } from 'pl-fe/hooks';
import { userTouching } from 'pl-fe/is-mobile';
import { getReactForStatus } from 'pl-fe/utils/emoji-reacts';
interface IStatusReactionWrapper {
statusId: string;
children: JSX.Element;
}
/** Provides emoji reaction functionality to the underlying button component */
const StatusReactionWrapper: React.FC<IStatusReactionWrapper> = ({ statusId, children }): JSX.Element | null => {
const dispatch = useAppDispatch();
const { account: ownAccount } = useOwnAccount();
const status = useAppSelector(state => state.statuses.get(statusId));
const timeout = useRef<NodeJS.Timeout>();
const [visible, setVisible] = useState(false);
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
useEffect(() => () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
}, []);
if (!status) return null;
const handleMouseEnter = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
if (!userTouching.matches) {
setVisible(true);
}
};
const handleMouseLeave = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
// Unless the user is touching, delay closing the emoji selector briefly
// so the user can move the mouse diagonally to make a selection.
if (userTouching.matches) {
setVisible(false);
} else {
timeout.current = setTimeout(() => {
setVisible(false);
}, 500);
}
};
const handleReact = (emoji: string, custom?: string): void => {
if (ownAccount) {
dispatch(simpleEmojiReact(status, emoji, custom));
} else {
handleUnauthorized();
}
setVisible(false);
};
const handleClick: React.EventHandler<React.MouseEvent> = e => {
const meEmojiReact = getReactForStatus(status)?.name || '👍';
if (userTouching.matches) {
if (ownAccount) {
if (visible) {
handleReact(meEmojiReact);
} else {
setVisible(true);
}
} else {
handleUnauthorized();
}
} else {
handleReact(meEmojiReact);
}
e.preventDefault();
e.stopPropagation();
};
const handleUnauthorized = () => {
dispatch(openModal('UNAUTHORIZED', {
action: 'FAVOURITE',
ap_id: status.url,
}));
};
return (
<div className='relative' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{React.cloneElement(children, {
onClick: handleClick,
ref: setReferenceElement,
})}
{visible && (
<Portal>
<EmojiSelector
placement='top-start'
referenceElement={referenceElement}
onReact={handleReact}
visible={visible}
onClose={() => setVisible(false)}
/>
</Portal>
)}
</div>
);
};
export { StatusReactionWrapper as default };

View file

@ -0,0 +1,113 @@
import clsx from 'clsx';
import { EmojiReaction } from 'pl-api';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { emojiReact, unEmojiReact } from 'pl-fe/actions/emoji-reacts';
import EmojiPickerDropdown from 'pl-fe/features/emoji/containers/emoji-picker-dropdown-container';
import unicodeMapping from 'pl-fe/features/emoji/mapping';
import { useAppDispatch, useSettings } from 'pl-fe/hooks';
import { sortEmoji } from 'pl-fe/utils/emoji-reacts';
import AnimatedNumber from './animated-number';
import { Emoji, HStack, Icon, Text } from './ui';
import type { Emoji as EmojiType } from 'pl-fe/features/emoji';
import type { SelectedStatus } from 'pl-fe/selectors';
const messages = defineMessages({
emojiCount: { id: 'status.reactions.label', defaultMessage: '{count} {count, plural, one {person} other {people}} reacted with {emoji}' },
addEmoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
});
interface IStatusReactionsBar {
status: Pick<SelectedStatus, 'id' | 'emoji_reactions'>;
collapsed?: boolean;
}
interface IStatusReaction {
status: Pick<SelectedStatus, 'id'>;
reaction: EmojiReaction;
obfuscate?: boolean;
}
const StatusReaction: React.FC<IStatusReaction> = ({ reaction, status, obfuscate }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
if (!reaction.count) return null;
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
if (reaction.me) {
dispatch(unEmojiReact(status, reaction.name));
} else {
dispatch(emojiReact(status, reaction.name));
}
};
let shortCode = reaction.name;
// @ts-ignore
if (unicodeMapping[shortCode]?.shortcode) {
// @ts-ignore
shortCode = unicodeMapping[shortCode].shortcode;
}
return (
<button
className={clsx('flex cursor-pointer items-center gap-2 rounded-md border border-gray-400 p-1.5 transition-colors', {
'bg-primary-100 dark:border-primary-400 dark:bg-primary-400 hover:bg-primary-200 hover:dark:border-primary-300 hover:dark:bg-primary-300': reaction.me,
'bg-transparent dark:border-primary-700 dark:bg-primary-700 hover:bg-primary-100 hover:dark:border-primary-600 hover:dark:bg-primary-600': !reaction.me,
})}
key={reaction.name}
onClick={handleClick}
title={intl.formatMessage(messages.emojiCount, {
emoji: `:${shortCode}:`,
count: reaction.count,
})}
>
<Emoji className='h-4 w-4' emoji={reaction.name} src={reaction.url || undefined} />
<Text size='xs' weight='semibold' theme='inherit'>
<AnimatedNumber value={reaction.count} obfuscate={obfuscate} short />
</Text>
</button>
);
};
const StatusReactionsBar: React.FC<IStatusReactionsBar> = ({ status, collapsed }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { demetricator } = useSettings();
const handlePickEmoji = (emoji: EmojiType) => {
dispatch(emojiReact(status, emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined));
};
if ((demetricator || status.emoji_reactions.length === 0) && collapsed) return null;
const sortedReactions = sortEmoji(status.emoji_reactions);
return (
<HStack className='pt-2' space={2} wrap>
{sortedReactions.map((reaction) => reaction.count ? (
<StatusReaction key={reaction.name} status={status} reaction={reaction} obfuscate={demetricator} />
) : null)}
<EmojiPickerDropdown onPickEmoji={handlePickEmoji}>
<button
className='emoji-picker-dropdown cursor-pointer rounded-md border border-gray-400 bg-transparent p-1.5 transition-colors hover:bg-gray-50 dark:border-primary-700 dark:bg-primary-700 hover:dark:border-primary-600 hover:dark:bg-primary-600'
title={intl.formatMessage(messages.addEmoji)}
>
<Icon
className='h-4 w-4'
src={require('@tabler/icons/outline/mood-plus.svg')}
/>
</button>
</EmojiPickerDropdown>
</HStack>
);
};
export { StatusReactionsBar as default };

View file

@ -21,6 +21,7 @@ import StatusActionBar from './status-action-bar';
import StatusContent from './status-content';
import StatusLanguagePicker from './status-language-picker';
import StatusMedia from './status-media';
import StatusReactionsBar from './status-reactions-bar';
import StatusReplyMentions from './status-reply-mentions';
import SensitiveContentOverlay from './statuses/sensitive-content-overlay';
import StatusInfo from './statuses/status-info';
@ -174,16 +175,11 @@ const Status: React.FC<IStatus> = (props) => {
};
const handleHotkeyReact = (): void => {
_expandEmojiSelector();
(node.current?.querySelector('.emoji-picker-dropdown') as HTMLButtonElement)?.click();
};
const handleUnfilter = () => dispatch(unfilterStatus(status.filtered.length ? status.id : actualStatus.id));
const _expandEmojiSelector = (): void => {
const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
firstEmoji?.focus();
};
const renderStatusInfo = () => {
if (isReblog && showGroup && group) {
return (
@ -452,8 +448,15 @@ const Status: React.FC<IStatus> = (props) => {
)}
</Stack>
<StatusReactionsBar status={actualStatus} collapsed />
{!hideActionBar && (
<div className='pt-4'>
<div
className={clsx({
'pt-2': actualStatus.emoji_reactions.length,
'pt-4': !actualStatus.emoji_reactions.length,
})}
>
<StatusActionBar status={actualStatus} rebloggedBy={isReblog ? status.account : undefined} fromBookmarks={fromBookmarks} />
</div>
)}

View file

@ -54,7 +54,7 @@ const TranslateButton: React.FC<ITranslateButton> = ({ status }) => {
const button = (
<button className='w-fit' onClick={handleTranslate}>
<HStack alignItems='center' space={1} className='text-primary-600 hover:underline dark:text-accent-blue'>
<HStack alignItems='center' space={1} className='text-primary-600 hover:underline dark:text-gray-600'>
<Icon src={require('@tabler/icons/outline/language.svg')} className='h-4 w-4' />
<span>
{status.translation ? (

View file

@ -1,152 +0,0 @@
import { shift, useFloating, Placement, offset, OffsetOptions } from '@floating-ui/react';
import clsx from 'clsx';
import React, { useEffect, useState } from 'react';
import EmojiComponent from 'pl-fe/components/ui/emoji/emoji';
import HStack from 'pl-fe/components/ui/hstack/hstack';
import IconButton from 'pl-fe/components/ui/icon-button/icon-button';
import EmojiPickerDropdown from 'pl-fe/features/emoji/components/emoji-picker-dropdown';
import { useAppSelector, useClickOutside, useFeatures, usePlFeConfig } from 'pl-fe/hooks';
import type { Emoji } from 'pl-fe/features/emoji';
interface IEmojiButton {
/** Unicode emoji character. */
emoji: string;
/** Event handler when the emoji is clicked. */
onClick(emoji: string, custom?: string): void;
/** Extra class name on the <button> element. */
className?: string;
/** Tab order of the button. */
tabIndex?: number;
}
/** Clickable emoji button that scales when hovered. */
const EmojiButton: React.FC<IEmojiButton> = ({ emoji, className, onClick, tabIndex }): JSX.Element => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const customEmoji = emoji.startsWith(':') ? useAppSelector((state) => {
return state.custom_emojis.find(({ shortcode }) => `:${shortcode}:` === emoji);
}) : undefined;
const handleClick: React.EventHandler<React.MouseEvent> = (event) => {
event.preventDefault();
event.stopPropagation();
onClick(customEmoji ? emoji.replace(/:(\w+):/, '$1') : emoji, customEmoji?.url);
};
return (
<button className={clsx(className)} onClick={handleClick} tabIndex={tabIndex}>
<EmojiComponent className='h-6 w-6 duration-100 hover:scale-110' emoji={emoji} src={customEmoji?.url} />
</button>
);
};
interface IEmojiSelector {
onClose?(): void;
/** Event handler when an emoji is clicked. */
onReact(emoji: string, custom?: string): void;
/** Element that triggers the EmojiSelector Popper */
referenceElement: HTMLElement | null;
placement?: Placement;
/** Whether the selector should be visible. */
visible?: boolean;
offsetOptions?: OffsetOptions;
/** Whether to allow any emoji to be chosen. */
all?: boolean;
}
/** Panel with a row of emoji buttons. */
const EmojiSelector: React.FC<IEmojiSelector> = ({
referenceElement,
onClose,
onReact,
placement = 'top',
visible = false,
offsetOptions,
all = true,
}): JSX.Element => {
const plFeConfig = usePlFeConfig();
const { customEmojiReacts } = useFeatures();
const [expanded, setExpanded] = useState(false);
const { x, y, strategy, refs, update } = useFloating<HTMLElement>({
placement,
middleware: [offset(offsetOptions), shift()],
});
const handleExpand: React.MouseEventHandler = () => {
setExpanded(true);
};
const handlePickEmoji = (emoji: Emoji) => {
onReact(emoji.custom ? emoji.id : emoji.native, emoji.custom ? emoji.imageUrl : undefined);
};
useEffect(() => {
refs.setReference(referenceElement);
}, [referenceElement]);
useEffect(() => () => {
document.body.style.overflow = '';
}, []);
useEffect(() => {
setExpanded(false);
}, [visible]);
useClickOutside(refs, () => {
if (onClose) {
onClose();
}
});
return (
<div
className={clsx('z-[101] transition-opacity duration-100', {
'opacity-0 pointer-events-none': !visible,
})}
ref={refs.setFloating}
style={{
position: strategy,
top: y ?? 0,
left: x ?? 0,
width: 'max-content',
}}
>
{expanded ? (
<EmojiPickerDropdown
visible={expanded}
setVisible={setExpanded}
update={update}
withCustom={customEmojiReacts}
onPickEmoji={handlePickEmoji}
/>
) : (
<HStack
className={clsx('z-[999] flex w-max max-w-[100vw] flex-wrap space-x-3 rounded-full bg-white px-3 py-2.5 shadow-lg focus:outline-none dark:bg-gray-900 dark:ring-2 dark:ring-primary-700')}
>
{Array.from(plFeConfig.allowedEmoji).map((emoji) => (
<EmojiButton
key={emoji}
emoji={emoji.replace(/^\\/, '')}
onClick={onReact}
tabIndex={visible ? 0 : -1}
/>
))}
{all && (
<IconButton
className='text-gray-600 hover:text-gray-600 dark:hover:text-white'
src={require('@tabler/icons/outline/dots.svg')}
onClick={handleExpand}
/>
)}
</HStack>
)}
</div>
);
};
export { EmojiSelector as default };

View file

@ -18,7 +18,6 @@ export { default as Counter } from './counter/counter';
export { default as Datepicker } from './datepicker/datepicker';
export { default as Divider } from './divider/divider';
export { default as Emoji } from './emoji/emoji';
export { default as EmojiSelector } from './emoji-selector/emoji-selector';
export { default as FileInput } from './file-input/file-input';
export { default as Form } from './form/form';
export { default as FormActions } from './form-actions/form-actions';

View file

@ -1,5 +1,4 @@
import { useFloating, shift, flip } from '@floating-ui/react';
import clsx from 'clsx';
import React, { KeyboardEvent, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
@ -13,7 +12,7 @@ const messages = defineMessages({
});
const EmojiPickerDropdownContainer = (
props: Pick<IEmojiPickerDropdown, 'onPickEmoji' | 'condensed' | 'withCustom'>,
{ children, ...props }: Pick<IEmojiPickerDropdown, 'onPickEmoji' | 'condensed' | 'withCustom'> & { children?: JSX.Element },
) => {
const intl = useIntl();
const title = intl.formatMessage(messages.emoji);
@ -27,27 +26,41 @@ const EmojiPickerDropdownContainer = (
setVisible(false);
});
const handleToggle = (e: MouseEvent | KeyboardEvent) => {
const handleClick = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setVisible(!visible);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (['Enter', ' '].includes(e.key)) {
e.stopPropagation();
e.preventDefault();
setVisible(!visible);
}
};
return (
<div className='relative'>
<IconButton
className={clsx({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
})}
ref={refs.setReference}
src={require('@tabler/icons/outline/mood-happy.svg')}
title={title}
aria-label={title}
aria-expanded={visible}
role='button'
onClick={handleToggle as any}
onKeyDown={handleToggle as React.KeyboardEventHandler<HTMLButtonElement>}
tabIndex={0}
/>
{children ? (
React.cloneElement(children, {
onClick: handleClick,
onKeyDown: handleKeyDown,
ref: refs.setReference,
})
) : (
<IconButton
className='emoji-picker-dropdown text-gray-600 hover:text-gray-700 dark:hover:text-gray-500'
ref={refs.setReference}
src={require('@tabler/icons/outline/mood-happy.svg')}
title={title}
aria-label={title}
aria-expanded={visible}
role='button'
onClick={handleClick as any}
onKeyDown={handleKeyDown as React.KeyboardEventHandler<HTMLButtonElement>}
tabIndex={0}
/>)}
<Portal>
<div

View file

@ -6,6 +6,7 @@ import Account from 'pl-fe/components/account';
import StatusContent from 'pl-fe/components/status-content';
import StatusLanguagePicker from 'pl-fe/components/status-language-picker';
import StatusMedia from 'pl-fe/components/status-media';
import StatusReactionsBar from 'pl-fe/components/status-reactions-bar';
import StatusReplyMentions from 'pl-fe/components/status-reply-mentions';
import SensitiveContentOverlay from 'pl-fe/components/statuses/sensitive-content-overlay';
import StatusInfo from 'pl-fe/components/statuses/status-info';
@ -133,6 +134,8 @@ const DetailedStatus: React.FC<IDetailedStatus> = ({
</Stack>
</Stack>
<StatusReactionsBar status={actualStatus} />
<HStack justifyContent='between' alignItems='center' className='py-3' wrap>
<StatusInteractionBar status={actualStatus} />

View file

@ -4,14 +4,13 @@ import { Link } from 'react-router-dom';
import { openModal } from 'pl-fe/actions/modals';
import AnimatedNumber from 'pl-fe/components/animated-number';
import { HStack, Text, Emoji } from 'pl-fe/components/ui';
import { HStack, Text } from 'pl-fe/components/ui';
import { useAppSelector, useFeatures, useAppDispatch } from 'pl-fe/hooks';
import { reduceEmoji } from 'pl-fe/utils/emoji-reacts';
import type { Status } from 'pl-fe/normalizers';
interface IStatusInteractionBar {
status: Pick<Status, 'id' | 'account' | 'dislikes_count' | 'emoji_reactions' | 'favourited' | 'favourites_count' | 'reblogs_count' | 'quotes_count'>;
status: Pick<Status, 'id' | 'account' | 'dislikes_count' | 'favourited' | 'favourites_count' | 'reblogs_count' | 'quotes_count'>;
}
const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.Element | null => {
@ -38,16 +37,6 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
dispatch(openModal('DISLIKES', { statusId }));
};
const onOpenReactionsModal = (username: string, statusId: string): void => {
dispatch(openModal('REACTIONS', { statusId }));
};
const getNormalizedReacts = () => reduceEmoji(
status.emoji_reactions,
status.favourites_count,
status.favourited,
);
const handleOpenReblogsModal: React.EventHandler<React.MouseEvent> = (e) => {
e.preventDefault();
@ -135,49 +124,11 @@ const StatusInteractionBar: React.FC<IStatusInteractionBar> = ({ status }): JSX.
return null;
};
const handleOpenReactionsModal = () => {
if (!me) {
return onOpenUnauthorizedModal();
}
onOpenReactionsModal(account.acct, status.id);
};
const getEmojiReacts = () => {
const emojiReacts = getNormalizedReacts();
const count = emojiReacts.reduce((acc, cur) => (
acc + (cur.count || 0)
), 0);
const handleClick = features.emojiReacts ? handleOpenReactionsModal : handleOpenFavouritesModal;
if (count) {
return (
<InteractionCounter count={count} onClick={features.exposableReactions ? handleClick : undefined}>
<HStack space={0.5} alignItems='center'>
{emojiReacts.slice(0, 3).map((e, i) => {
return (
<Emoji
key={i}
className='h-4.5 w-4.5 flex-none'
emoji={e.name}
src={e.url}
/>
);
})}
</HStack>
</InteractionCounter>
);
}
return null;
};
return (
<HStack space={3}>
{getReposts()}
{getQuotes()}
{(features.emojiReacts || features.emojiReactsMastodon) ? getEmojiReacts() : getFavourites()}
{getFavourites()}
{getDislikes()}
</HStack>
);
@ -203,7 +154,6 @@ const InteractionCounter: React.FC<IInteractionCounter> = ({ count, children, on
<HStack space={1} alignItems='center'>
<Text weight='bold'>
<AnimatedNumber value={count} short />
{/* {shortNumberFormat(count)} */}
</Text>
<Text tag='div' theme='muted'>

View file

@ -120,8 +120,7 @@ const Thread: React.FC<IThread> = ({
const handleHotkeyReact = () => {
if (statusRef.current) {
const firstEmoji: HTMLButtonElement | null = statusRef.current.querySelector('.emoji-react-selector .emoji-react-selector__emoji');
firstEmoji?.focus();
(node.current?.querySelector('.emoji-picker-dropdown') as HTMLButtonElement)?.click();
}
};

View file

@ -3,12 +3,11 @@ import { List as ImmutableList } from 'immutable';
import React, { useEffect, useMemo, useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { fetchFavourites, fetchReactions } from 'pl-fe/actions/interactions';
import { fetchReactions } from 'pl-fe/actions/interactions';
import ScrollableList from 'pl-fe/components/scrollable-list';
import { Emoji, Modal, Spinner, Tabs } from 'pl-fe/components/ui';
import AccountContainer from 'pl-fe/containers/account-container';
import { useAppDispatch, useAppSelector } from 'pl-fe/hooks';
import { ReactionRecord } from 'pl-fe/reducers/user-lists';
import type { BaseModalProps } from '../modal-root';
import type { Item } from 'pl-fe/components/ui/tabs/tabs';
@ -32,16 +31,7 @@ const ReactionsModal: React.FC<BaseModalProps & ReactionsModalProps> = ({ onClos
const dispatch = useAppDispatch();
const intl = useIntl();
const [reaction, setReaction] = useState(initialReaction);
const reactions = useAppSelector<ImmutableList<ReturnType<typeof ReactionRecord>> | undefined>((state) => {
const favourites = state.user_lists.favourited_by.get(statusId)?.items;
const reactions = state.user_lists.reactions.get(statusId)?.items;
return favourites && reactions && ImmutableList(favourites?.size ? [ReactionRecord({ accounts: favourites, count: favourites.size, name: '👍' })] : []).concat(reactions || []);
});
const fetchData = () => {
dispatch(fetchFavourites(statusId));
dispatch(fetchReactions(statusId));
};
const reactions = useAppSelector((state) => state.user_lists.reactions.get(statusId)?.items);
const onClickClose = () => {
onClose('REACTIONS');
@ -83,7 +73,7 @@ const ReactionsModal: React.FC<BaseModalProps & ReactionsModalProps> = ({ onClos
}, [reactions, reaction]);
useEffect(() => {
fetchData();
dispatch(fetchReactions(statusId));
}, []);
let body;

View file

@ -1475,13 +1475,8 @@
"status.pinned": "Pinned post",
"status.quote": "Quote post",
"status.quote_tombstone": "Post is unavailable.",
"status.reactions.cry": "Sad",
"status.reactions.empty": "No one has reacted to this post yet. When someone does, they will show up here.",
"status.reactions.heart": "Love",
"status.reactions.laughing": "Haha",
"status.reactions.like": "Like",
"status.reactions.open_mouth": "Wow",
"status.reactions.weary": "Weary",
"status.reactions.label": "{count} {count, plural, one {person} other {people}} reacted with {emoji}",
"status.read_more": "Read more",
"status.reblog": "Repost",
"status.reblog_private": "Repost to original audience",
@ -1512,6 +1507,7 @@
"status.unbookmarked": "Bookmark removed.",
"status.unmute_conversation": "Unmute conversation",
"status.unpin": "Unpin from profile",
"status.view_reactions": "View reactions",
"status.visibility.direct": "The post is only visible to mentioned users",
"status.visibility.list": "The post is only visible to the members of a list",
"status.visibility.list.named": "The post is only visible to the members of a {name} list",

View file

@ -86,14 +86,6 @@ const PlFeConfigRecord = ImmutableRecord({
navlinks: ImmutableMap({
homeFooter: ImmutableList<FooterItem>(),
}),
allowedEmoji: ImmutableList<string>([
'👍',
'❤️',
'😆',
'😮',
'😢',
'😩',
]),
verifiedIcon: '',
displayFqn: true,
cryptoAddresses: ImmutableList<CryptoAddress>(),

View file

@ -1,56 +1,8 @@
import { List as ImmutableList } from 'immutable';
import { emojiReactionSchema, type EmojiReaction } from 'pl-api';
import type { Status } from 'pl-fe/normalizers';
// https://emojipedia.org/facebook
// I've customized them.
const ALLOWED_EMOJI = ImmutableList([
'👍',
'❤️',
'😆',
'😮',
'😢',
'😩',
]);
const sortEmoji = (emojiReacts: Array<EmojiReaction>): Array<EmojiReaction> =>
emojiReacts.toSorted(emojiReact => -(emojiReact.count || 0));
const mergeEmojiFavourites = (emojiReacts: Array<EmojiReaction> | null, favouritesCount: number, favourited: boolean) => {
if (!emojiReacts) return [emojiReactionSchema.parse({ count: favouritesCount, me: favourited, name: '👍' })];
if (!favouritesCount) return emojiReacts;
const likeIndex = emojiReacts.findIndex(emojiReact => emojiReact.name === '👍');
if (likeIndex > -1) {
const likeCount = Number(emojiReacts[likeIndex].count);
favourited = favourited || Boolean(emojiReacts[likeIndex].me || false);
return emojiReacts.map((reaction, index) => index === likeIndex ? {
...reaction,
count: likeCount + favouritesCount,
me: favourited,
} : reaction);
} else {
return [...emojiReacts, emojiReactionSchema.parse({ count: favouritesCount, me: favourited, name: '👍' })];
}
};
const reduceEmoji = (emojiReacts: Array<EmojiReaction> | null, favouritesCount: number, favourited: boolean): Array<EmojiReaction> => (
sortEmoji(mergeEmojiFavourites(emojiReacts, favouritesCount, favourited)));
const getReactForStatus = (
status: Pick<Status, 'emoji_reactions' | 'favourited' | 'favourites_count'>,
): EmojiReaction | undefined => {
if (!status.emoji_reactions) return;
const result = reduceEmoji(
status.emoji_reactions,
status.favourites_count || 0,
status.favourited,
).filter(e => e.me === true)[0];
return typeof result?.name === 'string' ? result : undefined;
};
const simulateEmojiReact = (emojiReacts: Array<EmojiReaction>, emoji: string, url?: string) => {
const idx = emojiReacts.findIndex(e => e.name === emoji);
const emojiReact = emojiReacts[idx];
@ -92,11 +44,7 @@ const simulateUnEmojiReact = (emojiReacts: Array<EmojiReaction>, emoji: string)
};
export {
ALLOWED_EMOJI,
sortEmoji,
mergeEmojiFavourites,
reduceEmoji,
getReactForStatus,
simulateEmojiReact,
simulateUnEmojiReact,
};