TelegramPWA/src/components/story/StoryViewModal.tsx
2024-03-01 14:02:45 -05:00

294 lines
9.5 KiB
TypeScript

import type { FC } from '../../lib/teact/teact';
import React, {
memo, useEffect, useMemo, useState,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiStory, ApiTypeStoryView } from '../../api/types';
import {
STORY_MIN_REACTIONS_SORT,
STORY_VIEWS_MIN_CONTACTS_FILTER,
STORY_VIEWS_MIN_SEARCH,
} from '../../config';
import {
selectIsCurrentUserPremium,
selectPeerStory,
selectTabState,
} from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { getServerTime } from '../../util/serverTime';
import renderText from '../common/helpers/renderText';
import useDebouncedCallback from '../../hooks/useDebouncedCallback';
import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useScrolledState from '../../hooks/useScrolledState';
import Button from '../ui/Button';
import DropdownMenu from '../ui/DropdownMenu';
import InfiniteScroll from '../ui/InfiniteScroll';
import ListItem from '../ui/ListItem';
import MenuItem from '../ui/MenuItem';
import Modal from '../ui/Modal';
import PlaceholderChatInfo from '../ui/placeholder/PlaceholderChatInfo';
import SearchInput from '../ui/SearchInput';
import StoryView from './StoryView';
import styles from './StoryViewModal.module.scss';
interface StateProps {
story?: ApiStory;
isLoading?: boolean;
views?: ApiTypeStoryView[];
nextOffset?: string;
viewersExpirePeriod: number;
isCurrentUserPremium?: boolean;
}
const REFETCH_DEBOUNCE = 250;
function StoryViewModal({
story,
viewersExpirePeriod,
views,
nextOffset,
isLoading,
isCurrentUserPremium,
}: StateProps) {
const {
loadStoryViewList, closeStoryViewModal, clearStoryViews,
} = getActions();
const [areJustContacts, markJustContacts, unmarkJustContacts] = useFlag(false);
const [areReactionsFirst, markReactionsFirst, unmarkReactionsFirst] = useFlag(true);
const [query, setQuery] = useState('');
const lang = useLang();
const isOpen = Boolean(story);
const isExpired = Boolean(story?.date) && (story!.date + viewersExpirePeriod) < getServerTime();
const { viewsCount = 0, reactionsCount = 0 } = story?.views || {};
const shouldShowJustContacts = story?.isPublic && viewsCount > STORY_VIEWS_MIN_CONTACTS_FILTER;
const shouldShowSortByReactions = reactionsCount > STORY_MIN_REACTIONS_SORT;
const shouldShowSearch = viewsCount > STORY_VIEWS_MIN_SEARCH;
const hasHeader = shouldShowJustContacts || shouldShowSortByReactions || shouldShowSearch;
useEffect(() => {
if (!isOpen) {
setQuery('');
unmarkJustContacts();
markReactionsFirst();
}
}, [isOpen]);
const refetchViews = useDebouncedCallback(() => {
clearStoryViews({ isLoading: true });
}, [], REFETCH_DEBOUNCE, true);
useEffect(() => {
refetchViews();
}, [areJustContacts, areReactionsFirst, query, refetchViews]);
const sortedViews = useMemo(() => {
return views?.sort(prepareComparator(areReactionsFirst));
}, [areReactionsFirst, views]);
const placeholderCount = !sortedViews?.length ? Math.min(viewsCount, 8) : 1;
const notAllAvailable = Boolean(sortedViews?.length) && sortedViews!.length < viewsCount && isExpired;
const handleLoadMore = useLastCallback(() => {
if (!story?.id || nextOffset === undefined) return;
loadStoryViewList({
peerId: story.peerId,
storyId: story.id,
offset: nextOffset,
areReactionsFirst: areReactionsFirst || undefined,
areJustContacts: areJustContacts || undefined,
query,
});
});
const { handleScroll, isAtBeginning } = useScrolledState();
const handleClose = useLastCallback(() => {
closeStoryViewModal();
});
const MoreMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
return ({ onTrigger, isOpen: isMenuOpen }) => (
<Button
fluid
size="tiny"
color="translucent"
className={buildClassName(!isMenuOpen && 'active', styles.sortButton, styles.topButton)}
faded={isMenuOpen}
onClick={onTrigger}
ariaLabel={lang('SortBy')}
>
<i className={buildClassName(
'icon',
areReactionsFirst ? 'icon-heart-outline' : 'icon-recent',
styles.iconSort,
)}
/>
<i className={buildClassName('icon icon-down', styles.iconDown)} />
</Button>
);
}, [areReactionsFirst, lang]);
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
className="component-theme-dark"
contentClassName={styles.viewsList}
isSlim
>
{hasHeader && (
<div className={styles.header}>
{shouldShowJustContacts && (
<div className={styles.contactFilter}>
<Button
className={buildClassName(!areJustContacts && styles.selected, styles.topButton)}
size="tiny"
color="translucent-white"
fluid
onClick={unmarkJustContacts}
>
{lang('AllViewers')}
</Button>
<Button
className={buildClassName(areJustContacts && styles.selected, styles.topButton)}
size="tiny"
color="translucent-white"
fluid
onClick={markJustContacts}
>
{lang('Contacts')}
</Button>
</div>
)}
{shouldShowSortByReactions && (
<DropdownMenu
className={styles.sort}
trigger={MoreMenuButton}
positionX="right"
>
<MenuItem icon="heart-outline" onClick={markReactionsFirst}>
{lang('SortByReactions')}
{areReactionsFirst && (
<i className={buildClassName('icon icon-check', styles.check)} aria-hidden />
)}
</MenuItem>
<MenuItem icon="recent" onClick={unmarkReactionsFirst}>
{lang('SortByTime')}
{!areReactionsFirst && (
<i className={buildClassName('icon icon-check', styles.check)} aria-hidden />
)}
</MenuItem>
</DropdownMenu>
)}
{shouldShowSearch && (
<SearchInput className={styles.search} value={query} onChange={setQuery} />
)}
</div>
)}
<div
className={buildClassName(styles.content, !isAtBeginning && styles.topScrolled, 'custom-scroll')}
onScroll={handleScroll}
>
{isExpired && !isLoading && !query && Boolean(!sortedViews?.length) && (
<div className={buildClassName(styles.info, styles.centeredInfo)}>
{renderText(
lang(isCurrentUserPremium ? 'ServerErrorViewers' : 'ExpiredViewsStub'),
['simple_markdown', 'emoji'],
)}
</div>
)}
{!isLoading && Boolean(query.length) && !sortedViews?.length && (
<div className={styles.info}>
{lang('Story.ViewList.EmptyTextSearch')}
</div>
)}
<InfiniteScroll
items={sortedViews}
onLoadMore={handleLoadMore}
>
{sortedViews?.map((view) => {
const additionalKeyId = view.type === 'forward' ? view.messageId
: view.type === 'repost' ? view.storyId : 'user';
return (
<StoryView key={`${view.peerId}-${view.date}-${additionalKeyId}`} storyView={view} />
);
})}
{isLoading && Array.from({ length: placeholderCount }).map((_, i) => (
<ListItem
// eslint-disable-next-line react/no-array-index-key
key={`placeholder-${i}`}
className="chat-item-clickable contact-list-item scroll-item small-icon"
disabled
>
<PlaceholderChatInfo />
</ListItem>
))}
{notAllAvailable && (
<div key="not-all-available" className={buildClassName(styles.info, styles.bottomInfo)}>
{lang('Story.ViewList.NotFullyRecorded')}
</div>
)}
</InfiniteScroll>
</div>
<div className={buildClassName(styles.footer, 'dialog-buttons')}>
<Button
className={buildClassName('confirm-dialog-button', styles.close)}
isText
onClick={handleClose}
>
{lang('Close')}
</Button>
</div>
</Modal>
);
}
function prepareComparator(areReactionsFirst?: boolean) {
return (a: ApiTypeStoryView, b: ApiTypeStoryView) => {
if (areReactionsFirst) {
const reactionA = a.type === 'user' && a.reaction;
const reactionB = b.type === 'user' && b.reaction;
if (reactionA && !reactionB) {
return -1;
}
if (!reactionA && reactionB) {
return 1;
}
}
return b.date - a.date;
};
}
export default memo(withGlobal((global) => {
const { appConfig } = global;
const { storyViewer: { viewModal } } = selectTabState(global);
const {
storyId, views, nextOffset, isLoading,
} = viewModal || {};
const story = storyId ? selectPeerStory(global, global.currentUserId!, storyId) : undefined;
return {
storyId,
views,
viewersExpirePeriod: appConfig!.storyExpirePeriod + appConfig!.storyViewersExpirePeriod,
story: story && 'content' in story ? story : undefined,
nextOffset,
isLoading,
availableReactions: global.reactions.availableReactions,
isCurrentUserPremium: selectIsCurrentUserPremium(global),
};
})(StoryViewModal));