diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 72f203e086..42c1212cb5 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -5740,10 +5740,6 @@ button.module-calling-participants-list__contact { } } -.module-sticker-picker__recents--title { - color: $color-gray-05; -} - .module-sticker-picker__header__button { width: 28px; height: 28px; @@ -5960,6 +5956,55 @@ button.module-calling-participants-list__contact { background-color: $color-gray-05; } +.module-sticker-picker__pack-title { + display: flex; + flex-direction: column; + justify-content: end; + + margin-bottom: 5px; + height: 40px; + + white-space: nowrap; + + &__recents { + height: 20px; + margin-top: 0; + margin-bottom: 10px; + } + + & > h3 { + height: 20px; + + @include light-theme { + color: $color-gray-95; + } + + @include dark-theme { + color: $color-gray-05; + } + + overflow: hidden; + text-overflow: ellipsis; + + margin: 0; + padding: 0; + } + + & > i { + @include light-theme { + color: $color-gray-90; + } + + @include dark-theme { + color: $color-gray-15; + } + height: 20px; + + overflow: hidden; + text-overflow: ellipsis; + } +} + .module-sticker-picker__body { position: relative; @@ -5968,14 +6013,15 @@ button.module-calling-participants-list__contact { grid-gap: 8px; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 68px; + + margin-bottom: 20px; } &__content { width: 332px; - height: 356px; - padding-block: 8px 16px; + height: 386px; padding-inline: 13px; - overflow-y: auto; + overflow: none; &--under-text { height: 320px; @@ -6031,8 +6077,8 @@ button.module-calling-participants-list__contact { @include font-body-1-bold; text-align: center; - padding-block: 8px 12px; - padding-inline: 0 16px; + height: 30px; + padding: 0; @include light-theme() { color: $color-gray-60; @@ -6057,6 +6103,7 @@ button.module-calling-participants-list__contact { } &--hint { + height: 60px; @include light-theme() { color: $color-ultramarine; } @@ -6065,12 +6112,23 @@ button.module-calling-participants-list__contact { color: $color-ultramarine-light; } } + } +} - &--pin { - padding-block: 8px 12px; - padding-inline: 0px 16px; - position: absolute; - top: 0; +.module-sticker-picker__featured { + margin-bottom: 20px; + + &--title { + height: 20px; + margin-top: 0; + margin-bottom: 10px; + + @include light-theme { + color: $color-gray-95; + } + + @include dark-theme { + color: $color-gray-05; } } } diff --git a/ts/components/stickers/StickerPicker.tsx b/ts/components/stickers/StickerPicker.tsx index 8e3d28dec7..85e10bb02a 100644 --- a/ts/components/stickers/StickerPicker.tsx +++ b/ts/components/stickers/StickerPicker.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import classNames from 'classnames'; import FocusTrap from 'focus-trap-react'; +import { List } from 'react-virtualized'; import { useRestoreFocus } from '../../hooks/useRestoreFocus'; import type { StickerPackType, StickerType } from '../../state/ducks/stickers'; @@ -28,23 +29,22 @@ export type OwnProps = { export type Props = OwnProps & Pick, 'style'>; -function useTabs(tabs: ReadonlyArray, initialTab = tabs[0]) { - const [tab, setTab] = React.useState(initialTab); - const handlers = React.useMemo( - () => - tabs.map(t => () => { - setTab(t); - }), - [tabs] - ); - - return [tab, handlers] as [T, ReadonlyArray<() => void>]; -} - const PACKS_PAGE_SIZE = 7; const PACK_ICON_WIDTH = 32; const PACK_PAGE_WIDTH = PACKS_PAGE_SIZE * PACK_ICON_WIDTH; +const STICKER_HEIGHT = 68; +// 20 in height + 5 in margin-top + 5 in margin-bottom +const RECENTS_HEADER_HEIGHT = 20 + 5 + 5; +const SHOW_TEXT_HEIGHT = 30; +const SHOW_LONG_TEXT_HEIGHT = 60; +// 40 in height + 5 in margin-bottom +const PACK_NAME_HEIGHT = 40 + 5; +const PACK_MARGIN_BOTTOM = 20; +// 20px in margin-bottom +const FEATURED_STICKERS_HEIGHT = + RECENTS_HEADER_HEIGHT + STICKER_HEIGHT + PACK_MARGIN_BOTTOM; + function getPacksPageOffset(page: number, packs: number): number { if (page === 0) { return 0; @@ -80,22 +80,12 @@ export const StickerPicker = React.memo( }: Props, ref ) => { - const tabIds = React.useMemo( - () => ['recents', ...packs.map(({ id }) => id)], - [packs] - ); - const [currentTab, [recentsHandler, ...packsHandlers]] = useTabs( - tabIds, - // If there are no recent stickers, - // default to the first sticker pack, - // unless there are no sticker packs. - tabIds[recentStickers.length > 0 ? 0 : Math.min(1, tabIds.length)] - ); - const selectedPack = packs.find(({ id }) => id === currentTab); - const { - stickers = recentStickers, - title: packTitle = 'Recent Stickers', - } = selectedPack || {}; + // The index of the pack we're browsing (whether by + // scrolling to it, or clicking on it) on the virtualized list. + // 0 is the index for the recent/feature stickers element. + const [selectedPackIndex, setSelectedPackIndex] = React.useState(0); + + const isRTL = i18n.getLocaleDirection() === 'rtl'; const [isUsingKeyboard, setIsUsingKeyboard] = React.useState(false); const [packsPage, setPacksPage] = React.useState(0); @@ -106,6 +96,90 @@ export const StickerPicker = React.memo( setPacksPage(i => i + 1); }, [setPacksPage]); + const packDownloadInfo = React.useCallback((pack: StickerPackType) => { + const pendingCount = + pack && pack.status === 'pending' + ? pack.stickerCount - pack.stickers.length + : 0; + + const hasDownloadError = + pack && + pack.status === 'error' && + pack.stickerCount !== pack.stickers.length; + + const showPendingText = pendingCount > 0; + const showEmptyText = + pack && !hasDownloadError && pack.stickerCount === 0; + const showText = showPendingText || hasDownloadError || showEmptyText; + + return [ + showText, + showPendingText, + pendingCount, + showEmptyText, + hasDownloadError, + ]; + }, []); + + const hasPacks = packs.length > 0; + const hasRecents = recentStickers.length > 0; + const isRecentsSelected = hasPacks && selectedPackIndex === 0; + + const hasTimeStickers = isRecentsSelected && onPickTimeSticker; + const isEmpty = !hasPacks && !hasTimeStickers && !hasRecents; + + const rowHeight = React.useCallback( + ({ index }: { index: number }) => { + const isRecents = index === 0; + + if (isRecents && !hasRecents && !hasTimeStickers) { + return 0; + } + + const isLastPack = !isRecents && index === packs.length; + const stickerCount = isRecents + ? recentStickers.length + : packs[index - 1].stickers.length; + + const [showText] = packDownloadInfo(packs[index - 1]); + + const rows = Math.ceil(stickerCount / 4); + + // Extra height can be needed to render pending downloads or errors + const showTextHeight = !isRecents && showText ? SHOW_TEXT_HEIGHT : 0; + + const showHintHeight = + isRecents && showPickerHint ? SHOW_LONG_TEXT_HEIGHT : 0; + + const recentStickersHeaderHeight = + (hasRecents ? RECENTS_HEADER_HEIGHT : 0) + + (hasTimeStickers ? FEATURED_STICKERS_HEIGHT : 0) + + showHintHeight; + + const packHeaderHeight = isRecents + ? recentStickersHeaderHeight + : PACK_NAME_HEIGHT; + + const packGridHeight = STICKER_HEIGHT * rows; + const packBottomMargin = + !isLastPack || (isRecents && hasRecents) ? PACK_MARGIN_BOTTOM : 0; + // We have to consider 8px padding in between each row + const packGridPadding = 8 * (rows - 1) + packBottomMargin; + + return ( + packHeaderHeight + showTextHeight + packGridHeight + packGridPadding + ); + }, + [ + recentStickers, + packs, + showPickerHint, + hasRecents, + hasTimeStickers, + packDownloadInfo, + ] + ); + // Handle escape key React.useEffect(() => { const handler = (event: KeyboardEvent) => { @@ -135,27 +209,259 @@ export const StickerPicker = React.memo( // Focus popup on after initial render, restore focus on teardown const [focusRef] = useRestoreFocus(); - const hasPacks = packs.length > 0; - const isRecents = hasPacks && currentTab === 'recents'; - const hasTimeStickers = isRecents && onPickTimeSticker; - const isEmpty = stickers.length === 0 && !hasTimeStickers; const addPackRef = isEmpty ? focusRef : undefined; - const downloadError = - selectedPack && - selectedPack.status === 'error' && - selectedPack.stickerCount !== selectedPack.stickers.length; - const pendingCount = - selectedPack && selectedPack.status === 'pending' - ? selectedPack.stickerCount - stickers.length - : 0; - - const showPendingText = pendingCount > 0; - const showDownloadErrorText = downloadError; - const showEmptyText = !downloadError && isEmpty; - const showText = - showPendingText || showDownloadErrorText || showEmptyText; - const showLongText = showPickerHint; - const analogTime = getAnalogTime(); + + const listRef = React.createRef(); + + // Computing the entire list height when rendering + // (or after new packs are added) is important to get + // an accurate scroll offset and scrollbar length + React.useEffect(() => { + if (listRef.current) { + listRef.current.measureAllRows(); + } + // We don't want to have listRef as a dependency because + // it updates too much and `measureAllRows()` is an expensive + // operation. The only thing that can affect row height is newly + // downloaded packs, so `packs.length` is enough + // + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [packs.length]); + + const renderStickerGrid = React.useCallback( + (packTitle: string, stickerList: ReadonlyArray) => + stickerList.map(sticker => ( + + )), + [onPickSticker] + ); + + const rowRenderer = React.useCallback( + ({ + index, + key: listKey, + style: listStyle, + }: { + index: number; + key: string; + style: React.CSSProperties; + }) => { + const analogTime = getAnalogTime(); + + // Recents and/or featured stickers are at special index 0. + if (index === 0) { + const featuredStickerElement = hasTimeStickers && ( +
+

+ {i18n('icu:stickers__StickerPicker__featured')} +

+
+ + + +
+
+ ); + + const recentsPackTitle = i18n( + 'icu:stickers__StickerPicker__recent' + ); + const recentStickersElement = hasRecents && ( + <> +
+

{recentsPackTitle}

+
+
+ {renderStickerGrid(recentsPackTitle, recentStickers)} +
+ + ); + + return ( +
+ {showPickerHint ? ( +
+ {i18n('icu:stickers--StickerPicker--Hint')} +
+ ) : null} + {featuredStickerElement} + {recentStickersElement} +
+ ); + } + + const selectedPack = packs[index - 1]; + const [ + , + pendingCount, + showPendingText, + showEmptyText, + hasDownloadError, + ] = packDownloadInfo(selectedPack); + + return ( +
+
+

{selectedPack.title}

+ {selectedPack.author} +
+ {showPendingText ? ( +
+ {i18n('icu:stickers--StickerPicker--DownloadPending')} +
+ ) : null} + {hasDownloadError && selectedPack.stickers.length > 0 ? ( +
+ {i18n('icu:stickers--StickerPicker--DownloadError')} +
+ ) : null} + {hasPacks && showEmptyText ? ( +
+ {i18n('icu:stickers--StickerPicker--Empty')} +
+ ) : null} +
+ {renderStickerGrid(selectedPack.title, selectedPack.stickers)} + {showPendingText + ? Array(pendingCount) + .fill(0) + .map((_, i) => ( +
+ )) + : null} +
+
+ ); + }, + [ + recentStickers, + packs, + showPickerHint, + hasPacks, + hasRecents, + hasTimeStickers, + isRecentsSelected, + i18n, + onPickTimeSticker, + packDownloadInfo, + renderStickerGrid, + ] + ); + + const onRowsRendered = React.useCallback( + ({ startIndex }: { startIndex: number }) => { + if (startIndex === 0) { + setPacksPage(0); + } else if (startIndex > 0 && selectedPackIndex > 0) { + // If we're on a new page, we're on a new multiple of PACKS_PAGE_SIZE + // (because there are PACKS_PAGE_SIZE stickers per page) + // + // The indexes have an offset of 1 because of recent stickers at index 0. + const newPage = Math.floor((startIndex - 1) / PACKS_PAGE_SIZE); + const oldPage = Math.floor( + (selectedPackIndex - 1) / PACKS_PAGE_SIZE + ); + const pageDiff = newPage - oldPage; + + if ( + packsPage !== newPage || + (pageDiff > 0 && !isLastPacksPage(packsPage, packs.length)) || + (pageDiff < 0 && packsPage > 0) + ) { + setPacksPage(newPage); + } + } + + setSelectedPackIndex(startIndex); + }, + [ + packsPage, + setPacksPage, + selectedPackIndex, + setSelectedPackIndex, + packs.length, + ] + ); + + const noRowsRenderer = React.useCallback(() => { + return ( +
+ {i18n('icu:stickers--StickerPicker--NoPacks')} +
+ ); + }, [i18n]); return ( - {hasPacks ? ( - - ))} + + ); + })}
{!isUsingKeyboard && packsPage > 0 ? ( - - - - {stickers.length > 0 && ( - - {i18n('icu:stickers__StickerPicker__recent')} - - )} - - )} -
- {stickers.map(({ packId, id, url }, index: number) => { - const maybeFocusRef = index === 0 ? focusRef : undefined; - - return ( - - ); - })} - {Array(pendingCount) - .fill(0) - .map((_, i) => ( -
- ))} -
-
- ) : null} +
+ +