From ffe99335e8b65c934377dd2c50859a74368e3d96 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Sun, 1 Dec 2024 23:03:41 -0300 Subject: [PATCH 01/24] 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 ( - Date: Mon, 2 Dec 2024 18:14:13 -0300 Subject: [PATCH 02/24] refactor: rename NewStatusList and NewStatus to PureStatusList and PureStatus --- .../{new-status-list.tsx => pure-status-list.tsx} | 13 ++++++++----- src/components/{new-status.tsx => pure-status.tsx} | 9 ++++++--- src/components/status-list.tsx | 5 ++++- src/components/status.tsx | 4 ++++ src/features/bookmarks/index.tsx | 4 ++-- 5 files changed, 24 insertions(+), 11 deletions(-) rename src/components/{new-status-list.tsx => pure-status-list.tsx} (95%) rename src/components/{new-status.tsx => pure-status.tsx} (98%) diff --git a/src/components/new-status-list.tsx b/src/components/pure-status-list.tsx similarity index 95% rename from src/components/new-status-list.tsx rename to src/components/pure-status-list.tsx index 675d2486b..4440506b1 100644 --- a/src/components/new-status-list.tsx +++ b/src/components/pure-status-list.tsx @@ -3,7 +3,7 @@ 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 PureStatus from 'soapbox/components/pure-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'; @@ -12,7 +12,7 @@ import PendingStatus from 'soapbox/features/ui/components/pending-status.tsx'; import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts'; -interface INewStatusList extends Omit{ +interface IPureStatusList extends Omit{ /** List of statuses to display. */ statuses: readonly EntityTypes[Entities.STATUSES][]|null; /** Pinned statuses to show at the top of the feed. */ @@ -26,7 +26,10 @@ interface INewStatusList extends Omit = ({ +/** + * Feed of statuses, built atop ScrollableList. + */ +const PureStatusList: React.FC = ({ statuses, featuredStatusIds, isLoading, @@ -85,7 +88,7 @@ const NewStatusList: React.FC = ({ const renderStatus = (status: EntityTypes[Entities.STATUSES]) => { return ( - = ({ ); }; -export default NewStatusList; \ No newline at end of file +export default PureStatusList; \ No newline at end of file diff --git a/src/components/new-status.tsx b/src/components/pure-status.tsx similarity index 98% rename from src/components/new-status.tsx rename to src/components/pure-status.tsx index 855c51606..a01fe27d3 100644 --- a/src/components/new-status.tsx +++ b/src/components/pure-status.tsx @@ -44,7 +44,7 @@ const messages = defineMessages({ reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' }, }); -export interface INewStatus { +export interface IPureStatus { id?: string; avatarSize?: number; status: EntityTypes[Entities.STATUSES]; @@ -63,7 +63,10 @@ export interface INewStatus { accountAction?: React.ReactElement; } -const NewStatus: React.FC = (props) => { +/** + * Status accepting the full status entity in pure format. + */ +const PureStatus: React.FC = (props) => { const { status, accountAction, @@ -496,4 +499,4 @@ const NewStatus: React.FC = (props) => { ); }; -export default NewStatus; \ No newline at end of file +export default PureStatus; \ No newline at end of file diff --git a/src/components/status-list.tsx b/src/components/status-list.tsx index 44905c568..1d7274f8d 100644 --- a/src/components/status-list.tsx +++ b/src/components/status-list.tsx @@ -44,7 +44,10 @@ interface IStatusList extends Omit { showGroup?: boolean; } -/** Feed of statuses, built atop ScrollableList. */ +/** + * Legacy Feed of statuses, built atop ScrollableList. + * @deprecated Use the PureStatusList component. + */ const StatusList: React.FC = ({ statusIds, lastStatusId, diff --git a/src/components/status.tsx b/src/components/status.tsx index 2995d8c5d..dc36ae974 100644 --- a/src/components/status.tsx +++ b/src/components/status.tsx @@ -60,6 +60,10 @@ export interface IStatus { accountAction?: React.ReactElement; } +/** + * Legacy Status accepting a the full entity in immutable. + * @deprecated Use the PureStatus component. + */ const Status: React.FC = (props) => { const { status, diff --git a/src/features/bookmarks/index.tsx b/src/features/bookmarks/index.tsx index 547c1f5e6..2cb4d7c71 100644 --- a/src/features/bookmarks/index.tsx +++ b/src/features/bookmarks/index.tsx @@ -4,8 +4,8 @@ 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 PureStatusList from 'soapbox/components/pure-status-list.tsx'; import { Column } from 'soapbox/components/ui/column.tsx'; import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; @@ -51,7 +51,7 @@ const Bookmarks: React.FC = ({ params }) => { return ( - Date: Mon, 2 Dec 2024 20:43:10 -0300 Subject: [PATCH 03/24] refactor: make PureStatusList the same as StatusList component (except without immutable, statusIds, etc...) --- src/components/pure-status-list.tsx | 159 ++++++++++++++++++---------- 1 file changed, 106 insertions(+), 53 deletions(-) diff --git a/src/components/pure-status-list.tsx b/src/components/pure-status-list.tsx index 4440506b1..6a8aa9064 100644 --- a/src/components/pure-status-list.tsx +++ b/src/components/pure-status-list.tsx @@ -1,29 +1,48 @@ - -import { useRef } from 'react'; -import { VirtuosoHandle } from 'react-virtuoso'; +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, { IScrollableList } from 'soapbox/components/scrollable-list.tsx'; +import ScrollableList 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 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{ + /** Unique key to preserve the scroll position when navigating back. */ + scrollKey: string; /** List of statuses to display. */ - statuses: readonly EntityTypes[Entities.STATUSES][]|null; + statuses: readonly EntityTypes[Entities.STATUSES][]; + /** Last _unfiltered_ status ID (maxId) for pagination. */ + lastStatusId?: string; /** Pinned statuses to show at the top of the feed. */ - featuredStatusIds?: Set; - /** Whether the data is currently being fetched. */ - isLoading: boolean; + featuredStatuses?: readonly EntityTypes[Entities.STATUSES][]; /** Pagination callback when the end of the list is reached. */ onLoadMore?: (lastStatusId: string) => void; - - - [key: string]: any; + /** 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; } /** @@ -31,31 +50,28 @@ interface IPureStatusList extends Omit = ({ statuses, - featuredStatusIds, - isLoading, + lastStatusId, + featuredStatuses, + divideType = 'border', onLoadMore, + timelineId, + isLoading, + isPartial, + showAds = false, + showGroup = true, + className, + ...other }) => { - const node = useRef(null); const soapboxConfig = useSoapboxConfig(); + const node = useRef(null); 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(); - }, - }); + return featuredStatuses?.length || 0; }; const getCurrentStatusIndex = (id: string, featured: boolean): number => { if (featured) { - return Array.from(featuredStatusIds?.keys() ?? []).findIndex(key => key === id) || 0; + return (featuredStatuses ?? []).findIndex(key => key.id === id) || 0; } else { return ( (statuses?.map(status => status.id) ?? []).findIndex(key => key === id) + @@ -69,6 +85,29 @@ const PureStatusList: React.FC = ({ 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(`#status-list [data-index="${index}"] .focusable`); + element?.focus(); + }, + }); + }; + const renderLoadGap = (index: number) => { const ids = statuses?.map(status => status.id) ?? []; const nextId = ids[index + 1]; @@ -93,10 +132,9 @@ const PureStatusList: React.FC = ({ key={status.id} id={status.id} onMoveUp={handleMoveUp} - // onMoveDown={handleMoveDown} - // contextType={timelineId} - // showGroup={showGroup} - // variant={divideType === 'border' ? 'slim' : 'rounded'} + onMoveDown={handleMoveDown} + showGroup={showGroup} + variant={divideType === 'border' ? 'slim' : 'rounded'} /> ); }; @@ -113,18 +151,18 @@ const PureStatusList: React.FC = ({ }; const renderFeaturedStatuses = (): React.ReactNode[] => { - if (!featuredStatusIds) return []; + if (!featuredStatuses) return []; - return Array.from(featuredStatusIds).map(statusId => ( + return (featuredStatuses ?? []).map(status => ( )); }; @@ -135,7 +173,7 @@ const PureStatusList: React.FC = ({ key='suggestions' statusId={statusId} onMoveUp={handleMoveUp} - // onMoveDown={handleMoveDown} + onMoveDown={handleMoveDown} /> ); }; @@ -177,23 +215,38 @@ const PureStatusList: React.FC = ({ } }; + if (isPartial) { + return ( +
+
+
+ + + + +
+
+
+ ); + } + 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} + isLoading={isLoading} + showLoading={isLoading && statuses.length === 0} + onLoadMore={handleLoadOlder} + placeholderComponent={() => } + 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()} From 73419b09ff8895344aab77c60d48097a436d9b0d Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 2 Dec 2024 21:34:10 -0300 Subject: [PATCH 04/24] refactor(Bookmarks): remove all dispatch actions, use hooks returned by useBookmarks --- src/features/bookmarks/index.tsx | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/src/features/bookmarks/index.tsx b/src/features/bookmarks/index.tsx index 2cb4d7c71..54e8dd631 100644 --- a/src/features/bookmarks/index.tsx +++ b/src/features/bookmarks/index.tsx @@ -2,13 +2,10 @@ import { debounce } from 'es-toolkit'; import { useEffect } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks.ts'; import { useBookmarks } from 'soapbox/api/hooks/index.ts'; import PullToRefresh from 'soapbox/components/pull-to-refresh.tsx'; import PureStatusList from 'soapbox/components/pure-status-list.tsx'; import { Column } from 'soapbox/components/ui/column.tsx'; -import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; -import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; import { useIsMobile } from 'soapbox/hooks/useIsMobile.ts'; import { useTheme } from 'soapbox/hooks/useTheme.ts'; @@ -16,34 +13,24 @@ const messages = defineMessages({ heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, }); -const handleLoadMore = debounce((dispatch) => { - dispatch(expandBookmarkedStatuses()); -}, 300, { edges: ['leading'] }); - -interface IBookmarks { - params?: { - id?: string; - }; -} - -const Bookmarks: React.FC = ({ params }) => { +const Bookmarks: React.FC = () => { const intl = useIntl(); - const dispatch = useAppDispatch(); const theme = useTheme(); const isMobile = useIsMobile(); - const { bookmarks } = useBookmarks(); + const handleLoadMore = debounce(() => { + fetchNextPage(); + }, 300, { edges: ['leading'] }); - const isLoading = useAppSelector((state) => state.status_lists.get('bookmarks')?.isLoading === true); - const hasMore = useAppSelector((state) => !!state.status_lists.get('bookmarks')?.next); + const { bookmarks, isLoading, hasNextPage, fetchEntities, fetchNextPage } = useBookmarks(); useEffect(() => { - dispatch(fetchBookmarkedStatuses()); + fetchEntities(); }, []); const handleRefresh = () => { - return dispatch(fetchBookmarkedStatuses()); + return fetchEntities(); }; const emptyMessage = ; @@ -55,9 +42,9 @@ const Bookmarks: React.FC = ({ params }) => { className='black:p-4 black:sm:p-5' statuses={bookmarks} scrollKey='bookmarked_statuses' - hasMore={hasMore} + hasMore={hasNextPage} isLoading={typeof isLoading === 'boolean' ? isLoading : true} - onLoadMore={() => handleLoadMore(dispatch)} + onLoadMore={() => handleLoadMore()} emptyMessage={emptyMessage} divideType={(theme === 'black' || isMobile) ? 'border' : 'space'} /> From 643319439576094a544f8b2f7e8a9f5a9a2d4cd5 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 3 Dec 2024 15:08:34 -0300 Subject: [PATCH 05/24] fix: default to true, actualStatus?.pleroma?.quote_visible --- src/components/pure-status.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/pure-status.tsx b/src/components/pure-status.tsx index a01fe27d3..5a867a9f3 100644 --- a/src/components/pure-status.tsx +++ b/src/components/pure-status.tsx @@ -368,7 +368,7 @@ const PureStatus: React.FC = (props) => { let quote; if (actualStatus.quote) { - if (actualStatus?.pleroma?.quote_visible === false) { + if ((actualStatus?.pleroma?.quote_visible ?? true) === false) { quote = (

From 423cff2fa8d0ed856a27c019dbf8f0f082968ab3 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 3 Dec 2024 15:12:31 -0300 Subject: [PATCH 06/24] fix(PureStatusList): Cannot read properties of undefined (reading 'id') --- src/components/pure-status-list.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/pure-status-list.tsx b/src/components/pure-status-list.tsx index 6a8aa9064..a3075e1b5 100644 --- a/src/components/pure-status-list.tsx +++ b/src/components/pure-status-list.tsx @@ -91,11 +91,11 @@ const PureStatusList: React.FC = ({ }; const handleLoadOlder = useCallback(debounce(() => { - const maxId = lastStatusId || statuses.slice(-1)[0].id; + 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]); + }, 300, { edges: ['leading'] }), [onLoadMore, lastStatusId, statuses.slice(-1)?.[0]?.id]); const selectChild = (index: number) => { node.current?.scrollIntoView({ From bfe25c6ce108780301ef9cf3ae2495690e9dc33b Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 3 Dec 2024 18:29:27 -0300 Subject: [PATCH 07/24] refactor: create useFavourite and useReplyCompose hooks, used in PureStatus component --- src/components/pure-status.tsx | 15 +++++++++------ src/hooks/useFavourite.ts | 31 +++++++++++++++++++++++++++++++ src/hooks/useReplyCompose.ts | 17 +++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 src/hooks/useFavourite.ts create mode 100644 src/hooks/useReplyCompose.ts diff --git a/src/components/pure-status.tsx b/src/components/pure-status.tsx index 5a867a9f3..f3da09e52 100644 --- a/src/components/pure-status.tsx +++ b/src/components/pure-status.tsx @@ -6,8 +6,8 @@ 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 { mentionCompose } from 'soapbox/actions/compose.ts'; +import { 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'; @@ -21,6 +21,8 @@ import QuotedStatus from 'soapbox/features/status/containers/quoted-status-conta 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 { useReplyCompose } from 'soapbox/hooks/useReplyCompose.ts'; import { useSettings } from 'soapbox/hooks/useSettings.ts'; import { makeGetStatus } from 'soapbox/selectors/index.ts'; import { emojifyText } from 'soapbox/utils/emojify.tsx'; @@ -35,8 +37,6 @@ 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 }; @@ -104,6 +104,9 @@ const PureStatus: React.FC = (props) => { const filtered = (status.filtered.length || actualStatus.filtered.length) > 0; + const { replyCompose } = useReplyCompose(); + const { toggleFavourite } = useFavourite(); + // Track height changes we know about to compensate scrolling. useEffect(() => { didShowCard.current = Boolean(!muted && !hidden && status?.card); @@ -165,11 +168,11 @@ const PureStatus: React.FC = (props) => { const handleHotkeyReply = (e?: KeyboardEvent): void => { e?.preventDefault(); - dispatch(replyCompose(statusImmutable)); // fix later + replyCompose(status.id); }; const handleHotkeyFavourite = (): void => { - toggleFavourite(statusImmutable); // fix later + toggleFavourite(status.id); }; const handleHotkeyBoost = (e?: KeyboardEvent): void => { diff --git a/src/hooks/useFavourite.ts b/src/hooks/useFavourite.ts new file mode 100644 index 000000000..54158e9b6 --- /dev/null +++ b/src/hooks/useFavourite.ts @@ -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 }; +} diff --git a/src/hooks/useReplyCompose.ts b/src/hooks/useReplyCompose.ts new file mode 100644 index 000000000..407083d09 --- /dev/null +++ b/src/hooks/useReplyCompose.ts @@ -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 }; +} \ No newline at end of file From d0c4140b2e4df8ab85dccd03427f202e9aaf6655 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 4 Dec 2024 13:58:13 -0300 Subject: [PATCH 08/24] refactor: create useReblog hook --- src/components/pure-status.tsx | 7 ++++--- src/hooks/useReblog.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useReblog.ts diff --git a/src/components/pure-status.tsx b/src/components/pure-status.tsx index f3da09e52..4a3328022 100644 --- a/src/components/pure-status.tsx +++ b/src/components/pure-status.tsx @@ -7,7 +7,6 @@ import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; import { mentionCompose } from 'soapbox/actions/compose.ts'; -import { 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'; @@ -22,6 +21,7 @@ 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 { useReblog } from 'soapbox/hooks/useReblog.ts'; import { useReplyCompose } from 'soapbox/hooks/useReplyCompose.ts'; import { useSettings } from 'soapbox/hooks/useSettings.ts'; import { makeGetStatus } from 'soapbox/selectors/index.ts'; @@ -106,6 +106,7 @@ const PureStatus: React.FC = (props) => { const { replyCompose } = useReplyCompose(); const { toggleFavourite } = useFavourite(); + const { toggleReblog } = useReblog(); // Track height changes we know about to compensate scrolling. useEffect(() => { @@ -176,11 +177,11 @@ const PureStatus: React.FC = (props) => { }; const handleHotkeyBoost = (e?: KeyboardEvent): void => { - const modalReblog = () => dispatch(toggleReblog(statusImmutable)); // fix later + const modalReblog = () => toggleReblog(status.id); if ((e && e.shiftKey) || !boostModal) { modalReblog(); } else { - dispatch(openModal('BOOST', { status: actualStatus, onReblog: modalReblog })); + dispatch(openModal('BOOST', { status: statusImmutable, onReblog: modalReblog })); // fix later } }; diff --git a/src/hooks/useReblog.ts b/src/hooks/useReblog.ts new file mode 100644 index 000000000..d0028dbae --- /dev/null +++ b/src/hooks/useReblog.ts @@ -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 }; +} From def8d8a72b512165ac28b0563c09c6f223e7006d Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 4 Dec 2024 17:52:40 -0300 Subject: [PATCH 09/24] refactor: create useStatusHidden hook and use it in PureStatus component --- src/components/pure-status.tsx | 6 ++++-- src/hooks/useStatusHidden.ts | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useStatusHidden.ts diff --git a/src/components/pure-status.tsx b/src/components/pure-status.tsx index 4a3328022..7724d5058 100644 --- a/src/components/pure-status.tsx +++ b/src/components/pure-status.tsx @@ -8,7 +8,7 @@ import { Link, useHistory } from 'react-router-dom'; import { mentionCompose } from 'soapbox/actions/compose.ts'; import { openModal } from 'soapbox/actions/modals.ts'; -import { toggleStatusHidden, unfilterStatus } from 'soapbox/actions/statuses.ts'; +import { 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'; @@ -24,6 +24,7 @@ import { useFavourite } from 'soapbox/hooks/useFavourite.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'; @@ -107,6 +108,7 @@ const PureStatus: React.FC = (props) => { const { replyCompose } = useReplyCompose(); const { toggleFavourite } = useFavourite(); const { toggleReblog } = useReblog(); + const { toggleStatusHidden } = useStatusHidden(); // Track height changes we know about to compensate scrolling. useEffect(() => { @@ -211,7 +213,7 @@ const PureStatus: React.FC = (props) => { }; const handleHotkeyToggleHidden = (): void => { - dispatch(toggleStatusHidden(statusImmutable)); // fix later + toggleStatusHidden(status.id); }; const handleHotkeyToggleSensitive = (): void => { diff --git a/src/hooks/useStatusHidden.ts b/src/hooks/useStatusHidden.ts new file mode 100644 index 000000000..a492f450f --- /dev/null +++ b/src/hooks/useStatusHidden.ts @@ -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 }; +} From e303d19f8c5bbf6705575b7f52f0dcf5a0c24f1a Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 4 Dec 2024 18:31:53 -0300 Subject: [PATCH 10/24] refactor(StatusActionBar): calculate emojiReactCount & meEmojiReact in a pure way --- src/components/status-action-bar.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/components/status-action-bar.tsx b/src/components/status-action-bar.tsx index 5cbbae4ac..48a50410e 100644 --- a/src/components/status-action-bar.tsx +++ b/src/components/status-action-bar.tsx @@ -54,11 +54,9 @@ import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; import { useFeatures } from 'soapbox/hooks/useFeatures.ts'; import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts'; import { useSettings } from 'soapbox/hooks/useSettings.ts'; -import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts'; import { GroupRoles } from 'soapbox/schemas/group-member.ts'; import toast from 'soapbox/toast.tsx'; import copy from 'soapbox/utils/copy.ts'; -import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji-reacts.ts'; import GroupPopover from './groups/popover/group-popover.tsx'; @@ -170,9 +168,6 @@ const StatusActionBar: React.FC = ({ const { groupRelationship } = useGroupRelationship(status.group?.id); const features = useFeatures(); const { boostModal, deleteModal } = useSettings(); - const soapboxConfig = useSoapboxConfig(); - - const { allowedEmoji } = soapboxConfig; const { account } = useOwnAccount(); const isStaff = account ? account.staff : false; @@ -662,14 +657,9 @@ const StatusActionBar: React.FC = ({ const reblogCount = status.reblogs_count; const favouriteCount = status.favourites_count; - const emojiReactCount = status.reactions ? reduceEmoji( - status.reactions, - favouriteCount, - status.favourited, - allowedEmoji, - ).reduce((acc, cur) => acc + (cur.count || 0), 0) : undefined; + const emojiReactCount = status.reactions?.reduce((acc, reaction) => acc + (reaction.count ?? 0), 0) ?? 0; // allow all emojis - const meEmojiReact = getReactForStatus(status, allowedEmoji); + const meEmojiReact = status.reactions?.find((emojiReact) => emojiReact.me); // allow all emojis const meEmojiName = meEmojiReact?.name as keyof typeof reactMessages | undefined; const reactMessages = { @@ -783,7 +773,7 @@ const StatusActionBar: React.FC = ({ filled color='accent' active={Boolean(meEmojiName)} - count={emojiReactCount} + count={emojiReactCount + favouriteCount} emoji={meEmojiReact} theme={statusActionButtonTheme} /> From 5a28c42e7cec7f7b973a9f49802114641b0d48ec Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 4 Dec 2024 18:41:48 -0300 Subject: [PATCH 11/24] fix: add zaps_amount to baseStatusSchema --- src/schemas/status.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/schemas/status.ts b/src/schemas/status.ts index 9335a18fb..4ff040890 100644 --- a/src/schemas/status.ts +++ b/src/schemas/status.ts @@ -72,6 +72,7 @@ const baseStatusSchema = z.object({ url: z.string().url().catch(''), visibility: z.string().catch('public'), zapped: z.coerce.boolean(), + zaps_amount: z.number().catch(0), }); type BaseStatus = z.infer; From 19b4078af6df0a8055996722ac74065c2419fcaa Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 4 Dec 2024 20:33:35 -0300 Subject: [PATCH 12/24] refactor: create useMentionCompose hook, used in PureStatus component --- src/components/pure-status.tsx | 7 ++++--- src/hooks/useMentionCompose.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useMentionCompose.ts diff --git a/src/components/pure-status.tsx b/src/components/pure-status.tsx index 7724d5058..d7cb46e3f 100644 --- a/src/components/pure-status.tsx +++ b/src/components/pure-status.tsx @@ -6,7 +6,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; import { Link, useHistory } from 'react-router-dom'; -import { mentionCompose } from 'soapbox/actions/compose.ts'; import { openModal } from 'soapbox/actions/modals.ts'; import { unfilterStatus } from 'soapbox/actions/statuses.ts'; import TranslateButton from 'soapbox/components/translate-button.tsx'; @@ -21,6 +20,7 @@ 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'; @@ -106,6 +106,7 @@ const PureStatus: React.FC = (props) => { const filtered = (status.filtered.length || actualStatus.filtered.length) > 0; const { replyCompose } = useReplyCompose(); + const { mentionCompose } = useMentionCompose(); const { toggleFavourite } = useFavourite(); const { toggleReblog } = useReblog(); const { toggleStatusHidden } = useStatusHidden(); @@ -183,13 +184,13 @@ const PureStatus: React.FC = (props) => { if ((e && e.shiftKey) || !boostModal) { modalReblog(); } else { - dispatch(openModal('BOOST', { status: statusImmutable, onReblog: modalReblog })); // fix later + dispatch(openModal('BOOST', { status: statusImmutable, onReblog: modalReblog })); // fix later, ReplyIndicator component: status.emojis.toJS is not a function } }; const handleHotkeyMention = (e?: KeyboardEvent): void => { e?.preventDefault(); - dispatch(mentionCompose(actualStatus.account)); + mentionCompose(actualStatus.account); }; const handleHotkeyOpen = (): void => { diff --git a/src/hooks/useMentionCompose.ts b/src/hooks/useMentionCompose.ts new file mode 100644 index 000000000..1c022f8f3 --- /dev/null +++ b/src/hooks/useMentionCompose.ts @@ -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 }; +} \ No newline at end of file From 231f3ff56a6bc78a740503a1879db62919b90b57 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Wed, 4 Dec 2024 22:52:26 -0300 Subject: [PATCH 13/24] fix(Bookmarks): remove useless useEffect it was causing bugs also, not showing all bookmarked posts --- src/features/bookmarks/index.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/features/bookmarks/index.tsx b/src/features/bookmarks/index.tsx index 54e8dd631..79ab0940d 100644 --- a/src/features/bookmarks/index.tsx +++ b/src/features/bookmarks/index.tsx @@ -1,5 +1,4 @@ import { debounce } from 'es-toolkit'; -import { useEffect } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { useBookmarks } from 'soapbox/api/hooks/index.ts'; @@ -25,10 +24,6 @@ const Bookmarks: React.FC = () => { const { bookmarks, isLoading, hasNextPage, fetchEntities, fetchNextPage } = useBookmarks(); - useEffect(() => { - fetchEntities(); - }, []); - const handleRefresh = () => { return fetchEntities(); }; From 440ed28b11c723604cbfa39b74e1ff19f55f6302 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 5 Dec 2024 17:58:45 -0300 Subject: [PATCH 14/24] fix: use PureStatus instead of StatusContainer --- src/components/pure-status-list.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/pure-status-list.tsx b/src/components/pure-status-list.tsx index a3075e1b5..11a12907b 100644 --- a/src/components/pure-status-list.tsx +++ b/src/components/pure-status-list.tsx @@ -6,7 +6,6 @@ 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 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 PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status.tsx'; @@ -154,15 +153,15 @@ const PureStatusList: React.FC = ({ if (!featuredStatuses) return []; return (featuredStatuses ?? []).map(status => ( - )); }; From 575fd24ec19d1605f6b15e6e6e2107d26b4b0a11 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 5 Dec 2024 18:16:54 -0300 Subject: [PATCH 15/24] fix: status.emojis.toJS is not a function --- src/components/pure-status.tsx | 2 +- src/features/compose/components/reply-indicator.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/pure-status.tsx b/src/components/pure-status.tsx index d7cb46e3f..9aa081836 100644 --- a/src/components/pure-status.tsx +++ b/src/components/pure-status.tsx @@ -184,7 +184,7 @@ const PureStatus: React.FC = (props) => { if ((e && e.shiftKey) || !boostModal) { modalReblog(); } else { - dispatch(openModal('BOOST', { status: statusImmutable, onReblog: modalReblog })); // fix later, ReplyIndicator component: status.emojis.toJS is not a function + dispatch(openModal('BOOST', { status: status, onReblog: modalReblog })); } }; diff --git a/src/features/compose/components/reply-indicator.tsx b/src/features/compose/components/reply-indicator.tsx index 52b265ab6..46c3e6e08 100644 --- a/src/features/compose/components/reply-indicator.tsx +++ b/src/features/compose/components/reply-indicator.tsx @@ -50,7 +50,7 @@ const ReplyIndicator: React.FC = ({ className, status, hideActi className='break-words' size='sm' direction={getTextDirection(status.search_index)} - emojis={status.emojis.toJS()} + emojis={status?.emojis?.toJS() ?? status.emojis} mentions={status.mentions.toJS()} html={{ __html: status.content }} /> From 4632cb49641724579eda0cf30d2ce36307085e65 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 5 Dec 2024 22:21:14 -0300 Subject: [PATCH 16/24] refactor: create PureStatusReplyMentions component and use it in PureStatus component --- src/components/pure-status-reply-mentions.tsx | 113 ++++++++++++++++++ src/components/pure-status.tsx | 4 +- 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 src/components/pure-status-reply-mentions.tsx diff --git a/src/components/pure-status-reply-mentions.tsx b/src/components/pure-status-reply-mentions.tsx new file mode 100644 index 000000000..49a63b6df --- /dev/null +++ b/src/components/pure-status-reply-mentions.tsx @@ -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 = ({ status, hoverable = true }) => { + const dispatch = useAppDispatch(); + + const handleOpenMentionsModal: React.MouseEventHandler = (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 ( +
+ +
+ ); + } + + // The typical case with a reply-to and a list of mentions. + const accounts = to.slice(0, 2).map(account => { + const link = ( + e.stopPropagation()} + > {/* eslint-disable-line formatjs/no-literal-string-in-jsx */} + @{shortenNostr(account.username)} + + ); + + if (hoverable) { + return ( + + {link} + + ); + } else { + return link; + } + }); + + if (to.length > 2) { + accounts.push( + + + , + ); + } + + return ( +
+ , + // @ts-ignore wtf? + hover: (children: React.ReactNode) => { + if (hoverable) { + return ( + + + {children} + + + ); + } else { + return children; + } + }, + }} + /> +
+ ); +}; + +export default PureStatusReplyMentions; diff --git a/src/components/pure-status.tsx b/src/components/pure-status.tsx index 9aa081836..68e000795 100644 --- a/src/components/pure-status.tsx +++ b/src/components/pure-status.tsx @@ -8,6 +8,7 @@ import { Link, useHistory } from 'react-router-dom'; import { openModal } from 'soapbox/actions/modals.ts'; import { unfilterStatus } from 'soapbox/actions/statuses.ts'; +import PureStatusReplyMentions from 'soapbox/components/pure-status-reply-mentions.tsx'; import TranslateButton from 'soapbox/components/translate-button.tsx'; import { Card } from 'soapbox/components/ui/card.tsx'; import Icon from 'soapbox/components/ui/icon.tsx'; @@ -33,7 +34,6 @@ 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'; @@ -451,7 +451,7 @@ const PureStatus: React.FC = (props) => { />
- {/* fix later */} + Date: Thu, 5 Dec 2024 23:21:26 -0300 Subject: [PATCH 17/24] refactor: create PureSensitiveContentOverlay component, used in PureStatus component --- src/components/pure-status.tsx | 6 +- .../pure-sensitive-content-overlay.tsx | 186 ++++++++++++++++++ 2 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 src/components/statuses/pure-sensitive-content-overlay.tsx diff --git a/src/components/pure-status.tsx b/src/components/pure-status.tsx index 68e000795..9cd85ce71 100644 --- a/src/components/pure-status.tsx +++ b/src/components/pure-status.tsx @@ -9,6 +9,7 @@ import { Link, useHistory } from 'react-router-dom'; import { openModal } from 'soapbox/actions/modals.ts'; import { unfilterStatus } from 'soapbox/actions/statuses.ts'; import PureStatusReplyMentions from 'soapbox/components/pure-status-reply-mentions.tsx'; +import PureSensitiveContentOverlay from 'soapbox/components/statuses/pure-sensitive-content-overlay.tsx'; import TranslateButton from 'soapbox/components/translate-button.tsx'; import { Card } from 'soapbox/components/ui/card.tsx'; import Icon from 'soapbox/components/ui/icon.tsx'; @@ -34,7 +35,6 @@ 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 SensitiveContentOverlay from './statuses/sensitive-content-overlay.tsx'; import StatusInfo from './statuses/status-info.tsx'; import Tombstone from './tombstone.tsx'; @@ -458,8 +458,8 @@ const PureStatus: React.FC = (props) => { style={{ minHeight: isUnderReview || isSensitive ? Math.max(minHeight, 208) + 12 : undefined }} > {(isUnderReview || isSensitive) && ( - ((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(defaultMediaVisibility(status, displayMedia)); + + const toggleVisibility = (event: React.MouseEvent) => { + 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 ( +
+ {visible ? ( + + + )} + + ) : null} + + + + {(isUnderReview && isOwnStatus) ? ( + + ) : null} + +
+
+ )} +
+ ); +}); + +export default PureSensitiveContentOverlay; From b0337b6d872d73311f8acf5f1d094db0b9173c47 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 5 Dec 2024 23:31:11 -0300 Subject: [PATCH 18/24] refactor: create PureEventDate component --- .../event/components/pure-event-date.tsx | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/features/event/components/pure-event-date.tsx diff --git a/src/features/event/components/pure-event-date.tsx b/src/features/event/components/pure-event-date.tsx new file mode 100644 index 000000000..59bcef9bb --- /dev/null +++ b/src/features/event/components/pure-event-date.tsx @@ -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 = ({ 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 = ( + <> + + {' - '} + + + ); + } else { + date = ( + <> + + {' - '} + + + ); + } + } else { + date = ( + + ); + } + + return ( + + + {date} + + ); +}; + +export default PureEventDate; From 286aaa24d9a499aa065a019954256086dd8c871c Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 5 Dec 2024 23:42:23 -0300 Subject: [PATCH 19/24] refactor: create PureEventActionButton component --- .../components/pure-event-action-button.tsx | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/features/event/components/pure-event-action-button.tsx diff --git a/src/features/event/components/pure-event-action-button.tsx b/src/features/event/components/pure-event-action-button.tsx new file mode 100644 index 000000000..d2a9a0e19 --- /dev/null +++ b/src/features/event/components/pure-event-action-button.tsx @@ -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 = ({ status, theme = 'secondary' }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const me = useAppSelector((state) => state.me); + + const event = status.event!; + + const handleJoin: React.EventHandler = (e) => { + e.preventDefault(); + + if (event.join_mode === 'free') { + dispatch(joinEvent(status.id)); + } else { + dispatch(openModal('JOIN_EVENT', { + statusId: status.id, + })); + } + }; + + const handleLeave: React.EventHandler = (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 = (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 = ; + buttonIcon = checkIcon; + break; + case 'pending': + buttonLabel = ; + break; + case 'reject': + buttonLabel = ; + buttonIcon = banIcon; + buttonDisabled = true; + break; + default: + buttonLabel = ; + buttonAction = me ? handleJoin : handleOpenUnauthorizedModal; + } + + return ( + + ); +}; + +export default PureEventActionButton; From 3ce20394a6dbb75fa9e748642b1f9708cdd08e1e Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Thu, 5 Dec 2024 23:48:14 -0300 Subject: [PATCH 20/24] refactor: create PureEventPreview component, used in PureStatus component --- src/components/pure-event-preview.tsx | 97 +++++++++++++++++++++++++++ src/components/pure-status.tsx | 4 +- 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src/components/pure-event-preview.tsx diff --git a/src/components/pure-event-preview.tsx b/src/components/pure-event-preview.tsx new file mode 100644 index 000000000..1c06724ec --- /dev/null +++ b/src/components/pure-event-preview.tsx @@ -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 = ({ 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 ? ( + + ) : ( + + )); + + return ( +
+
+ {floatingAction && action} +
+
+ {banner && {intl.formatMessage(messages.eventBanner)}} +
+ + + {event.name} + + {!floatingAction && action} + + +
+ + + + {account.display_name} + {account.verified && } + + + + + + {event.location && ( + + + + {event.location?.name} + + + )} +
+
+
+ ); +}; + +export default PureEventPreview; diff --git a/src/components/pure-status.tsx b/src/components/pure-status.tsx index 9cd85ce71..496858585 100644 --- a/src/components/pure-status.tsx +++ b/src/components/pure-status.tsx @@ -8,6 +8,7 @@ import { Link, useHistory } from 'react-router-dom'; import { openModal } from 'soapbox/actions/modals.ts'; import { unfilterStatus } from 'soapbox/actions/statuses.ts'; +import PureEventPreview from 'soapbox/components/pure-event-preview.tsx'; import PureStatusReplyMentions from 'soapbox/components/pure-status-reply-mentions.tsx'; import PureSensitiveContentOverlay from 'soapbox/components/statuses/pure-sensitive-content-overlay.tsx'; import TranslateButton from 'soapbox/components/translate-button.tsx'; @@ -31,7 +32,6 @@ 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'; @@ -466,7 +466,7 @@ const PureStatus: React.FC = (props) => { /> )} - {actualStatus.event ? : ( // fix later + {actualStatus.event ? : ( Date: Fri, 6 Dec 2024 00:39:07 -0300 Subject: [PATCH 21/24] refactor: create PureStatusContent component, used in PureStatus component --- src/components/pure-status-content.tsx | 137 +++++++++++++++++++++++++ src/components/pure-status.tsx | 6 +- 2 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 src/components/pure-status-content.tsx diff --git a/src/components/pure-status-content.tsx b/src/components/pure-status-content.tsx new file mode 100644 index 000000000..a728ecd6b --- /dev/null +++ b/src/components/pure-status-content.tsx @@ -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 = ({ onClick }) => ( + +); + +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 = ({ + status, + onClick, + collapsable = false, + translatable, + textSize = 'md', +}) => { + const [collapsed, setCollapsed] = useState(false); + + const node = useRef(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 = [ + , + ]; + + if (collapsed) { + output.push(); + } + + const hasPoll = (!!status.poll) && typeof status.poll.id === 'string'; + if (hasPoll) { + output.push(); + } + + return
{output}
; + } else { + const output = [ + , + ]; + + if (status.poll && typeof status.poll === 'string') { + output.push(); + } + + return <>{output}; + } +}; + +export default memo(PureStatusContent); diff --git a/src/components/pure-status.tsx b/src/components/pure-status.tsx index 496858585..56134ea8b 100644 --- a/src/components/pure-status.tsx +++ b/src/components/pure-status.tsx @@ -9,6 +9,7 @@ import { Link, useHistory } from 'react-router-dom'; 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 PureSensitiveContentOverlay from 'soapbox/components/statuses/pure-sensitive-content-overlay.tsx'; import TranslateButton from 'soapbox/components/translate-button.tsx'; @@ -33,7 +34,6 @@ import { emojifyText } from 'soapbox/utils/emojify.tsx'; import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status.ts'; import StatusActionBar from './status-action-bar.tsx'; -import StatusContent from './status-content.tsx'; import StatusMedia from './status-media.tsx'; import StatusInfo from './statuses/status-info.tsx'; import Tombstone from './tombstone.tsx'; @@ -468,8 +468,8 @@ const PureStatus: React.FC = (props) => { {actualStatus.event ? : ( - Date: Fri, 6 Dec 2024 14:23:01 -0300 Subject: [PATCH 22/24] refactor: create PureTranslateButton component, used in PureStatus component --- src/components/pure-status.tsx | 4 +- src/components/pure-translate-button.tsx | 82 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/components/pure-translate-button.tsx diff --git a/src/components/pure-status.tsx b/src/components/pure-status.tsx index 56134ea8b..ccc9a7ec3 100644 --- a/src/components/pure-status.tsx +++ b/src/components/pure-status.tsx @@ -11,8 +11,8 @@ 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 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'; @@ -475,7 +475,7 @@ const PureStatus: React.FC = (props) => { translatable /> - {/* fix later */} + {(quote || actualStatus.card || actualStatus.media_attachments.length > 0) && ( diff --git a/src/components/pure-translate-button.tsx b/src/components/pure-translate-button.tsx new file mode 100644 index 000000000..b5632e35d --- /dev/null +++ b/src/components/pure-translate-button.tsx @@ -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 = ({ 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 = (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 ( + +