From ffe99335e8b65c934377dd2c50859a74368e3d96 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Sun, 1 Dec 2024 23:03:41 -0300 Subject: [PATCH] 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 --- src/api/hooks/index.ts | 3 + src/api/hooks/statuses/useBookmarks.ts | 25 ++ src/components/new-status-list.tsx | 200 ++++++++++ src/components/new-status.tsx | 499 +++++++++++++++++++++++++ src/features/bookmarks/index.tsx | 15 +- 5 files changed, 734 insertions(+), 8 deletions(-) create mode 100644 src/api/hooks/statuses/useBookmarks.ts create mode 100644 src/components/new-status-list.tsx create mode 100644 src/components/new-status.tsx diff --git a/src/api/hooks/index.ts b/src/api/hooks/index.ts index 3bb6aee81..990dda57b 100644 --- a/src/api/hooks/index.ts +++ b/src/api/hooks/index.ts @@ -43,6 +43,9 @@ export { useUnmuteGroup } from './groups/useUnmuteGroup.ts'; export { useUpdateGroup } from './groups/useUpdateGroup.ts'; export { useUpdateGroupTag } from './groups/useUpdateGroupTag.ts'; +// Statuses +export { useBookmarks } from './statuses/useBookmarks.ts'; + // Streaming export { useUserStream } from './streaming/useUserStream.ts'; export { useCommunityStream } from './streaming/useCommunityStream.ts'; diff --git a/src/api/hooks/statuses/useBookmarks.ts b/src/api/hooks/statuses/useBookmarks.ts new file mode 100644 index 000000000..012870ec6 --- /dev/null +++ b/src/api/hooks/statuses/useBookmarks.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( + [Entities.STATUSES, 'bookmarks'], + () => api.get('/api/v1/bookmarks'), + { enabled: features.bookmarks, schema: statusSchema }, + ); + + const bookmarks = entities; + + return { + ...result, + bookmarks, + }; +} + +export { useBookmarks }; \ No newline at end of file diff --git a/src/components/new-status-list.tsx b/src/components/new-status-list.tsx new file mode 100644 index 000000000..675d2486b --- /dev/null +++ b/src/components/new-status-list.tsx @@ -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{ + /** List of statuses to display. */ + statuses: readonly EntityTypes[Entities.STATUSES][]|null; + /** Pinned statuses to show at the top of the feed. */ + featuredStatusIds?: Set; + /** 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 = ({ + statuses, + featuredStatusIds, + isLoading, + onLoadMore, +}) => { + const node = useRef(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(`#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 ( + + ); + }; + + const renderStatus = (status: EntityTypes[Entities.STATUSES]) => { + return ( + + ); + }; + + const renderPendingStatus = (statusId: string) => { + const idempotencyKey = statusId.replace(/^末pending-/, ''); + + return ( + + ); + }; + + const renderFeaturedStatuses = (): React.ReactNode[] => { + if (!featuredStatusIds) return []; + + return Array.from(featuredStatusIds).map(statusId => ( + + )); + }; + + const renderFeedSuggestions = (statusId: string): React.ReactNode => { + return ( + + ); + }; + + 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 ( + } + // 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()} + + ); +}; + +export default NewStatusList; \ No newline at end of file diff --git a/src/components/new-status.tsx b/src/components/new-status.tsx new file mode 100644 index 000000000..855c51606 --- /dev/null +++ b/src/components/new-status.tsx @@ -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 = (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(null); + const overlay = useRef(null); + + const [showMedia, setShowMedia] = useState(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 ( + } + text={ + + + + {emojifyText(status.account.display_name, status.account.emojis)} + + + + ), + group: ( + + + {group.display_name} + + + ), + }} + /> + } + /> + ); + } else if (isReblog) { + return ( + } + text={ + + + + {emojifyText(status.account.display_name, status.account.emojis)} + + + + ), + }} + /> + } + /> + ); + } else if (featured) { + return ( + } + text={ + + } + /> + ); + } else if (showGroup && group) { + return ( + } + text={ + + + + {group.display_name} + + + + ), + }} + /> + } + /> + ); + } + }; + + if (!status) return null; + + if (hidden) { + return ( +
+ <> + {actualStatus.account.display_name || actualStatus.account.username} + {actualStatus.content} + +
+ ); + } + + if (filtered && status.showFiltered) { + const minHandlers = muted ? undefined : { + moveUp: handleHotkeyMoveUp, + moveDown: handleHotkeyMoveDown, + }; + + return ( + +
+ {/* eslint-disable formatjs/no-literal-string-in-jsx */} + + : {status.filtered.join(', ')}. + {' '} + + + {/* eslint-enable formatjs/no-literal-string-in-jsx */} +
+
+ ); + } + + 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 = ( +
+

+
+ ); + } else { + quote = ; + } + } + + 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 ( + onMoveUp ? onMoveUp(id) : null} + onMoveDown={(id) => onMoveDown ? onMoveDown(id) : null} + /> + ); + } + + return ( + + {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} +
+ + {renderStatusInfo()} + + + +
+ {/* fix later */} + + + {(isUnderReview || isSensitive) && ( + + )} + + {actualStatus.event ? : ( // fix later + + + + {/* fix later */} + + {(quote || actualStatus.card || actualStatus.media_attachments.length > 0) && ( + + + + {quote} + + )} + + )} + + + {(!hideActionBar && !isUnderReview) && ( +
+ {/* fix later */} +
+ )} +
+
+
+
+ ); +}; + +export default NewStatus; \ No newline at end of file diff --git a/src/features/bookmarks/index.tsx b/src/features/bookmarks/index.tsx index 444327f9b..547c1f5e6 100644 --- a/src/features/bookmarks/index.tsx +++ b/src/features/bookmarks/index.tsx @@ -1,11 +1,11 @@ import { debounce } from 'es-toolkit'; -import { OrderedSet as ImmutableOrderedSet } from 'immutable'; import { useEffect } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks.ts'; +import { useBookmarks } from 'soapbox/api/hooks/index.ts'; +import NewStatusList from 'soapbox/components/new-status-list.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 { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; @@ -33,11 +33,10 @@ const Bookmarks: React.FC = ({ params }) => { const theme = useTheme(); const isMobile = useIsMobile(); - const bookmarks = 'bookmarks'; + const { bookmarks } = useBookmarks(); - const statusIds = useAppSelector((state) => state.status_lists.get(bookmarks)?.items || ImmutableOrderedSet()); - const isLoading = useAppSelector((state) => state.status_lists.get(bookmarks)?.isLoading === true); - const hasMore = useAppSelector((state) => !!state.status_lists.get(bookmarks)?.next); + const isLoading = useAppSelector((state) => state.status_lists.get('bookmarks')?.isLoading === true); + const hasMore = useAppSelector((state) => !!state.status_lists.get('bookmarks')?.next); useEffect(() => { dispatch(fetchBookmarkedStatuses()); @@ -52,9 +51,9 @@ const Bookmarks: React.FC = ({ params }) => { return ( -