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:
commit
b03c29c371
|
@ -43,6 +43,9 @@ export { useUnmuteGroup } from './groups/useUnmuteGroup.ts';
|
|||
export { useUpdateGroup } from './groups/useUpdateGroup.ts';
|
||||
export { useUpdateGroupTag } from './groups/useUpdateGroupTag.ts';
|
||||
|
||||
// Statuses
|
||||
export { useBookmarks } from './statuses/useBookmarks.ts';
|
||||
|
||||
// Streaming
|
||||
export { useUserStream } from './streaming/useUserStream.ts';
|
||||
export { useCommunityStream } from './streaming/useCommunityStream.ts';
|
||||
|
|
|
@ -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 };
|
|
@ -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;
|
|
@ -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);
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -54,11 +54,9 @@ import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
|||
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
|
||||
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
|
||||
import { useSettings } from 'soapbox/hooks/useSettings.ts';
|
||||
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
|
||||
import { GroupRoles } from 'soapbox/schemas/group-member.ts';
|
||||
import toast from 'soapbox/toast.tsx';
|
||||
import copy from 'soapbox/utils/copy.ts';
|
||||
import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts.ts';
|
||||
|
||||
import GroupPopover from './groups/popover/group-popover.tsx';
|
||||
|
||||
|
@ -170,9 +168,6 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
const { groupRelationship } = useGroupRelationship(status.group?.id);
|
||||
const features = useFeatures();
|
||||
const { boostModal, deleteModal } = useSettings();
|
||||
const soapboxConfig = useSoapboxConfig();
|
||||
|
||||
const { allowedEmoji } = soapboxConfig;
|
||||
|
||||
const { account } = useOwnAccount();
|
||||
const isStaff = account ? account.staff : false;
|
||||
|
@ -662,14 +657,9 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
const reblogCount = status.reblogs_count;
|
||||
const favouriteCount = status.favourites_count;
|
||||
|
||||
const emojiReactCount = status.reactions ? reduceEmoji(
|
||||
status.reactions,
|
||||
favouriteCount,
|
||||
status.favourited,
|
||||
allowedEmoji,
|
||||
).reduce((acc, cur) => acc + (cur.count || 0), 0) : undefined;
|
||||
const emojiReactCount = status.reactions?.reduce((acc, reaction) => acc + (reaction.count ?? 0), 0) ?? 0; // allow all emojis
|
||||
|
||||
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 reactMessages = {
|
||||
|
@ -783,7 +773,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
filled
|
||||
color='accent'
|
||||
active={Boolean(meEmojiName)}
|
||||
count={emojiReactCount}
|
||||
count={emojiReactCount + favouriteCount}
|
||||
emoji={meEmojiReact}
|
||||
theme={statusActionButtonTheme}
|
||||
/>
|
||||
|
|
|
@ -44,7 +44,10 @@ interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
|
|||
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> = ({
|
||||
statusIds,
|
||||
lastStatusId,
|
||||
|
|
|
@ -60,6 +60,10 @@ export interface IStatus {
|
|||
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,
|
||||
|
|
|
@ -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'>
|
||||
“<span>{emojifyText(status.spoiler_text, status.emojis)}</span>”
|
||||
</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;
|
|
@ -1,14 +1,10 @@
|
|||
import { debounce } from 'es-toolkit';
|
||||
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import { useEffect } from 'react';
|
||||
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 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 { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||
import { useIsMobile } from 'soapbox/hooks/useIsMobile.ts';
|
||||
import { useTheme } from 'soapbox/hooks/useTheme.ts';
|
||||
|
||||
|
@ -16,35 +12,20 @@ const messages = defineMessages({
|
|||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||
});
|
||||
|
||||
const handleLoadMore = debounce((dispatch) => {
|
||||
dispatch(expandBookmarkedStatuses());
|
||||
}, 300, { edges: ['leading'] });
|
||||
|
||||
interface IBookmarks {
|
||||
params?: {
|
||||
id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const Bookmarks: React.FC<IBookmarks> = ({ params }) => {
|
||||
const Bookmarks: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
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 isLoading = useAppSelector((state) => state.status_lists.get(bookmarks)?.isLoading === true);
|
||||
const hasMore = useAppSelector((state) => !!state.status_lists.get(bookmarks)?.next);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchBookmarkedStatuses());
|
||||
}, []);
|
||||
const { bookmarks, isLoading, hasNextPage, fetchEntities, fetchNextPage } = useBookmarks();
|
||||
|
||||
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." />;
|
||||
|
@ -52,13 +33,13 @@ const Bookmarks: React.FC<IBookmarks> = ({ params }) => {
|
|||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<StatusList
|
||||
<PureStatusList
|
||||
className='black:p-4 black:sm:p-5'
|
||||
statusIds={statusIds}
|
||||
statuses={bookmarks}
|
||||
scrollKey='bookmarked_statuses'
|
||||
hasMore={hasMore}
|
||||
hasMore={hasNextPage}
|
||||
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
||||
onLoadMore={() => handleLoadMore(dispatch)}
|
||||
onLoadMore={() => handleLoadMore()}
|
||||
emptyMessage={emptyMessage}
|
||||
divideType={(theme === 'black' || isMobile) ? 'border' : 'space'}
|
||||
/>
|
||||
|
|
|
@ -50,7 +50,7 @@ const ReplyIndicator: React.FC<IReplyIndicator> = ({ className, status, hideActi
|
|||
className='break-words'
|
||||
size='sm'
|
||||
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()}
|
||||
html={{ __html: status.content }}
|
||||
/>
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 };
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -72,6 +72,7 @@ const baseStatusSchema = z.object({
|
|||
url: z.string().url().catch(''),
|
||||
visibility: z.string().catch('public'),
|
||||
zapped: z.coerce.boolean(),
|
||||
zaps_amount: z.number().catch(0),
|
||||
});
|
||||
|
||||
type BaseStatus = z.infer<typeof baseStatusSchema>;
|
||||
|
|
Loading…
Reference in New Issue