Merge branch 'new-status-list-bookmark' into 'main'

Checkpoint/Feature: Rewrite Bookmark component  and all of its children

See merge request soapbox-pub/soapbox!3289
This commit is contained in:
Alex Gleason 2024-12-09 15:55:51 +00:00
commit b03c29c371
22 changed files with 1717 additions and 46 deletions

View File

@ -43,6 +43,9 @@ export { useUnmuteGroup } from './groups/useUnmuteGroup.ts';
export { useUpdateGroup } from './groups/useUpdateGroup.ts'; export { useUpdateGroup } from './groups/useUpdateGroup.ts';
export { useUpdateGroupTag } from './groups/useUpdateGroupTag.ts'; export { useUpdateGroupTag } from './groups/useUpdateGroupTag.ts';
// Statuses
export { useBookmarks } from './statuses/useBookmarks.ts';
// Streaming // Streaming
export { useUserStream } from './streaming/useUserStream.ts'; export { useUserStream } from './streaming/useUserStream.ts';
export { useCommunityStream } from './streaming/useCommunityStream.ts'; export { useCommunityStream } from './streaming/useCommunityStream.ts';

View File

@ -0,0 +1,25 @@
import { EntityTypes, Entities } from 'soapbox/entity-store/entities.ts';
import { useEntities } from 'soapbox/entity-store/hooks/index.ts';
import { useApi } from 'soapbox/hooks/useApi.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
import { statusSchema } from 'soapbox/schemas/status.ts';
function useBookmarks() {
const api = useApi();
const features = useFeatures();
const { entities, ...result } = useEntities<EntityTypes[Entities.STATUSES]>(
[Entities.STATUSES, 'bookmarks'],
() => api.get('/api/v1/bookmarks'),
{ enabled: features.bookmarks, schema: statusSchema },
);
const bookmarks = entities;
return {
...result,
bookmarks,
};
}
export { useBookmarks };

View File

@ -0,0 +1,97 @@
import mapPinIcon from '@tabler/icons/outline/map-pin.svg';
import userIcon from '@tabler/icons/outline/user.svg';
import clsx from 'clsx';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import Button from 'soapbox/components/ui/button.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { EntityTypes, Entities } from 'soapbox/entity-store/entities.ts';
import PureEventActionButton from 'soapbox/features/event/components/pure-event-action-button.tsx';
import PureEventDate from 'soapbox/features/event/components/pure-event-date.tsx';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import Icon from './icon.tsx';
import VerificationBadge from './verification-badge.tsx';
const messages = defineMessages({
eventBanner: { id: 'event.banner', defaultMessage: 'Event banner' },
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
});
interface IPureEventPreview {
status: EntityTypes[Entities.STATUSES];
className?: string;
hideAction?: boolean;
floatingAction?: boolean;
}
const PureEventPreview: React.FC<IPureEventPreview> = ({ status, className, hideAction, floatingAction = true }) => {
const intl = useIntl();
const me = useAppSelector((state) => state.me);
const account = status.account;
const event = status.event!;
const banner = event.banner;
const action = !hideAction && (account.id === me ? (
<Button
size='sm'
theme={floatingAction ? 'secondary' : 'primary'}
to={`/@${account.acct}/events/${status.id}`}
>
<FormattedMessage id='event.manage' defaultMessage='Manage' />
</Button>
) : (
<PureEventActionButton
status={status}
theme={floatingAction ? 'secondary' : 'primary'}
/>
));
return (
<div className={clsx('relative w-full overflow-hidden rounded-lg bg-gray-100 black:border black:border-gray-800 black:bg-black dark:bg-primary-800', className)}>
<div className='absolute right-3 top-28'>
{floatingAction && action}
</div>
<div className='h-40 bg-primary-200 dark:bg-gray-600'>
{banner && <img className='size-full object-cover' src={banner.url} alt={intl.formatMessage(messages.eventBanner)} />}
</div>
<Stack className='p-2.5' space={2}>
<HStack space={2} alignItems='center' justifyContent='between'>
<Text weight='semibold' truncate>{event.name}</Text>
{!floatingAction && action}
</HStack>
<div className='flex flex-wrap gap-x-2 gap-y-1 text-gray-700 dark:text-gray-600'>
<HStack alignItems='center' space={2}>
<Icon src={userIcon} />
<HStack space={1} alignItems='center' grow>
<span>{account.display_name}</span>
{account.verified && <VerificationBadge />}
</HStack>
</HStack>
<PureEventDate status={status} />
{event.location && (
<HStack alignItems='center' space={2}>
<Icon src={mapPinIcon} />
<span>
{event.location?.name}
</span>
</HStack>
)}
</div>
</Stack>
</div>
);
};
export default PureEventPreview;

View File

@ -0,0 +1,137 @@
import chevronRightIcon from '@tabler/icons/outline/chevron-right.svg';
import clsx from 'clsx';
import { useState, useRef, useLayoutEffect, useMemo, memo } from 'react';
import { FormattedMessage } from 'react-intl';
import Icon from 'soapbox/components/icon.tsx';
import { Entities, EntityTypes } from 'soapbox/entity-store/entities.ts';
import { isOnlyEmoji as _isOnlyEmoji } from 'soapbox/utils/only-emoji.ts';
import { getTextDirection } from 'soapbox/utils/rtl.ts';
import Markup from './markup.tsx';
import Poll from './polls/poll.tsx';
import type { Sizes } from 'soapbox/components/ui/text.tsx';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
interface IReadMoreButton {
onClick: React.MouseEventHandler;
}
/** Button to expand a truncated status (due to too much content) */
const ReadMoreButton: React.FC<IReadMoreButton> = ({ onClick }) => (
<button className='flex items-center border-0 bg-transparent p-0 pt-2 text-gray-900 hover:underline active:underline dark:text-gray-300' onClick={onClick}>
<FormattedMessage id='status.read_more' defaultMessage='Read more' />
<Icon className='inline-block size-5' src={chevronRightIcon} />
</button>
);
interface IPureStatusContent {
status: EntityTypes[Entities.STATUSES];
onClick?: () => void;
collapsable?: boolean;
translatable?: boolean;
textSize?: Sizes;
}
/** Renders the text content of a status */
const PureStatusContent: React.FC<IPureStatusContent> = ({
status,
onClick,
collapsable = false,
translatable,
textSize = 'md',
}) => {
const [collapsed, setCollapsed] = useState(false);
const node = useRef<HTMLDivElement>(null);
const isOnlyEmoji = useMemo(() => _isOnlyEmoji(status.content, status.emojis, 10), [status.content]);
const maybeSetCollapsed = (): void => {
if (!node.current) return;
if (collapsable && onClick && !collapsed) {
if (node.current.clientHeight > MAX_HEIGHT) {
setCollapsed(true);
}
}
};
useLayoutEffect(() => {
maybeSetCollapsed();
});
const parsedHtml = useMemo((): string => {
return translatable && status.translation ? status.translation.content : status.content;
}, [status.content, status.translation]);
if (status.content.length === 0) {
return null;
}
const withSpoiler = status.spoiler_text.length > 0;
const baseClassName = 'text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative focus:outline-none';
const direction = getTextDirection(status.search_index);
const className = clsx(baseClassName, {
'cursor-pointer': onClick,
'whitespace-normal': withSpoiler,
'max-h-[300px]': collapsed,
'leading-normal !text-4xl': isOnlyEmoji,
});
if (onClick) {
const output = [
<Markup
ref={node}
tabIndex={0}
key='content'
className={className}
direction={direction}
lang={status.language || undefined}
size={textSize}
emojis={status.emojis}
mentions={status.mentions}
html={{ __html: parsedHtml }}
/>,
];
if (collapsed) {
output.push(<ReadMoreButton onClick={onClick} key='read-more' />);
}
const hasPoll = (!!status.poll) && typeof status.poll.id === 'string';
if (hasPoll) {
output.push(<Poll id={status.poll!.id} key='poll' status={status.url} />);
}
return <div className={clsx({ 'bg-gray-100 dark:bg-primary-800 rounded-md p-4': hasPoll })}>{output}</div>;
} else {
const output = [
<Markup
ref={node}
tabIndex={0}
key='content'
className={clsx(baseClassName, {
'leading-normal !text-4xl': isOnlyEmoji,
})}
direction={direction}
lang={status.language || undefined}
size={textSize}
emojis={status.emojis}
mentions={status.mentions}
html={{ __html: parsedHtml }}
/>,
];
if (status.poll && typeof status.poll === 'string') {
output.push(<Poll id={status.poll} key='poll' status={status.url} />);
}
return <>{output}</>;
}
};
export default memo(PureStatusContent);

View File

@ -0,0 +1,255 @@
import clsx from 'clsx';
import { debounce } from 'es-toolkit';
import { useRef, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import LoadGap from 'soapbox/components/load-gap.tsx';
import PureStatus from 'soapbox/components/pure-status.tsx';
import ScrollableList from 'soapbox/components/scrollable-list.tsx';
import { EntityTypes, Entities } from 'soapbox/entity-store/entities.ts';
import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions.tsx';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status.tsx';
import PendingStatus from 'soapbox/features/ui/components/pending-status.tsx';
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
import type { VirtuosoHandle } from 'react-virtuoso';
import type { IScrollableList } from 'soapbox/components/scrollable-list.tsx';
interface IPureStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'>{
/** Unique key to preserve the scroll position when navigating back. */
scrollKey: string;
/** List of statuses to display. */
statuses: readonly EntityTypes[Entities.STATUSES][];
/** Last _unfiltered_ status ID (maxId) for pagination. */
lastStatusId?: string;
/** Pinned statuses to show at the top of the feed. */
featuredStatuses?: readonly EntityTypes[Entities.STATUSES][];
/** Pagination callback when the end of the list is reached. */
onLoadMore?: (lastStatusId: string) => void;
/** Whether the data is currently being fetched. */
isLoading: boolean;
/** Whether the server did not return a complete page. */
isPartial?: boolean;
/** Whether we expect an additional page of data. */
hasMore: boolean;
/** Message to display when the list is loaded but empty. */
emptyMessage: React.ReactNode;
/** ID of the timeline in Redux. */
timelineId?: string;
/** Whether to display a gap or border between statuses in the list. */
divideType?: 'space' | 'border';
/** Whether to display ads. */
showAds?: boolean;
/** Whether to show group information. */
showGroup?: boolean;
}
/**
* Feed of statuses, built atop ScrollableList.
*/
const PureStatusList: React.FC<IPureStatusList> = ({
statuses,
lastStatusId,
featuredStatuses,
divideType = 'border',
onLoadMore,
timelineId,
isLoading,
isPartial,
showAds = false,
showGroup = true,
className,
...other
}) => {
const soapboxConfig = useSoapboxConfig();
const node = useRef<VirtuosoHandle>(null);
const getFeaturedStatusCount = () => {
return featuredStatuses?.length || 0;
};
const getCurrentStatusIndex = (id: string, featured: boolean): number => {
if (featured) {
return (featuredStatuses ?? []).findIndex(key => key.id === id) || 0;
} else {
return (
(statuses?.map(status => status.id) ?? []).findIndex(key => key === id) +
getFeaturedStatusCount()
);
}
};
const handleMoveUp = (id: string, featured: boolean = false) => {
const elementIndex = getCurrentStatusIndex(id, featured) - 1;
selectChild(elementIndex);
};
const handleMoveDown = (id: string, featured: boolean = false) => {
const elementIndex = getCurrentStatusIndex(id, featured) + 1;
selectChild(elementIndex);
};
const handleLoadOlder = useCallback(debounce(() => {
const maxId = lastStatusId || statuses.slice(-1)?.[0]?.id;
if (onLoadMore && maxId) {
onLoadMore(maxId.replace('末suggestions-', ''));
}
}, 300, { edges: ['leading'] }), [onLoadMore, lastStatusId, statuses.slice(-1)?.[0]?.id]);
const selectChild = (index: number) => {
node.current?.scrollIntoView({
index,
behavior: 'smooth',
done: () => {
const element = document.querySelector<HTMLDivElement>(`#status-list [data-index="${index}"] .focusable`);
element?.focus();
},
});
};
const renderLoadGap = (index: number) => {
const ids = statuses?.map(status => status.id) ?? [];
const nextId = ids[index + 1];
const prevId = ids[index - 1];
if (index < 1 || !nextId || !prevId || !onLoadMore) return null;
return (
<LoadGap
key={'gap:' + nextId}
disabled={isLoading}
maxId={prevId!}
onClick={onLoadMore}
/>
);
};
const renderStatus = (status: EntityTypes[Entities.STATUSES]) => {
return (
<PureStatus
status={status}
key={status.id}
id={status.id}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
showGroup={showGroup}
variant={divideType === 'border' ? 'slim' : 'rounded'}
/>
);
};
const renderPendingStatus = (statusId: string) => {
const idempotencyKey = statusId.replace(/^末pending-/, '');
return (
<PendingStatus
key={statusId}
idempotencyKey={idempotencyKey}
/>
);
};
const renderFeaturedStatuses = (): React.ReactNode[] => {
if (!featuredStatuses) return [];
return (featuredStatuses ?? []).map(status => (
<PureStatus
status={status}
key={`f-${status.id}`}
id={status.id}
featured
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
showGroup={showGroup}
variant={divideType === 'border' ? 'slim' : 'default'} // shouldn't "default" be changed to "rounded" ?
/>
));
};
const renderFeedSuggestions = (statusId: string): React.ReactNode => {
return (
<FeedSuggestions
key='suggestions'
statusId={statusId}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
);
};
const renderStatuses = (): React.ReactNode[] => {
if (isLoading || (statuses?.length ?? 0) > 0) {
return (statuses ?? []).reduce((acc, status, index) => {
if (status.id === null) {
const gap = renderLoadGap(index);
// one does not simply push a null item to Virtuoso: https://github.com/petyosi/react-virtuoso/issues/206#issuecomment-747363793
if (gap) {
acc.push(gap);
}
} else if (status.id.startsWith('末suggestions-')) {
if (soapboxConfig.feedInjection) {
acc.push(renderFeedSuggestions(status.id));
}
} else if (status.id.startsWith('末pending-')) {
acc.push(renderPendingStatus(status.id));
} else {
acc.push(renderStatus(status));
}
return acc;
}, [] as React.ReactNode[]);
} else {
return [];
}
};
const renderScrollableContent = () => {
const featuredStatuses = renderFeaturedStatuses();
const statuses = renderStatuses();
if (featuredStatuses && statuses) {
return featuredStatuses.concat(statuses);
} else {
return statuses;
}
};
if (isPartial) {
return (
<div className='flex flex-1 cursor-default items-center justify-center rounded-lg p-5 text-center text-[16px] font-medium text-gray-900 sm:rounded-none'>
<div className='w-full bg-transparent pt-0'>
<div>
<strong className='mb-2.5 block text-gray-900'>
<FormattedMessage id='regeneration_indicator.label' defaultMessage='Loading…' />
</strong>
<FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' />
</div>
</div>
</div>
);
}
return (
<ScrollableList
id='status-list'
key='scrollable-list'
isLoading={isLoading}
showLoading={isLoading && statuses.length === 0}
onLoadMore={handleLoadOlder}
placeholderComponent={() => <PlaceholderStatus variant={divideType === 'border' ? 'slim' : 'rounded'} />}
placeholderCount={20}
ref={node}
listClassName={clsx('divide-y divide-solid divide-gray-200 dark:divide-gray-800', {
'divide-none': divideType !== 'border',
}, className)}
itemClassName={clsx({
'pb-3': divideType !== 'border',
})}
{...other}
>
{renderScrollableContent()}
</ScrollableList>
);
};
export default PureStatusList;

View File

@ -0,0 +1,113 @@
import { FormattedList, FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { openModal } from 'soapbox/actions/modals.ts';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper.tsx';
import HoverStatusWrapper from 'soapbox/components/hover-status-wrapper.tsx';
import { Entities, EntityTypes } from 'soapbox/entity-store/entities.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { shortenNostr } from 'soapbox/utils/nostr.ts';
interface IPureStatusReplyMentions {
status: EntityTypes[Entities.STATUSES];
hoverable?: boolean;
}
const PureStatusReplyMentions: React.FC<IPureStatusReplyMentions> = ({ status, hoverable = true }) => {
const dispatch = useAppDispatch();
const handleOpenMentionsModal: React.MouseEventHandler<HTMLSpanElement> = (e) => {
e.stopPropagation();
const account = status.account;
dispatch(openModal('MENTIONS', {
username: account.acct,
statusId: status.id,
}));
};
if (!status.in_reply_to_id) {
return null;
}
const to = status.mentions;
// The post is a reply, but it has no mentions.
// Rare, but it can happen.
if (to.length === 0) {
return (
<div className='mb-1 text-sm text-gray-700 dark:text-gray-600'>
<FormattedMessage
id='reply_mentions.reply_empty'
defaultMessage='Replying to post'
/>
</div>
);
}
// The typical case with a reply-to and a list of mentions.
const accounts = to.slice(0, 2).map(account => {
const link = (
<Link
key={account.id}
to={`/@${account.acct}`}
className='inline-block max-w-[200px] truncate align-bottom text-primary-600 no-underline hover:text-primary-700 hover:underline dark:text-accent-blue dark:hover:text-accent-blue' style={{ direction: 'ltr' }}
onClick={(e) => e.stopPropagation()}
> {/* eslint-disable-line formatjs/no-literal-string-in-jsx */}
@{shortenNostr(account.username)}
</Link>
);
if (hoverable) {
return (
<HoverRefWrapper key={account.id} accountId={account.id} inline>
{link}
</HoverRefWrapper>
);
} else {
return link;
}
});
if (to.length > 2) {
accounts.push(
<span key='more' className='cursor-pointer hover:underline' role='button' onClick={handleOpenMentionsModal} tabIndex={0}>
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.length - 2 }} />
</span>,
);
}
return (
<div className='mb-1 text-sm text-gray-700 dark:text-gray-600'>
<FormattedMessage
id='reply_mentions.reply.hoverable'
defaultMessage='<hover>Replying to</hover> {accounts}'
values={{
accounts: <FormattedList type='conjunction' value={accounts} />,
// @ts-ignore wtf?
hover: (children: React.ReactNode) => {
if (hoverable) {
return (
<HoverStatusWrapper statusId={status.in_reply_to_id} inline>
<span
key='hoverstatus'
className='cursor-pointer hover:underline'
role='presentation'
>
{children}
</span>
</HoverStatusWrapper>
);
} else {
return children;
}
},
}}
/>
</div>
);
};
export default PureStatusReplyMentions;

View File

@ -0,0 +1,515 @@
import circlesIcon from '@tabler/icons/outline/circles.svg';
import pinnedIcon from '@tabler/icons/outline/pinned.svg';
import repeatIcon from '@tabler/icons/outline/repeat.svg';
import clsx from 'clsx';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { importFetchedStatuses } from 'soapbox/actions/importer/index.ts';
import { openModal } from 'soapbox/actions/modals.ts';
import { unfilterStatus } from 'soapbox/actions/statuses.ts';
import PureEventPreview from 'soapbox/components/pure-event-preview.tsx';
import PureStatusContent from 'soapbox/components/pure-status-content.tsx';
import PureStatusReplyMentions from 'soapbox/components/pure-status-reply-mentions.tsx';
import PureTranslateButton from 'soapbox/components/pure-translate-button.tsx';
import PureSensitiveContentOverlay from 'soapbox/components/statuses/pure-sensitive-content-overlay.tsx';
import { Card } from 'soapbox/components/ui/card.tsx';
import Icon from 'soapbox/components/ui/icon.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import AccountContainer from 'soapbox/containers/account-container.tsx';
import { EntityTypes, Entities } from 'soapbox/entity-store/entities.ts';
import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container.tsx';
import { HotKeys } from 'soapbox/features/ui/components/hotkeys.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFavourite } from 'soapbox/hooks/useFavourite.ts';
import { useMentionCompose } from 'soapbox/hooks/useMentionCompose.ts';
import { useReblog } from 'soapbox/hooks/useReblog.ts';
import { useReplyCompose } from 'soapbox/hooks/useReplyCompose.ts';
import { useSettings } from 'soapbox/hooks/useSettings.ts';
import { useStatusHidden } from 'soapbox/hooks/useStatusHidden.ts';
import { makeGetStatus } from 'soapbox/selectors/index.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status.ts';
import StatusActionBar from './status-action-bar.tsx';
import StatusMedia from './status-media.tsx';
import StatusInfo from './statuses/status-info.tsx';
import Tombstone from './tombstone.tsx';
// Defined in components/scrollable-list
export type ScrollPosition = { height: number; top: number };
const messages = defineMessages({
reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' },
});
export interface IPureStatus {
id?: string;
avatarSize?: number;
status: EntityTypes[Entities.STATUSES];
onClick?: () => void;
muted?: boolean;
hidden?: boolean;
unread?: boolean;
onMoveUp?: (statusId: string, featured?: boolean) => void;
onMoveDown?: (statusId: string, featured?: boolean) => void;
focusable?: boolean;
featured?: boolean;
hideActionBar?: boolean;
hoverable?: boolean;
variant?: 'default' | 'rounded' | 'slim';
showGroup?: boolean;
accountAction?: React.ReactElement;
}
/**
* Status accepting the full status entity in pure format.
*/
const PureStatus: React.FC<IPureStatus> = (props) => {
const {
status,
accountAction,
avatarSize = 42,
focusable = true,
hoverable = true,
onClick,
onMoveUp,
onMoveDown,
muted,
hidden,
featured,
unread,
hideActionBar,
variant = 'rounded',
showGroup = true,
} = props;
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const { displayMedia, boostModal } = useSettings();
const didShowCard = useRef(false);
const node = useRef<HTMLDivElement>(null);
const overlay = useRef<HTMLDivElement>(null);
const [showMedia, setShowMedia] = useState<boolean>(defaultMediaVisibility(status, displayMedia));
const [minHeight, setMinHeight] = useState(208);
const actualStatus = getActualStatus(status);
const isReblog = status.reblog && typeof status.reblog === 'object';
const statusUrl = `/@${actualStatus.account.acct}/posts/${actualStatus.id}`;
const group = actualStatus.group;
const filtered = (status.filtered.length || actualStatus.filtered.length) > 0;
const { replyCompose } = useReplyCompose();
const { mentionCompose } = useMentionCompose();
const { toggleFavourite } = useFavourite();
const { toggleReblog } = useReblog();
const { toggleStatusHidden } = useStatusHidden();
// Track height changes we know about to compensate scrolling.
useEffect(() => {
didShowCard.current = Boolean(!muted && !hidden && status?.card);
}, []);
useEffect(() => {
setShowMedia(defaultMediaVisibility(status, displayMedia));
}, [status.id]);
useEffect(() => {
if (overlay.current) {
setMinHeight(overlay.current.getBoundingClientRect().height);
}
}, [overlay.current]);
// START: this is a temporary code, it will be removed
useEffect(() => {
dispatch(importFetchedStatuses([status]));
}, []);
const getStatus = useCallback(makeGetStatus(), []);
const statusImmutable = useAppSelector(state => getStatus(state, { id: status.id }));
if (!statusImmutable) {
return null;
}
// END: this is a temporary code, it will be removed
const handleToggleMediaVisibility = (): void => {
setShowMedia(!showMedia);
};
const handleClick = (e?: React.MouseEvent): void => {
e?.stopPropagation();
// If the user is selecting text, don't focus the status.
if (getSelection()?.toString().length) {
return;
}
if (!e || !(e.ctrlKey || e.metaKey)) {
if (onClick) {
onClick();
} else {
history.push(statusUrl);
}
} else {
window.open(statusUrl, '_blank');
}
};
const handleHotkeyOpenMedia = (e?: KeyboardEvent): void => {
const status = actualStatus;
const firstAttachment = status.media_attachments[0];
e?.preventDefault();
if (firstAttachment) {
if (firstAttachment.type === 'video') {
dispatch(openModal('VIDEO', { status, media: firstAttachment, time: 0 }));
} else {
dispatch(openModal('MEDIA', { status, media: status.media_attachments, index: 0 }));
}
}
};
const handleHotkeyReply = (e?: KeyboardEvent): void => {
e?.preventDefault();
replyCompose(status.id);
};
const handleHotkeyFavourite = (): void => {
toggleFavourite(status.id);
};
const handleHotkeyBoost = (e?: KeyboardEvent): void => {
const modalReblog = () => toggleReblog(status.id);
if ((e && e.shiftKey) || !boostModal) {
modalReblog();
} else {
dispatch(openModal('BOOST', { status: status, onReblog: modalReblog }));
}
};
const handleHotkeyMention = (e?: KeyboardEvent): void => {
e?.preventDefault();
mentionCompose(actualStatus.account);
};
const handleHotkeyOpen = (): void => {
history.push(statusUrl);
};
const handleHotkeyOpenProfile = (): void => {
history.push(`/@${actualStatus.account.acct}`);
};
const handleHotkeyMoveUp = (e?: KeyboardEvent): void => {
if (onMoveUp) {
onMoveUp(status.id, featured);
}
};
const handleHotkeyMoveDown = (e?: KeyboardEvent): void => {
if (onMoveDown) {
onMoveDown(status.id, featured);
}
};
const handleHotkeyToggleHidden = (): void => {
toggleStatusHidden(status.id);
};
const handleHotkeyToggleSensitive = (): void => {
handleToggleMediaVisibility();
};
const handleHotkeyReact = (): void => {
_expandEmojiSelector();
};
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 (
<StatusInfo
avatarSize={avatarSize}
icon={<Icon src={repeatIcon} className='size-4 text-green-600' />}
text={
<FormattedMessage
id='status.reblogged_by_with_group'
defaultMessage='{name} reposted from {group}'
values={{
name: (
<Link
to={`/@${status.account.acct}`}
className='hover:underline'
>
<bdi className='truncate'>
<strong className='text-gray-800 dark:text-gray-200'>
{emojifyText(status.account.display_name, status.account.emojis)}
</strong>
</bdi>
</Link>
),
group: (
<Link to={`/group/${group.slug}`} className='hover:underline'>
<strong className='text-gray-800 dark:text-gray-200'>
{group.display_name}
</strong>
</Link>
),
}}
/>
}
/>
);
} else if (isReblog) {
return (
<StatusInfo
avatarSize={avatarSize}
icon={<Icon src={repeatIcon} className='size-4 text-green-600' />}
text={
<FormattedMessage
id='status.reblogged_by'
defaultMessage='{name} reposted'
values={{
name: (
<Link to={`/@${status.account.acct}`} className='hover:underline'>
<bdi className='truncate'>
<strong className='text-gray-800 dark:text-gray-200'>
{emojifyText(status.account.display_name, status.account.emojis)}
</strong>
</bdi>
</Link>
),
}}
/>
}
/>
);
} else if (featured) {
return (
<StatusInfo
avatarSize={avatarSize}
icon={<Icon src={pinnedIcon} className='size-4 text-gray-600 dark:text-gray-400' />}
text={
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
}
/>
);
} else if (showGroup && group) {
return (
<StatusInfo
avatarSize={avatarSize}
icon={<Icon src={circlesIcon} className='size-4 text-primary-600 dark:text-accent-blue' />}
text={
<FormattedMessage
id='status.group'
defaultMessage='Posted in {group}'
values={{
group: (
<Link to={`/group/${group.slug}`} className='hover:underline'>
<bdi className='truncate'>
<strong className='text-gray-800 dark:text-gray-200'>
<span>{group.display_name}</span>
</strong>
</bdi>
</Link>
),
}}
/>
}
/>
);
}
};
if (!status) return null;
if (hidden) {
return (
<div ref={node}>
<>
{actualStatus.account.display_name || actualStatus.account.username}
{actualStatus.content}
</>
</div>
);
}
if (filtered && status.showFiltered) {
const minHandlers = muted ? undefined : {
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
};
return (
<HotKeys handlers={minHandlers}>
<div className={clsx('status--wrapper text-center', { focusable })} tabIndex={focusable ? 0 : undefined} ref={node}>
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
<Text theme='muted'>
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {status.filtered.join(', ')}.
{' '}
<button className='text-primary-600 hover:underline dark:text-accent-blue' onClick={handleUnfilter}>
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
</button>
</Text>
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
</div>
</HotKeys>
);
}
let rebloggedByText;
if (status.reblog && typeof status.reblog === 'object') {
rebloggedByText = intl.formatMessage(
messages.reblogged_by,
{ name: status.account.acct },
);
}
let quote;
if (actualStatus.quote) {
if ((actualStatus?.pleroma?.quote_visible ?? true) === false) {
quote = (
<div>
<p><FormattedMessage id='statuses.quote_tombstone' defaultMessage='Post is unavailable.' /></p>
</div>
);
} else {
quote = <QuotedStatus statusId={actualStatus.quote.id} />;
}
}
const handlers = muted ? undefined : {
reply: handleHotkeyReply,
favourite: handleHotkeyFavourite,
boost: handleHotkeyBoost,
mention: handleHotkeyMention,
open: handleHotkeyOpen,
openProfile: handleHotkeyOpenProfile,
moveUp: handleHotkeyMoveUp,
moveDown: handleHotkeyMoveDown,
toggleHidden: handleHotkeyToggleHidden,
toggleSensitive: handleHotkeyToggleSensitive,
openMedia: handleHotkeyOpenMedia,
react: handleHotkeyReact,
};
const isUnderReview = actualStatus.visibility === 'self';
const isSensitive = actualStatus.hidden;
const isSoftDeleted = status.tombstone?.reason === 'deleted';
if (isSoftDeleted) {
return (
<Tombstone
id={status.id}
onMoveUp={(id) => onMoveUp ? onMoveUp(id) : null}
onMoveDown={(id) => onMoveDown ? onMoveDown(id) : null}
/>
);
}
return (
<HotKeys handlers={handlers} data-testid='status'>
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
<div
className={clsx('status cursor-pointer', { focusable })}
tabIndex={focusable && !muted ? 0 : undefined}
data-featured={featured ? 'true' : null}
aria-label={textForScreenReader(intl, actualStatus, rebloggedByText)}
ref={node}
onClick={handleClick}
role='link'
>
<Card
variant={variant}
className={clsx('status--wrapper space-y-4', {
'py-6 sm:p-5': variant === 'rounded', muted, read: unread === false,
})}
data-id={status.id}
>
{renderStatusInfo()}
<AccountContainer
key={actualStatus.account.id}
id={actualStatus.account.id}
timestamp={actualStatus.created_at}
timestampUrl={statusUrl}
action={accountAction}
hideActions={!accountAction}
showEdit={!!actualStatus.edited_at}
showProfileHoverCard={hoverable}
withLinkToProfile={hoverable}
approvalStatus={actualStatus.approval_status}
avatarSize={avatarSize}
/>
<div className='status--content-wrapper'>
<PureStatusReplyMentions status={status} hoverable={hoverable} />
<Stack
className='relative z-0'
style={{ minHeight: isUnderReview || isSensitive ? Math.max(minHeight, 208) + 12 : undefined }}
>
{(isUnderReview || isSensitive) && (
<PureSensitiveContentOverlay
status={status}
visible={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
ref={overlay}
/>
)}
{actualStatus.event ? <PureEventPreview className='shadow-xl' status={status} /> : (
<Stack space={4}>
<PureStatusContent
status={status}
onClick={handleClick}
collapsable
translatable
/>
<PureTranslateButton status={status} />
{(quote || actualStatus.card || actualStatus.media_attachments.length > 0) && (
<Stack space={4}>
<StatusMedia
status={statusImmutable} // fix later
muted={muted}
onClick={handleClick}
showMedia={showMedia}
onToggleVisibility={handleToggleMediaVisibility}
/>
{quote}
</Stack>
)}
</Stack>
)}
</Stack>
{(!hideActionBar && !isUnderReview) && (
<div className='pt-4'>
<StatusActionBar status={statusImmutable} /> {/* fix later */}
</div>
)}
</div>
</Card>
</div >
</HotKeys >
);
};
export default PureStatus;

View File

@ -0,0 +1,82 @@
import languageIcon from '@tabler/icons/outline/language.svg';
import { FormattedMessage, useIntl } from 'react-intl';
import { translateStatus, undoStatusTranslation } from 'soapbox/actions/statuses.ts';
import Button from 'soapbox/components/ui/button.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { Entities, EntityTypes } from 'soapbox/entity-store/entities.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
import { useInstance } from 'soapbox/hooks/useInstance.ts';
interface IPureTranslateButton {
status: EntityTypes[Entities.STATUSES];
}
const PureTranslateButton: React.FC<IPureTranslateButton> = ({ status }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const features = useFeatures();
const { instance } = useInstance();
const me = useAppSelector((state) => state.me);
const {
allow_remote: allowRemote,
allow_unauthenticated: allowUnauthenticated,
source_languages: sourceLanguages,
target_languages: targetLanguages,
} = instance.pleroma.metadata.translation;
const renderTranslate = (me || allowUnauthenticated) && (allowRemote || status.account.local) && ['public', 'unlisted'].includes(status.visibility) && status.content.length > 0 && status.language !== null && intl.locale !== status.language;
const supportsLanguages = (!sourceLanguages || sourceLanguages.includes(status.language!)) && (!targetLanguages || targetLanguages.includes(intl.locale));
const handleTranslate: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.stopPropagation();
if (status.translation) {
dispatch(undoStatusTranslation(status.id));
} else {
dispatch(translateStatus(status.id, intl.locale));
}
};
if (!features.translations || !renderTranslate || !supportsLanguages) return null;
if (status.translation) {
const languageNames = new Intl.DisplayNames([intl.locale], { type: 'language' });
const languageName = languageNames.of(status.language!);
const provider = status.translation.provider;
return (
<Stack space={3} alignItems='start'>
<Button
theme='muted'
text={<FormattedMessage id='status.show_original' defaultMessage='Show original' />}
icon={languageIcon}
onClick={handleTranslate}
/>
<Text theme='muted'>
<FormattedMessage id='status.translated_from_with' defaultMessage='Translated from {lang} using {provider}' values={{ lang: languageName, provider }} />
</Text>
</Stack>
);
}
return (
<div>
<Button
theme='muted'
text={<FormattedMessage id='status.translate' defaultMessage='Translate' />}
icon={languageIcon}
onClick={handleTranslate}
/>
</div>
);
};
export default PureTranslateButton;

View File

@ -54,11 +54,9 @@ import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts'; import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
import { useSettings } from 'soapbox/hooks/useSettings.ts'; import { useSettings } from 'soapbox/hooks/useSettings.ts';
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
import { GroupRoles } from 'soapbox/schemas/group-member.ts'; import { GroupRoles } from 'soapbox/schemas/group-member.ts';
import toast from 'soapbox/toast.tsx'; import toast from 'soapbox/toast.tsx';
import copy from 'soapbox/utils/copy.ts'; import copy from 'soapbox/utils/copy.ts';
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts.ts';
import GroupPopover from './groups/popover/group-popover.tsx'; import GroupPopover from './groups/popover/group-popover.tsx';
@ -170,9 +168,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const { groupRelationship } = useGroupRelationship(status.group?.id); const { groupRelationship } = useGroupRelationship(status.group?.id);
const features = useFeatures(); const features = useFeatures();
const { boostModal, deleteModal } = useSettings(); const { boostModal, deleteModal } = useSettings();
const soapboxConfig = useSoapboxConfig();
const { allowedEmoji } = soapboxConfig;
const { account } = useOwnAccount(); const { account } = useOwnAccount();
const isStaff = account ? account.staff : false; const isStaff = account ? account.staff : false;
@ -662,14 +657,9 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const reblogCount = status.reblogs_count; const reblogCount = status.reblogs_count;
const favouriteCount = status.favourites_count; const favouriteCount = status.favourites_count;
const emojiReactCount = status.reactions ? reduceEmoji( const emojiReactCount = status.reactions?.reduce((acc, reaction) => acc + (reaction.count ?? 0), 0) ?? 0; // allow all emojis
status.reactions,
favouriteCount,
status.favourited,
allowedEmoji,
).reduce((acc, cur) => acc + (cur.count || 0), 0) : undefined;
const meEmojiReact = getReactForStatus(status, allowedEmoji); const meEmojiReact = status.reactions?.find((emojiReact) => emojiReact.me); // allow all emojis
const meEmojiName = meEmojiReact?.name as keyof typeof reactMessages | undefined; const meEmojiName = meEmojiReact?.name as keyof typeof reactMessages | undefined;
const reactMessages = { const reactMessages = {
@ -783,7 +773,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
filled filled
color='accent' color='accent'
active={Boolean(meEmojiName)} active={Boolean(meEmojiName)}
count={emojiReactCount} count={emojiReactCount + favouriteCount}
emoji={meEmojiReact} emoji={meEmojiReact}
theme={statusActionButtonTheme} theme={statusActionButtonTheme}
/> />

View File

@ -44,7 +44,10 @@ interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
showGroup?: boolean; showGroup?: boolean;
} }
/** Feed of statuses, built atop ScrollableList. */ /**
* Legacy Feed of statuses, built atop ScrollableList.
* @deprecated Use the PureStatusList component.
*/
const StatusList: React.FC<IStatusList> = ({ const StatusList: React.FC<IStatusList> = ({
statusIds, statusIds,
lastStatusId, lastStatusId,

View File

@ -60,6 +60,10 @@ export interface IStatus {
accountAction?: React.ReactElement; accountAction?: React.ReactElement;
} }
/**
* Legacy Status accepting a the full entity in immutable.
* @deprecated Use the PureStatus component.
*/
const Status: React.FC<IStatus> = (props) => { const Status: React.FC<IStatus> = (props) => {
const { const {
status, status,

View File

@ -0,0 +1,186 @@
import dotsIcon from '@tabler/icons/outline/dots.svg';
import eyeOffIcon from '@tabler/icons/outline/eye-off.svg';
import eyeIcon from '@tabler/icons/outline/eye.svg';
import headsetIcon from '@tabler/icons/outline/headset.svg';
import trashIcon from '@tabler/icons/outline/trash.svg';
import clsx from 'clsx';
import { forwardRef, useEffect, useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals.ts';
import { deleteStatus } from 'soapbox/actions/statuses.ts';
import DropdownMenu from 'soapbox/components/dropdown-menu/index.ts';
import Button from 'soapbox/components/ui/button.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import { Entities, EntityTypes } from 'soapbox/entity-store/entities.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
import { useSettings } from 'soapbox/hooks/useSettings.ts';
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
import { emojifyText } from 'soapbox/utils/emojify.tsx';
import { defaultMediaVisibility } from 'soapbox/utils/status.ts';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' },
deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' },
hide: { id: 'moderation_overlay.hide', defaultMessage: 'Hide content' },
sensitiveTitle: { id: 'status.sensitive_warning', defaultMessage: 'Sensitive content' },
underReviewTitle: { id: 'moderation_overlay.title', defaultMessage: 'Content Under Review' },
underReviewSubtitle: { id: 'moderation_overlay.subtitle', defaultMessage: 'This Post has been sent to Moderation for review and is only visible to you. If you believe this is an error please contact Support.' },
sensitiveSubtitle: { id: 'status.sensitive_warning.subtitle', defaultMessage: 'This content may not be suitable for all audiences.' },
contact: { id: 'moderation_overlay.contact', defaultMessage: 'Contact' },
show: { id: 'moderation_overlay.show', defaultMessage: 'Show Content' },
});
interface IPureSensitiveContentOverlay {
status: EntityTypes[Entities.STATUSES];
onToggleVisibility?(): void;
visible?: boolean;
}
const PureSensitiveContentOverlay = forwardRef<HTMLDivElement, IPureSensitiveContentOverlay>((props, ref) => {
const { onToggleVisibility, status } = props;
const { account } = useOwnAccount();
const dispatch = useAppDispatch();
const intl = useIntl();
const { displayMedia, deleteModal } = useSettings();
const { links } = useSoapboxConfig();
const isUnderReview = status.visibility === 'self';
const isOwnStatus = status.account.id === account?.id;
const [visible, setVisible] = useState<boolean>(defaultMediaVisibility(status, displayMedia));
const toggleVisibility = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (onToggleVisibility) {
onToggleVisibility();
} else {
setVisible((prevValue) => !prevValue);
}
};
const handleDeleteStatus = () => {
if (!deleteModal) {
dispatch(deleteStatus(status.id, false));
} else {
dispatch(openModal('CONFIRM', {
icon: trashIcon,
heading: intl.formatMessage(messages.deleteHeading),
message: intl.formatMessage(messages.deleteMessage),
confirm: intl.formatMessage(messages.deleteConfirm),
onConfirm: () => dispatch(deleteStatus(status.id, false)),
}));
}
};
const menu = useMemo(() => {
return [
{
text: intl.formatMessage(messages.delete),
action: handleDeleteStatus,
icon: trashIcon,
destructive: true,
},
];
}, []);
useEffect(() => {
if (typeof props.visible !== 'undefined') {
setVisible(!!props.visible);
}
}, [props.visible]);
return (
<div
className={clsx('absolute z-40', {
'cursor-default backdrop-blur-lg rounded-lg w-full h-full border-0 flex justify-center': !visible,
'bg-gray-800/75 inset-0': !visible,
'bottom-1 right-1': visible,
})}
data-testid='sensitive-overlay'
>
{visible ? (
<Button
text={intl.formatMessage(messages.hide)}
icon={eyeOffIcon}
onClick={toggleVisibility}
theme='primary'
size='sm'
/>
) : (
<div className='flex max-h-screen items-center justify-center'>
<div className='mx-auto w-3/4 space-y-4 text-center' ref={ref}>
<div className='space-y-1'>
<Text theme='white' weight='semibold'>
{intl.formatMessage(isUnderReview ? messages.underReviewTitle : messages.sensitiveTitle)}
</Text>
<Text theme='white' size='sm' weight='medium'>
{intl.formatMessage(isUnderReview ? messages.underReviewSubtitle : messages.sensitiveSubtitle)}
</Text>
{status.spoiler_text && (
<div className='py-4 italic'>
{/* eslint-disable formatjs/no-literal-string-in-jsx */}
<Text className='line-clamp-6' theme='white' size='md' weight='medium'>
&ldquo;<span>{emojifyText(status.spoiler_text, status.emojis)}</span>&rdquo;
</Text>
{/* eslint-enable formatjs/no-literal-string-in-jsx */}
</div>
)}
</div>
<HStack alignItems='center' justifyContent='center' space={2}>
{isUnderReview ? (
<>
{links.get('support') && (
<a
href={links.get('support')}
target='_blank'
onClick={(event) => event.stopPropagation()}
>
<Button
type='button'
theme='outline'
size='sm'
icon={headsetIcon}
>
{intl.formatMessage(messages.contact)}
</Button>
</a>
)}
</>
) : null}
<Button
type='button'
theme='outline'
size='sm'
icon={eyeIcon}
onClick={toggleVisibility}
>
{intl.formatMessage(messages.show)}
</Button>
{(isUnderReview && isOwnStatus) ? (
<DropdownMenu
items={menu}
src={dotsIcon}
/>
) : null}
</HStack>
</div>
</div>
)}
</div>
);
});
export default PureSensitiveContentOverlay;

View File

@ -1,14 +1,10 @@
import { debounce } from 'es-toolkit'; import { debounce } from 'es-toolkit';
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import { useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks.ts'; import { useBookmarks } from 'soapbox/api/hooks/index.ts';
import PullToRefresh from 'soapbox/components/pull-to-refresh.tsx'; import PullToRefresh from 'soapbox/components/pull-to-refresh.tsx';
import StatusList from 'soapbox/components/status-list.tsx'; import PureStatusList from 'soapbox/components/pure-status-list.tsx';
import { Column } from 'soapbox/components/ui/column.tsx'; import { Column } from 'soapbox/components/ui/column.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useIsMobile } from 'soapbox/hooks/useIsMobile.ts'; import { useIsMobile } from 'soapbox/hooks/useIsMobile.ts';
import { useTheme } from 'soapbox/hooks/useTheme.ts'; import { useTheme } from 'soapbox/hooks/useTheme.ts';
@ -16,35 +12,20 @@ const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
}); });
const handleLoadMore = debounce((dispatch) => { const Bookmarks: React.FC = () => {
dispatch(expandBookmarkedStatuses());
}, 300, { edges: ['leading'] });
interface IBookmarks {
params?: {
id?: string;
};
}
const Bookmarks: React.FC<IBookmarks> = ({ params }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch();
const theme = useTheme(); const theme = useTheme();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const bookmarks = 'bookmarks'; const handleLoadMore = debounce(() => {
fetchNextPage();
}, 300, { edges: ['leading'] });
const statusIds = useAppSelector((state) => state.status_lists.get(bookmarks)?.items || ImmutableOrderedSet<string>()); const { bookmarks, isLoading, hasNextPage, fetchEntities, fetchNextPage } = useBookmarks();
const isLoading = useAppSelector((state) => state.status_lists.get(bookmarks)?.isLoading === true);
const hasMore = useAppSelector((state) => !!state.status_lists.get(bookmarks)?.next);
useEffect(() => {
dispatch(fetchBookmarkedStatuses());
}, []);
const handleRefresh = () => { const handleRefresh = () => {
return dispatch(fetchBookmarkedStatuses()); return fetchEntities();
}; };
const emptyMessage = <FormattedMessage id='empty_column.bookmarks' defaultMessage="You don't have any bookmarks yet. When you add one, it will show up here." />; const emptyMessage = <FormattedMessage id='empty_column.bookmarks' defaultMessage="You don't have any bookmarks yet. When you add one, it will show up here." />;
@ -52,13 +33,13 @@ const Bookmarks: React.FC<IBookmarks> = ({ params }) => {
return ( return (
<Column label={intl.formatMessage(messages.heading)} transparent> <Column label={intl.formatMessage(messages.heading)} transparent>
<PullToRefresh onRefresh={handleRefresh}> <PullToRefresh onRefresh={handleRefresh}>
<StatusList <PureStatusList
className='black:p-4 black:sm:p-5' className='black:p-4 black:sm:p-5'
statusIds={statusIds} statuses={bookmarks}
scrollKey='bookmarked_statuses' scrollKey='bookmarked_statuses'
hasMore={hasMore} hasMore={hasNextPage}
isLoading={typeof isLoading === 'boolean' ? isLoading : true} isLoading={typeof isLoading === 'boolean' ? isLoading : true}
onLoadMore={() => handleLoadMore(dispatch)} onLoadMore={() => handleLoadMore()}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
divideType={(theme === 'black' || isMobile) ? 'border' : 'space'} divideType={(theme === 'black' || isMobile) ? 'border' : 'space'}
/> />

View File

@ -50,7 +50,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
className='break-words' className='break-words'
size='sm' size='sm'
direction={getTextDirection(status.search_index)} direction={getTextDirection(status.search_index)}
emojis={status.emojis.toJS()} emojis={status?.emojis?.toJS() ?? status.emojis} // Use toJS() if status.emojis is immutable; otherwise, fallback to plain status.emojis
mentions={status.mentions.toJS()} mentions={status.mentions.toJS()}
html={{ __html: status.content }} html={{ __html: status.content }}
/> />

View File

@ -0,0 +1,103 @@
import banIcon from '@tabler/icons/outline/ban.svg';
import checkIcon from '@tabler/icons/outline/check.svg';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { joinEvent, leaveEvent } from 'soapbox/actions/events.ts';
import { openModal } from 'soapbox/actions/modals.ts';
import Button from 'soapbox/components/ui/button.tsx';
import { Entities, EntityTypes } from 'soapbox/entity-store/entities.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import type { ButtonThemes } from 'soapbox/components/ui/useButtonStyles.ts';
const messages = defineMessages({
leaveConfirm: { id: 'confirmations.leave_event.confirm', defaultMessage: 'Leave event' },
leaveMessage: { id: 'confirmations.leave_event.message', defaultMessage: 'If you want to rejoin the event, the request will be manually reviewed again. Are you sure you want to proceed?' },
});
interface IPureEventAction {
status: EntityTypes[Entities.STATUSES];
theme?: ButtonThemes;
}
const PureEventActionButton: React.FC<IPureEventAction> = ({ status, theme = 'secondary' }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const me = useAppSelector((state) => state.me);
const event = status.event!;
const handleJoin: React.EventHandler<React.MouseEvent> = (e) => {
e.preventDefault();
if (event.join_mode === 'free') {
dispatch(joinEvent(status.id));
} else {
dispatch(openModal('JOIN_EVENT', {
statusId: status.id,
}));
}
};
const handleLeave: React.EventHandler<React.MouseEvent> = (e) => {
e.preventDefault();
if (event.join_mode === 'restricted') {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.leaveMessage),
confirm: intl.formatMessage(messages.leaveConfirm),
onConfirm: () => dispatch(leaveEvent(status.id)),
}));
} else {
dispatch(leaveEvent(status.id));
}
};
const handleOpenUnauthorizedModal: React.EventHandler<React.MouseEvent> = (e) => {
e.preventDefault();
dispatch(openModal('UNAUTHORIZED', {
action: 'JOIN',
ap_id: status.url,
}));
};
let buttonLabel;
let buttonIcon;
let buttonDisabled = false;
let buttonAction = handleLeave;
switch (event.join_state) {
case 'accept':
buttonLabel = <FormattedMessage id='event.join_state.accept' defaultMessage='Going' />;
buttonIcon = checkIcon;
break;
case 'pending':
buttonLabel = <FormattedMessage id='event.join_state.pending' defaultMessage='Pending' />;
break;
case 'reject':
buttonLabel = <FormattedMessage id='event.join_state.rejected' defaultMessage='Going' />;
buttonIcon = banIcon;
buttonDisabled = true;
break;
default:
buttonLabel = <FormattedMessage id='event.join_state.empty' defaultMessage='Participate' />;
buttonAction = me ? handleJoin : handleOpenUnauthorizedModal;
}
return (
<Button
size='sm'
theme={theme}
icon={buttonIcon}
onClick={buttonAction}
disabled={buttonDisabled}
>
{buttonLabel}
</Button>
);
};
export default PureEventActionButton;

View File

@ -0,0 +1,59 @@
import calendarIcon from '@tabler/icons/outline/calendar.svg';
import { FormattedDate } from 'react-intl';
import Icon from 'soapbox/components/icon.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import { Entities, EntityTypes } from 'soapbox/entity-store/entities.ts';
interface IPureEventDate {
status: EntityTypes[Entities.STATUSES];
}
const PureEventDate: React.FC<IPureEventDate> = ({ status }) => {
const event = status.event!;
if (!event.start_time) return null;
const startDate = new Date(event.start_time);
let date;
if (event.end_time) {
const endDate = new Date(event.end_time);
const sameYear = startDate.getFullYear() === endDate.getFullYear();
const sameDay = startDate.getDate() === endDate.getDate() && startDate.getMonth() === endDate.getMonth() && sameYear;
if (sameDay) {
date = (
<>
<FormattedDate value={event.start_time} year={sameYear ? undefined : '2-digit'} month='short' day='2-digit' weekday='short' hour='2-digit' minute='2-digit' />
{' - '}
<FormattedDate value={event.end_time} hour='2-digit' minute='2-digit' />
</>
);
} else {
date = (
<>
<FormattedDate value={event.start_time} year='2-digit' month='short' day='2-digit' weekday='short' />
{' - '}
<FormattedDate value={event.end_time} year='2-digit' month='short' day='2-digit' weekday='short' />
</>
);
}
} else {
date = (
<FormattedDate value={event.start_time} year='2-digit' month='short' day='2-digit' weekday='short' hour='2-digit' minute='2-digit' />
);
}
return (
<HStack alignItems='center' space={2}>
<Icon src={calendarIcon} />
<span>{date}</span>
</HStack>
);
};
export default PureEventDate;

31
src/hooks/useFavourite.ts Normal file
View File

@ -0,0 +1,31 @@
import { favourite as favouriteAction, unfavourite as unfavouriteAction, toggleFavourite as toggleFavouriteAction } from 'soapbox/actions/interactions.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useGetState } from 'soapbox/hooks/useGetState.ts';
export function useFavourite() {
const getState = useGetState();
const dispatch = useAppDispatch();
const favourite = (statusId: string) => {
const status = getState().statuses.get(statusId);
if (status) {
dispatch(favouriteAction(status));
}
};
const unfavourite = (statusId: string) => {
const status = getState().statuses.get(statusId);
if (status) {
dispatch(unfavouriteAction(status));
}
};
const toggleFavourite = (statusId: string) => {
const status = getState().statuses.get(statusId);
if (status) {
dispatch(toggleFavouriteAction(status));
}
};
return { favourite, unfavourite, toggleFavourite };
}

View File

@ -0,0 +1,13 @@
import { mentionCompose as mentionComposeAction } from 'soapbox/actions/compose.ts';
import { EntityTypes, Entities } from 'soapbox/entity-store/entities.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
export function useMentionCompose() {
const dispatch = useAppDispatch();
const mentionCompose = (account: EntityTypes[Entities.ACCOUNTS]) => {
dispatch(mentionComposeAction(account));
};
return { mentionCompose };
}

31
src/hooks/useReblog.ts Normal file
View File

@ -0,0 +1,31 @@
import { reblog as reblogAction, unreblog as unreblogAction, toggleReblog as toggleReblogAction } from 'soapbox/actions/interactions.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useGetState } from 'soapbox/hooks/useGetState.ts';
export function useReblog() {
const getState = useGetState();
const dispatch = useAppDispatch();
const reblog = (statusId: string) => {
const status = getState().statuses.get(statusId);
if (status) {
dispatch(reblogAction(status));
}
};
const unreblog = (statusId: string) => {
const status = getState().statuses.get(statusId);
if (status) {
dispatch(unreblogAction(status));
}
};
const toggleReblog = (statusId: string) => {
const status = getState().statuses.get(statusId);
if (status) {
dispatch(toggleReblogAction(status));
}
};
return { reblog, unreblog, toggleReblog };
}

View File

@ -0,0 +1,17 @@
import { replyCompose as replyComposeAction } from 'soapbox/actions/compose.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useGetState } from 'soapbox/hooks/useGetState.ts';
export function useReplyCompose() {
const getState = useGetState();
const dispatch = useAppDispatch();
const replyCompose = (statusId: string) => {
const status = getState().statuses.get(statusId);
if (status) {
dispatch(replyComposeAction(status));
}
};
return { replyCompose };
}

View File

@ -0,0 +1,25 @@
import { revealStatus as revealStatusAction, hideStatus as hideStatusAction, toggleStatusHidden as toggleStatusHiddenAction } from 'soapbox/actions/statuses.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useGetState } from 'soapbox/hooks/useGetState.ts';
export function useStatusHidden() {
const getState = useGetState();
const dispatch = useAppDispatch();
const revealStatus = (statusId: string) => {
dispatch(revealStatusAction(statusId));
};
const hideStatus = (statusId: string) => {
dispatch(hideStatusAction(statusId));
};
const toggleStatusHidden = (statusId: string) => {
const status = getState().statuses.get(statusId);
if (status) {
dispatch(toggleStatusHiddenAction(status));
}
};
return { revealStatus, hideStatus, toggleStatusHidden };
}

View File

@ -72,6 +72,7 @@ const baseStatusSchema = z.object({
url: z.string().url().catch(''), url: z.string().url().catch(''),
visibility: z.string().catch('public'), visibility: z.string().catch('public'),
zapped: z.coerce.boolean(), zapped: z.coerce.boolean(),
zaps_amount: z.number().catch(0),
}); });
type BaseStatus = z.infer<typeof baseStatusSchema>; type BaseStatus = z.infer<typeof baseStatusSchema>;