draft/checkpoint: useBookmarks hook, start to rewrite all 'Status' and 'StatusList' components that rely in immutable, and all its children components and logic that also rely on immutable
in the transition phrase will name all new components with the 'New' keyword, to keep things organized
This commit is contained in:
parent
1e2ca045c7
commit
ffe99335e8
|
@ -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,200 @@
|
||||||
|
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
|
import LoadGap from 'soapbox/components/load-gap.tsx';
|
||||||
|
import NewStatus from 'soapbox/components/new-status.tsx';
|
||||||
|
import ScrollableList, { IScrollableList } from 'soapbox/components/scrollable-list.tsx';
|
||||||
|
import StatusContainer from 'soapbox/containers/status-container.tsx';
|
||||||
|
import { EntityTypes, Entities } from 'soapbox/entity-store/entities.ts';
|
||||||
|
import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions.tsx';
|
||||||
|
import PendingStatus from 'soapbox/features/ui/components/pending-status.tsx';
|
||||||
|
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
|
||||||
|
|
||||||
|
|
||||||
|
interface INewStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'>{
|
||||||
|
/** List of statuses to display. */
|
||||||
|
statuses: readonly EntityTypes[Entities.STATUSES][]|null;
|
||||||
|
/** Pinned statuses to show at the top of the feed. */
|
||||||
|
featuredStatusIds?: Set<string>;
|
||||||
|
/** Whether the data is currently being fetched. */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Pagination callback when the end of the list is reached. */
|
||||||
|
onLoadMore?: (lastStatusId: string) => void;
|
||||||
|
|
||||||
|
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NewStatusList: React.FC<INewStatusList> = ({
|
||||||
|
statuses,
|
||||||
|
featuredStatusIds,
|
||||||
|
isLoading,
|
||||||
|
onLoadMore,
|
||||||
|
}) => {
|
||||||
|
const node = useRef<VirtuosoHandle>(null);
|
||||||
|
const soapboxConfig = useSoapboxConfig();
|
||||||
|
|
||||||
|
const getFeaturedStatusCount = () => {
|
||||||
|
return featuredStatusIds?.size || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 getCurrentStatusIndex = (id: string, featured: boolean): number => {
|
||||||
|
if (featured) {
|
||||||
|
return Array.from(featuredStatusIds?.keys() ?? []).findIndex(key => key === 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 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 (
|
||||||
|
<NewStatus
|
||||||
|
status={status}
|
||||||
|
key={status.id}
|
||||||
|
id={status.id}
|
||||||
|
onMoveUp={handleMoveUp}
|
||||||
|
// onMoveDown={handleMoveDown}
|
||||||
|
// contextType={timelineId}
|
||||||
|
// 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 (!featuredStatusIds) return [];
|
||||||
|
|
||||||
|
return Array.from(featuredStatusIds).map(statusId => (
|
||||||
|
<StatusContainer
|
||||||
|
key={`f-${statusId}`}
|
||||||
|
id={statusId}
|
||||||
|
featured
|
||||||
|
onMoveUp={handleMoveUp}
|
||||||
|
// onMoveDown={handleMoveDown}
|
||||||
|
// contextType={timelineId}
|
||||||
|
// showGroup={showGroup}
|
||||||
|
// variant={divideType === 'border' ? 'slim' : 'default'}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollableList
|
||||||
|
id='status-list'
|
||||||
|
key='scrollable-list'
|
||||||
|
// isLoading={isLoading}
|
||||||
|
// showLoading={isLoading && statusIds.size === 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 NewStatusList;
|
|
@ -0,0 +1,499 @@
|
||||||
|
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 { mentionCompose, replyCompose } from 'soapbox/actions/compose.ts';
|
||||||
|
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions.ts';
|
||||||
|
import { openModal } from 'soapbox/actions/modals.ts';
|
||||||
|
import { toggleStatusHidden, unfilterStatus } from 'soapbox/actions/statuses.ts';
|
||||||
|
import TranslateButton from 'soapbox/components/translate-button.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 { useSettings } from 'soapbox/hooks/useSettings.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 EventPreview from './event-preview.tsx';
|
||||||
|
import StatusActionBar from './status-action-bar.tsx';
|
||||||
|
import StatusContent from './status-content.tsx';
|
||||||
|
import StatusMedia from './status-media.tsx';
|
||||||
|
import StatusReplyMentions from './status-reply-mentions.tsx';
|
||||||
|
import SensitiveContentOverlay from './statuses/sensitive-content-overlay.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 INewStatus {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NewStatus: React.FC<INewStatus> = (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;
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
const getStatus = useCallback(makeGetStatus(), []);
|
||||||
|
const statusImmutable = useAppSelector(state => getStatus(state, { id: status.id }));
|
||||||
|
if (!statusImmutable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
dispatch(replyCompose(statusImmutable)); // fix later
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyFavourite = (): void => {
|
||||||
|
toggleFavourite(statusImmutable); // fix later
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyBoost = (e?: KeyboardEvent): void => {
|
||||||
|
const modalReblog = () => dispatch(toggleReblog(statusImmutable)); // fix later
|
||||||
|
if ((e && e.shiftKey) || !boostModal) {
|
||||||
|
modalReblog();
|
||||||
|
} else {
|
||||||
|
dispatch(openModal('BOOST', { status: actualStatus, onReblog: modalReblog }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyMention = (e?: KeyboardEvent): void => {
|
||||||
|
e?.preventDefault();
|
||||||
|
dispatch(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 => {
|
||||||
|
dispatch(toggleStatusHidden(statusImmutable)); // fix later
|
||||||
|
};
|
||||||
|
|
||||||
|
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 === 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'>
|
||||||
|
<StatusReplyMentions status={statusImmutable} hoverable={hoverable} /> {/* fix later */}
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
className='relative z-0'
|
||||||
|
style={{ minHeight: isUnderReview || isSensitive ? Math.max(minHeight, 208) + 12 : undefined }}
|
||||||
|
>
|
||||||
|
{(isUnderReview || isSensitive) && (
|
||||||
|
<SensitiveContentOverlay
|
||||||
|
status={statusImmutable} // fix later
|
||||||
|
visible={showMedia}
|
||||||
|
onToggleVisibility={handleToggleMediaVisibility}
|
||||||
|
ref={overlay}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actualStatus.event ? <EventPreview className='shadow-xl' status={statusImmutable} /> : ( // fix later
|
||||||
|
<Stack space={4}>
|
||||||
|
<StatusContent
|
||||||
|
status={statusImmutable} // fix later
|
||||||
|
onClick={handleClick}
|
||||||
|
collapsable
|
||||||
|
translatable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TranslateButton status={statusImmutable} /> {/* fix later */}
|
||||||
|
|
||||||
|
{(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 NewStatus;
|
|
@ -1,11 +1,11 @@
|
||||||
import { debounce } from 'es-toolkit';
|
import { debounce } from 'es-toolkit';
|
||||||
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
|
||||||
import { useEffect } from 'react';
|
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 { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks.ts';
|
||||||
|
import { useBookmarks } from 'soapbox/api/hooks/index.ts';
|
||||||
|
import NewStatusList from 'soapbox/components/new-status-list.tsx';
|
||||||
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 { Column } from 'soapbox/components/ui/column.tsx';
|
import { Column } from 'soapbox/components/ui/column.tsx';
|
||||||
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
|
||||||
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
|
||||||
|
@ -33,11 +33,10 @@ const Bookmarks: React.FC<IBookmarks> = ({ params }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const bookmarks = 'bookmarks';
|
const { bookmarks } = useBookmarks();
|
||||||
|
|
||||||
const statusIds = useAppSelector((state) => state.status_lists.get(bookmarks)?.items || ImmutableOrderedSet<string>());
|
const isLoading = useAppSelector((state) => state.status_lists.get('bookmarks')?.isLoading === true);
|
||||||
const isLoading = useAppSelector((state) => state.status_lists.get(bookmarks)?.isLoading === true);
|
const hasMore = useAppSelector((state) => !!state.status_lists.get('bookmarks')?.next);
|
||||||
const hasMore = useAppSelector((state) => !!state.status_lists.get(bookmarks)?.next);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchBookmarkedStatuses());
|
dispatch(fetchBookmarkedStatuses());
|
||||||
|
@ -52,9 +51,9 @@ 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
|
<NewStatusList
|
||||||
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={hasMore}
|
||||||
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
||||||
|
|
Loading…
Reference in New Issue