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/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-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-list.tsx b/src/components/pure-status-list.tsx new file mode 100644 index 000000000..11a12907b --- /dev/null +++ b/src/components/pure-status-list.tsx @@ -0,0 +1,255 @@ +import clsx from 'clsx'; +import { debounce } from 'es-toolkit'; +import { useRef, useCallback } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import LoadGap from 'soapbox/components/load-gap.tsx'; +import PureStatus from 'soapbox/components/pure-status.tsx'; +import ScrollableList from 'soapbox/components/scrollable-list.tsx'; +import { EntityTypes, Entities } from 'soapbox/entity-store/entities.ts'; +import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions.tsx'; +import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status.tsx'; +import PendingStatus from 'soapbox/features/ui/components/pending-status.tsx'; +import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts'; + +import type { VirtuosoHandle } from 'react-virtuoso'; +import type { IScrollableList } from 'soapbox/components/scrollable-list.tsx'; + +interface IPureStatusList extends Omit{ + /** Unique key to preserve the scroll position when navigating back. */ + scrollKey: string; + /** List of statuses to display. */ + statuses: readonly EntityTypes[Entities.STATUSES][]; + /** Last _unfiltered_ status ID (maxId) for pagination. */ + lastStatusId?: string; + /** Pinned statuses to show at the top of the feed. */ + featuredStatuses?: readonly EntityTypes[Entities.STATUSES][]; + /** Pagination callback when the end of the list is reached. */ + onLoadMore?: (lastStatusId: string) => void; + /** Whether the data is currently being fetched. */ + isLoading: boolean; + /** Whether the server did not return a complete page. */ + isPartial?: boolean; + /** Whether we expect an additional page of data. */ + hasMore: boolean; + /** Message to display when the list is loaded but empty. */ + emptyMessage: React.ReactNode; + /** ID of the timeline in Redux. */ + timelineId?: string; + /** Whether to display a gap or border between statuses in the list. */ + divideType?: 'space' | 'border'; + /** Whether to display ads. */ + showAds?: boolean; + /** Whether to show group information. */ + showGroup?: boolean; +} + +/** + * Feed of statuses, built atop ScrollableList. + */ +const PureStatusList: React.FC = ({ + statuses, + lastStatusId, + featuredStatuses, + divideType = 'border', + onLoadMore, + timelineId, + isLoading, + isPartial, + showAds = false, + showGroup = true, + className, + ...other +}) => { + const soapboxConfig = useSoapboxConfig(); + const node = useRef(null); + + const getFeaturedStatusCount = () => { + return featuredStatuses?.length || 0; + }; + + const getCurrentStatusIndex = (id: string, featured: boolean): number => { + if (featured) { + return (featuredStatuses ?? []).findIndex(key => key.id === id) || 0; + } else { + return ( + (statuses?.map(status => status.id) ?? []).findIndex(key => key === id) + + getFeaturedStatusCount() + ); + } + }; + + const handleMoveUp = (id: string, featured: boolean = false) => { + const elementIndex = getCurrentStatusIndex(id, featured) - 1; + selectChild(elementIndex); + }; + + const handleMoveDown = (id: string, featured: boolean = false) => { + const elementIndex = getCurrentStatusIndex(id, featured) + 1; + selectChild(elementIndex); + }; + + const handleLoadOlder = useCallback(debounce(() => { + const maxId = lastStatusId || statuses.slice(-1)?.[0]?.id; + if (onLoadMore && maxId) { + onLoadMore(maxId.replace('末suggestions-', '')); + } + }, 300, { edges: ['leading'] }), [onLoadMore, lastStatusId, statuses.slice(-1)?.[0]?.id]); + + const selectChild = (index: number) => { + node.current?.scrollIntoView({ + index, + behavior: 'smooth', + done: () => { + const element = document.querySelector(`#status-list [data-index="${index}"] .focusable`); + element?.focus(); + }, + }); + }; + + const renderLoadGap = (index: number) => { + const ids = statuses?.map(status => status.id) ?? []; + const nextId = ids[index + 1]; + const prevId = ids[index - 1]; + + if (index < 1 || !nextId || !prevId || !onLoadMore) return null; + + return ( + + ); + }; + + const renderStatus = (status: EntityTypes[Entities.STATUSES]) => { + return ( + + ); + }; + + const renderPendingStatus = (statusId: string) => { + const idempotencyKey = statusId.replace(/^末pending-/, ''); + + return ( + + ); + }; + + const renderFeaturedStatuses = (): React.ReactNode[] => { + if (!featuredStatuses) return []; + + return (featuredStatuses ?? []).map(status => ( + + )); + }; + + 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; + } + }; + + 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} + > + {renderScrollableContent()} + + ); +}; + +export default PureStatusList; \ No newline at end of file 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 new file mode 100644 index 000000000..4bb32d190 --- /dev/null +++ b/src/components/pure-status.tsx @@ -0,0 +1,515 @@ +import circlesIcon from '@tabler/icons/outline/circles.svg'; +import pinnedIcon from '@tabler/icons/outline/pinned.svg'; +import repeatIcon from '@tabler/icons/outline/repeat.svg'; +import clsx from 'clsx'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; +import { Link, useHistory } from 'react-router-dom'; + +import { importFetchedStatuses } from 'soapbox/actions/importer/index.ts'; +import { openModal } from 'soapbox/actions/modals.ts'; +import { unfilterStatus } from 'soapbox/actions/statuses.ts'; +import PureEventPreview from 'soapbox/components/pure-event-preview.tsx'; +import PureStatusContent from 'soapbox/components/pure-status-content.tsx'; +import PureStatusReplyMentions from 'soapbox/components/pure-status-reply-mentions.tsx'; +import PureTranslateButton from 'soapbox/components/pure-translate-button.tsx'; +import PureSensitiveContentOverlay from 'soapbox/components/statuses/pure-sensitive-content-overlay.tsx'; +import { Card } from 'soapbox/components/ui/card.tsx'; +import Icon from 'soapbox/components/ui/icon.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Text from 'soapbox/components/ui/text.tsx'; +import AccountContainer from 'soapbox/containers/account-container.tsx'; +import { EntityTypes, Entities } from 'soapbox/entity-store/entities.ts'; +import QuotedStatus from 'soapbox/features/status/containers/quoted-status-container.tsx'; +import { HotKeys } from 'soapbox/features/ui/components/hotkeys.tsx'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; +import { useFavourite } from 'soapbox/hooks/useFavourite.ts'; +import { useMentionCompose } from 'soapbox/hooks/useMentionCompose.ts'; +import { useReblog } from 'soapbox/hooks/useReblog.ts'; +import { useReplyCompose } from 'soapbox/hooks/useReplyCompose.ts'; +import { useSettings } from 'soapbox/hooks/useSettings.ts'; +import { useStatusHidden } from 'soapbox/hooks/useStatusHidden.ts'; +import { makeGetStatus } from 'soapbox/selectors/index.ts'; +import { emojifyText } from 'soapbox/utils/emojify.tsx'; +import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status.ts'; + +import StatusActionBar from './status-action-bar.tsx'; +import StatusMedia from './status-media.tsx'; +import StatusInfo from './statuses/status-info.tsx'; +import Tombstone from './tombstone.tsx'; + +// Defined in components/scrollable-list +export type ScrollPosition = { height: number; top: number }; + +const messages = defineMessages({ + reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' }, +}); + +export interface IPureStatus { + id?: string; + avatarSize?: number; + status: EntityTypes[Entities.STATUSES]; + onClick?: () => void; + muted?: boolean; + hidden?: boolean; + unread?: boolean; + onMoveUp?: (statusId: string, featured?: boolean) => void; + onMoveDown?: (statusId: string, featured?: boolean) => void; + focusable?: boolean; + featured?: boolean; + hideActionBar?: boolean; + hoverable?: boolean; + variant?: 'default' | 'rounded' | 'slim'; + showGroup?: boolean; + accountAction?: React.ReactElement; +} + +/** + * Status accepting the full status entity in pure format. + */ +const PureStatus: React.FC = (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; + + const { replyCompose } = useReplyCompose(); + const { mentionCompose } = useMentionCompose(); + const { toggleFavourite } = useFavourite(); + const { toggleReblog } = useReblog(); + const { toggleStatusHidden } = useStatusHidden(); + + // Track height changes we know about to compensate scrolling. + useEffect(() => { + didShowCard.current = Boolean(!muted && !hidden && status?.card); + }, []); + + useEffect(() => { + setShowMedia(defaultMediaVisibility(status, displayMedia)); + }, [status.id]); + + useEffect(() => { + if (overlay.current) { + setMinHeight(overlay.current.getBoundingClientRect().height); + } + }, [overlay.current]); + + // START: this is a temporary code, it will be removed + useEffect(() => { + dispatch(importFetchedStatuses([status])); + }, []); + const getStatus = useCallback(makeGetStatus(), []); + const statusImmutable = useAppSelector(state => getStatus(state, { id: status.id })); + if (!statusImmutable) { + return null; + } + // END: this is a temporary code, it will be removed + + const handleToggleMediaVisibility = (): void => { + setShowMedia(!showMedia); + }; + + const handleClick = (e?: React.MouseEvent): void => { + e?.stopPropagation(); + + // If the user is selecting text, don't focus the status. + if (getSelection()?.toString().length) { + return; + } + + if (!e || !(e.ctrlKey || e.metaKey)) { + if (onClick) { + onClick(); + } else { + history.push(statusUrl); + } + } else { + window.open(statusUrl, '_blank'); + } + }; + + const handleHotkeyOpenMedia = (e?: KeyboardEvent): void => { + const status = actualStatus; + const firstAttachment = status.media_attachments[0]; + + e?.preventDefault(); + + if (firstAttachment) { + if (firstAttachment.type === 'video') { + dispatch(openModal('VIDEO', { status, media: firstAttachment, time: 0 })); + } else { + dispatch(openModal('MEDIA', { status, media: status.media_attachments, index: 0 })); + } + } + }; + + const handleHotkeyReply = (e?: KeyboardEvent): void => { + e?.preventDefault(); + replyCompose(status.id); + }; + + const handleHotkeyFavourite = (): void => { + toggleFavourite(status.id); + }; + + const handleHotkeyBoost = (e?: KeyboardEvent): void => { + const modalReblog = () => toggleReblog(status.id); + if ((e && e.shiftKey) || !boostModal) { + modalReblog(); + } else { + dispatch(openModal('BOOST', { status: status, onReblog: modalReblog })); + } + }; + + const handleHotkeyMention = (e?: KeyboardEvent): void => { + e?.preventDefault(); + mentionCompose(actualStatus.account); + }; + + const handleHotkeyOpen = (): void => { + history.push(statusUrl); + }; + + const handleHotkeyOpenProfile = (): void => { + history.push(`/@${actualStatus.account.acct}`); + }; + + const handleHotkeyMoveUp = (e?: KeyboardEvent): void => { + if (onMoveUp) { + onMoveUp(status.id, featured); + } + }; + + const handleHotkeyMoveDown = (e?: KeyboardEvent): void => { + if (onMoveDown) { + onMoveDown(status.id, featured); + } + }; + + const handleHotkeyToggleHidden = (): void => { + toggleStatusHidden(status.id); + }; + + const handleHotkeyToggleSensitive = (): void => { + handleToggleMediaVisibility(); + }; + + const handleHotkeyReact = (): void => { + _expandEmojiSelector(); + }; + + const handleUnfilter = () => dispatch(unfilterStatus(status.filtered.length ? status.id : actualStatus.id)); + + const _expandEmojiSelector = (): void => { + const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + firstEmoji?.focus(); + }; + + const renderStatusInfo = () => { + if (isReblog && showGroup && group) { + return ( + } + 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 ?? true) === 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()} + + + +
+ + + + {(isUnderReview || isSensitive) && ( + + )} + + {actualStatus.event ? : ( + + + + + + {(quote || actualStatus.card || actualStatus.media_attachments.length > 0) && ( + + + + {quote} + + )} + + )} + + + {(!hideActionBar && !isUnderReview) && ( +
+ {/* fix later */} +
+ )} +
+
+
+
+ ); +}; + +export default PureStatus; \ No newline at end of file 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 ( + + + + )} + + ) : null} + + + + {(isUnderReview && isOwnStatus) ? ( + + ) : null} + + + + )} + + ); +}); + +export default PureSensitiveContentOverlay; diff --git a/src/features/bookmarks/index.tsx b/src/features/bookmarks/index.tsx index 444327f9b..79ab0940d 100644 --- a/src/features/bookmarks/index.tsx +++ b/src/features/bookmarks/index.tsx @@ -1,14 +1,10 @@ import { debounce } from 'es-toolkit'; -import { OrderedSet as ImmutableOrderedSet } from 'immutable'; -import { useEffect } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks.ts'; +import { useBookmarks } from 'soapbox/api/hooks/index.ts'; import PullToRefresh from 'soapbox/components/pull-to-refresh.tsx'; -import StatusList from 'soapbox/components/status-list.tsx'; +import PureStatusList from 'soapbox/components/pure-status-list.tsx'; import { Column } from 'soapbox/components/ui/column.tsx'; -import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; -import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; import { useIsMobile } from 'soapbox/hooks/useIsMobile.ts'; import { useTheme } from 'soapbox/hooks/useTheme.ts'; @@ -16,35 +12,20 @@ const messages = defineMessages({ heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, }); -const handleLoadMore = debounce((dispatch) => { - dispatch(expandBookmarkedStatuses()); -}, 300, { edges: ['leading'] }); - -interface IBookmarks { - params?: { - id?: string; - }; -} - -const Bookmarks: React.FC = ({ params }) => { +const Bookmarks: React.FC = () => { const intl = useIntl(); - const dispatch = useAppDispatch(); const theme = useTheme(); const isMobile = useIsMobile(); - const bookmarks = 'bookmarks'; + const handleLoadMore = debounce(() => { + fetchNextPage(); + }, 300, { edges: ['leading'] }); - const statusIds = useAppSelector((state) => state.status_lists.get(bookmarks)?.items || ImmutableOrderedSet()); - const isLoading = useAppSelector((state) => state.status_lists.get(bookmarks)?.isLoading === true); - const hasMore = useAppSelector((state) => !!state.status_lists.get(bookmarks)?.next); - - useEffect(() => { - dispatch(fetchBookmarkedStatuses()); - }, []); + const { bookmarks, isLoading, hasNextPage, fetchEntities, fetchNextPage } = useBookmarks(); const handleRefresh = () => { - return dispatch(fetchBookmarkedStatuses()); + return fetchEntities(); }; const emptyMessage = ; @@ -52,13 +33,13 @@ const Bookmarks: React.FC = ({ params }) => { return ( - handleLoadMore(dispatch)} + onLoadMore={() => handleLoadMore()} emptyMessage={emptyMessage} divideType={(theme === 'black' || isMobile) ? 'border' : 'space'} /> diff --git a/src/features/compose/components/reply-indicator.tsx b/src/features/compose/components/reply-indicator.tsx index 52b265ab6..5570ec7cc 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} // Use toJS() if status.emojis is immutable; otherwise, fallback to plain status.emojis mentions={status.mentions.toJS()} html={{ __html: status.content }} /> 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; 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; 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/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 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 }; +} 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 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 }; +} 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;