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 { 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';
|
||||||
|
|
|
@ -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 { 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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 { 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'}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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 }}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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(''),
|
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>;
|
||||||
|
|
Loading…
Reference in New Issue