diff --git a/packages/common/src/adapters/audioTransactions.test.ts b/packages/common/src/adapters/audioTransactions.test.ts new file mode 100644 index 00000000000..a0a86afcc72 --- /dev/null +++ b/packages/common/src/adapters/audioTransactions.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { + TransactionMethod, + TransactionType +} from '~/store/ui/transaction-details/types' + +import { audioTransactionFromSdk } from './audioTransactions' + +const makeSdkTransaction = (overrides: Record = {}) => + ({ + signature: 'signature', + transactionType: 'transfer', + method: 'receive', + transactionDate: '2026-01-01T00:00:00.000Z', + change: '100000000', + balance: '500000000', + metadata: 'metadata', + ...overrides + }) as any + +describe('audioTransactionFromSdk', () => { + it('maps tip transactions', () => { + const tipTx = audioTransactionFromSdk( + makeSdkTransaction({ + transactionType: 'tip', + method: 'send' + }) + ) + + expect(tipTx.transactionType).toBe(TransactionType.TIP) + expect(tipTx.method).toBe(TransactionMethod.SEND) + }) + + it('throws on unknown transaction type', () => { + expect(() => + audioTransactionFromSdk( + makeSdkTransaction({ + transactionType: 'unknown_type' + }) + ) + ).toThrow('Unknown Transaction') + }) +}) diff --git a/packages/common/src/adapters/audioTransactions.ts b/packages/common/src/adapters/audioTransactions.ts index d6fa1e78455..2723b7899f2 100644 --- a/packages/common/src/adapters/audioTransactions.ts +++ b/packages/common/src/adapters/audioTransactions.ts @@ -16,7 +16,8 @@ export const audioTransactionFromSdk = ( 'purchase unknown': TransactionType.PURCHASE, user_reward: TransactionType.CHALLENGE_REWARD, trending_reward: TransactionType.TRENDING_REWARD, - transfer: TransactionType.TRANSFER + transfer: TransactionType.TRANSFER, + tip: TransactionType.TIP } const txType = transactionTypeMap[tx.transactionType] @@ -48,6 +49,7 @@ export const audioTransactionFromSdk = ( metadata: undefined } case TransactionType.TRANSFER: + case TransactionType.TIP: return { signature: tx.signature, transactionType: txType, diff --git a/packages/common/src/store/ui/transaction-details/types.ts b/packages/common/src/store/ui/transaction-details/types.ts index b42aaa4c5bf..66f61bc76c0 100644 --- a/packages/common/src/store/ui/transaction-details/types.ts +++ b/packages/common/src/store/ui/transaction-details/types.ts @@ -7,7 +7,8 @@ export enum TransactionType { PURCHASE = 'PURCHASE', CHALLENGE_REWARD = 'CHALLENGE_REWARD', TRENDING_REWARD = 'TRENDING_REWARD', - TRANSFER = 'TRANSFER' + TRANSFER = 'TRANSFER', + TIP = 'TIP' } export enum TransactionMethod { @@ -50,7 +51,7 @@ export type TransactionDetails = } | { signature: string - transactionType: TransactionType.TRANSFER + transactionType: TransactionType.TRANSFER | TransactionType.TIP method: TransactionMethod.SEND | TransactionMethod.RECEIVE date: string change: StringAudio diff --git a/packages/web/src/components/audio-transaction-icon/AudioTransactionIcon.tsx b/packages/web/src/components/audio-transaction-icon/AudioTransactionIcon.tsx index 0e93acbb823..77ea3e4f555 100644 --- a/packages/web/src/components/audio-transaction-icon/AudioTransactionIcon.tsx +++ b/packages/web/src/components/audio-transaction-icon/AudioTransactionIcon.tsx @@ -30,6 +30,7 @@ const typeIconSvgMap: Record = { [TransactionType.CHALLENGE_REWARD]: IconTrophy, [TransactionType.PURCHASE]: null, // Not needed, AppLogo is used for purchases [TransactionType.TRANSFER]: IconTransaction, + [TransactionType.TIP]: IconTransaction, [TransactionType.TRENDING_REWARD]: IconTrophy } as const @@ -54,6 +55,7 @@ const typeIconMap: Record< [TransactionType.CHALLENGE_REWARD]: TypeIcon, [TransactionType.PURCHASE]: AppLogo, [TransactionType.TRANSFER]: TypeIcon, + [TransactionType.TIP]: TypeIcon, [TransactionType.TRENDING_REWARD]: TypeIcon } as const diff --git a/packages/web/src/components/audio-transactions-table/AudioTransactionsTable.module.css b/packages/web/src/components/audio-transactions-table/AudioTransactionsTable.module.css index 39d4d5f5156..36ff7ccfbf5 100644 --- a/packages/web/src/components/audio-transactions-table/AudioTransactionsTable.module.css +++ b/packages/web/src/components/audio-transactions-table/AudioTransactionsTable.module.css @@ -12,17 +12,15 @@ line-height: 1.2; } -.icon { - margin-right: 22px; +.typeText { + line-height: 150%; } -.changeCell.increase { - color: var(--harmony-green); -} -.changeCell.decrease { - color: var(--harmony-orange); +.tableWrapper { + padding-bottom: 0; + margin-bottom: 96px; } -.typeText { - line-height: 150%; +.tableWrapper :global([class*='showMoreContainer']) { + background-color: transparent; } diff --git a/packages/web/src/components/audio-transactions-table/AudioTransactionsTable.tsx b/packages/web/src/components/audio-transactions-table/AudioTransactionsTable.tsx index bea6b12b210..3e44f2f3594 100644 --- a/packages/web/src/components/audio-transactions-table/AudioTransactionsTable.tsx +++ b/packages/web/src/components/audio-transactions-table/AudioTransactionsTable.tsx @@ -9,17 +9,17 @@ import { import { dayjs } from '@audius/common/utils' import { wAUDIO } from '@audius/fixed-decimal' import { Tooltip } from '@audius/harmony' -import cn from 'classnames' import { Cell, Row } from 'react-table' -import { AudioTransactionIcon } from 'components/audio-transaction-icon' import { Table } from 'components/table' import { TableProps } from 'components/table/Table' +import { RESPONSIVE_TABLE_POLICIES } from 'components/table/responsivePolicies' import styles from './AudioTransactionsTable.module.css' const transactionTypeLabelMap: Record = { [TransactionType.TRANSFER]: '$AUDIO', + [TransactionType.TIP]: 'Tip', [TransactionType.CHALLENGE_REWARD]: '$AUDIO Reward Earned', [TransactionType.TRENDING_REWARD]: 'Trending Competition Award', [TransactionType.PURCHASE]: 'Purchased $AUDIO' @@ -75,16 +75,13 @@ const renderTransactionTypeCell = (cellInfo: TransactionCell) => { const methodText = transactionMethodLabelMap[method as TransactionMethod] ?? '' - const isTransferType = transactionType === TransactionType.TRANSFER + const isMethodType = + transactionType === TransactionType.TRANSFER || + transactionType === TransactionType.TIP return ( - <> -
- -
- - {`${typeText} ${isTransferType ? methodText : ''}`.trim()} - - + + {`${typeText} ${isMethodType ? methodText : ''}`.trim()} + ) } @@ -113,12 +110,7 @@ const renderChangeCell = (cellInfo: TransactionCell) => { })} $AUDIO`} mount={'body'} > -
+
{wAUDIO(BigInt(change)).toLocaleString('en-US', { maximumFractionDigits: 0 })} @@ -210,6 +202,8 @@ export const AudioTransactionsTable = ({ columns={tableColumns} onClickRow={handleClickRow} isEmptyRow={isEmptyRow} + responsiveColumns={RESPONSIVE_TABLE_POLICIES.audioTransactions} + wrapperClassName={styles.tableWrapper} {...other} /> ) diff --git a/packages/web/src/components/collections-table/CollectionsTable.tsx b/packages/web/src/components/collections-table/CollectionsTable.tsx index d55e865683d..bab5c992ff3 100644 --- a/packages/web/src/components/collections-table/CollectionsTable.tsx +++ b/packages/web/src/components/collections-table/CollectionsTable.tsx @@ -11,6 +11,7 @@ import { Cell, Row } from 'react-table' import { TextLink } from 'components/link' import { Table, alphaSorter, dateSorter, numericSorter } from 'components/table' +import type { TableProps } from 'components/table/Table' import styles from './CollectionsTable.module.css' import { CollectionsTableOverflowMenuButton } from './CollectionsTableOverflowMenuButton' @@ -48,6 +49,7 @@ type CollectionsTableProps = { showMoreLimit?: number totalRowCount?: number tableHeaderClassName?: string + responsiveColumns?: TableProps['responsiveColumns'] } const defaultColumns: CollectionsTableColumn[] = [ @@ -75,7 +77,8 @@ export const CollectionsTable = ({ scrollRef, showMoreLimit, totalRowCount, - tableHeaderClassName + tableHeaderClassName, + responsiveColumns }: CollectionsTableProps) => { // Cell Render Functions const renderNameCell = useCallback((cellInfo: CollectionCell) => { @@ -262,6 +265,7 @@ export const CollectionsTable = ({ showMoreLimit={showMoreLimit} totalRowCount={totalRowCount} tableHeaderClassName={tableHeaderClassName} + responsiveColumns={responsiveColumns} /> ) } diff --git a/packages/web/src/components/table/Table.module.css b/packages/web/src/components/table/Table.module.css index 7c5bc79c7a1..e988b06fd17 100644 --- a/packages/web/src/components/table/Table.module.css +++ b/packages/web/src/components/table/Table.module.css @@ -50,40 +50,44 @@ .tableHeader { position: relative; background-color: var(--harmony-white); - padding-left: 12px; + padding-left: 0px; padding-right: 12px; line-height: 41px; user-select: none; } -.tableHeader.hasSorter .textCell { - padding-right: 6px; -} - -.titleHeader + .titleHeader::before { - content: ''; - position: absolute; - height: 18px; - width: 1px; - top: 50%; - left: 0; - transform: translateY(-50%); - background-color: var(--harmony-n-100); -} - .textCell { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.headerContent { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + min-width: 0; +} + +.tableHeader.leftAlign .headerContent { + justify-content: flex-start; + padding-left: 12px; +} + +.tableHeader.rightAlign .headerContent { + justify-content: flex-end; +} + +.tableHeader:not(.leftAlign):not(.rightAlign) .headerContent { + justify-content: center; +} + .sortCaretContainer { - position: absolute; display: flex; flex-direction: column; - top: 50%; - right: 6px; - transform: translateY(-50%); + flex: 0 0 auto; + justify-content: center; } .sortCaret { @@ -259,15 +263,6 @@ text-overflow: ellipsis; } -.resizer { - position: absolute; - z-index: 1; - top: 0; - bottom: 0; - right: -2px; - width: 6px; -} - .leftAlign { text-align: left; justify-content: left; diff --git a/packages/web/src/components/table/Table.tsx b/packages/web/src/components/table/Table.tsx index b1afdc6fba1..0fd26952037 100644 --- a/packages/web/src/components/table/Table.tsx +++ b/packages/web/src/components/table/Table.tsx @@ -26,7 +26,6 @@ import { Row, TableRowProps, useFlexLayout, - useResizeColumns, useSortBy, useTable } from 'react-table' @@ -42,12 +41,19 @@ import Skeleton from 'components/skeleton/Skeleton' import styles from './Table.module.css' import { TableLoadingSpinner } from './components/TableLoadingSpinner' +import { + ResponsiveColumns, + getHiddenResponsiveColumns +} from './responsiveColumns' // - Infinite scroll constants - // Fetch the next group of rows when the user scroll within X rows of the bottom const FETCH_THRESHOLD = 40 // Number of rows to fetch in each batch const FETCH_BATCH_SIZE = 80 +// Table cells/headers add 12px left + 12px right padding in CSS. +// Include this chrome in collapse budgeting to avoid clipping before drop. +const TABLE_COLUMN_HORIZONTAL_CHROME_WIDTH = 24 // Column Sort Functions export const numericSorter = (accessor: string) => (rowA: any, rowB: any) => { @@ -111,6 +117,7 @@ export type TableProps = { totalRowCount?: number useLocalSort?: boolean wrapperClassName?: string + responsiveColumns?: ResponsiveColumns } export const Table = ({ @@ -141,6 +148,7 @@ export const Table = ({ totalRowCount, useLocalSort = false, wrapperClassName, + responsiveColumns, ...other }: TableProps) => { const trackAccessMap = useGatedContentAccessMap(isTracksTable ? data : []) @@ -171,6 +179,37 @@ export const Table = ({ return Math.floor(totalRowCount / pageSize) }, [pageSize, totalRowCount]) + const tableResizeObserverRef = useRef(null) + const tableResizeHandlerRef = useRef<(() => void) | null>(null) + const [tableWidth, setTableWidth] = useState(0) + + const hiddenResponsiveColumnIds = useMemo(() => { + if (!responsiveColumns) return new Set() + return getHiddenResponsiveColumns({ + columns, + containerWidth: tableWidth, + responsiveColumns, + fallbackColumnWidth: defaultColumn.width, + columnChromeWidth: TABLE_COLUMN_HORIZONTAL_CHROME_WIDTH + }) + }, [columns, defaultColumn.width, responsiveColumns, tableWidth]) + + const getColumnId = (column: any) => { + if (typeof column?.id === 'string' && column.id.length > 0) return column.id + if (typeof column?.accessor === 'string' && column.accessor.length > 0) { + return column.accessor + } + return null + } + + const visibleColumns = useMemo(() => { + if (!hiddenResponsiveColumnIds.size) return columns + return columns.filter((column) => { + const id = getColumnId(column) + return id == null || !hiddenResponsiveColumnIds.has(id) + }) + }, [columns, hiddenResponsiveColumnIds]) + const { getTableProps, getTableBodyProps, @@ -180,18 +219,64 @@ export const Table = ({ state: { sortBy } } = useTable( { - columns, + columns: visibleColumns, data, defaultColumn, autoResetSortBy: false, - autoResetResize: false, manualSortBy: Boolean(onSort) }, useSortBy, - useResizeColumns, useFlexLayout ) + const setTableWrapperNode = useCallback((node: HTMLDivElement | null) => { + if (tableResizeObserverRef.current) { + tableResizeObserverRef.current.disconnect() + tableResizeObserverRef.current = null + } + if (tableResizeHandlerRef.current) { + window.removeEventListener('resize', tableResizeHandlerRef.current) + tableResizeHandlerRef.current = null + } + + if (!node) return + + const measure = () => setTableWidth(node.clientWidth) + measure() + + if (typeof ResizeObserver !== 'undefined') { + const resizeObserver = new ResizeObserver(() => { + measure() + }) + resizeObserver.observe(node) + tableResizeObserverRef.current = resizeObserver + } else { + window.addEventListener('resize', measure) + tableResizeHandlerRef.current = measure + } + }, []) + + useEffect(() => { + return () => { + if (tableResizeObserverRef.current) { + tableResizeObserverRef.current.disconnect() + } + if (tableResizeHandlerRef.current) { + window.removeEventListener('resize', tableResizeHandlerRef.current) + } + } + }, []) + + const isEndColumn = useCallback( + (id: string) => id === 'trackActions' || id === 'overflowMenu', + [] + ) + + const isColumnVisible = useCallback( + (id: string) => !hiddenResponsiveColumnIds.has(id), + [hiddenResponsiveColumnIds] + ) + const [showMore, setShowMore] = useState( !showMoreLimit || pageSize < showMoreLimit ) @@ -260,7 +345,11 @@ export const Table = ({ key={key} > {/* Sorting Container */} -
+
{column.sortTitle ? ( @@ -281,19 +370,6 @@ export const Table = ({
) : null}
- {/* Resizing Container */} - {!column.disableResizing ? ( -
{ - e.preventDefault() - e.stopPropagation() - }} - {...column.getResizerProps()} - className={cn(styles.resizer, { - [styles.isResizing]: column.isResizing - })} - /> - ) : null} ) }, []) @@ -301,11 +377,11 @@ export const Table = ({ const renderHeaders = useCallback(() => { return headerGroups.map((headerGroup) => { const headers = headerGroup.headers.filter( - (header) => header.id !== 'trackActions' && header.id !== 'overflowMenu' + (header) => isColumnVisible(header.id) && !isEndColumn(header.id) ) // Should only be one or the other const endHeaders = headerGroup.headers.filter( - (header) => header.id === 'trackActions' || header.id === 'overflowMenu' + (header) => isColumnVisible(header.id) && isEndColumn(header.id) ) const { key: headerGroupKey, ...headerGroupProps } = @@ -323,7 +399,7 @@ export const Table = ({ ) }) - }, [headerGroups, renderTableHeader]) + }, [headerGroups, isColumnVisible, isEndColumn, renderTableHeader]) const renderCell = useCallback( (cell: Cell, isEnd?: boolean) => { @@ -363,12 +439,12 @@ export const Table = ({ (row: Row, key: string, props: TableRowProps, className = '') => { const cells = row.cells.filter( (cell: Cell) => - cell.column.id !== 'trackActions' && cell.column.id !== 'overflowMenu' + isColumnVisible(cell.column.id) && !isEndColumn(cell.column.id) ) // Should only be one or the other const endCells = row.cells.filter( (cell: Cell) => - cell.column.id === 'trackActions' || cell.column.id === 'overflowMenu' + isColumnVisible(cell.column.id) && isEndColumn(cell.column.id) ) const Row = isVirtualized ? 'div' : 'tr' @@ -406,6 +482,8 @@ export const Table = ({ trackAccessMap, activeIndex, getRowClassName, + isColumnVisible, + isEndColumn, onClickRow, renderCell, isVirtualized @@ -414,6 +492,9 @@ export const Table = ({ const renderSkeletonRow = useCallback( (row: Row, key: string, props: TableRowProps) => { + const cells = row.cells.filter((cell: Cell) => + isColumnVisible(cell.column.id) + ) return ( - {row.cells.map((cell) => renderSkeletonCell(cell))} + {cells.map((cell) => renderSkeletonCell(cell))} ) }, - [activeIndex, getRowClassName, renderSkeletonCell] + [activeIndex, getRowClassName, isColumnVisible, renderSkeletonCell] ) const onDragEnd = useCallback( @@ -664,7 +745,10 @@ export const Table = ({ const renderContent = useCallback(() => { return ( -
+
( -
+
{ + it('hides configured columns at deterministic breakpoints', () => { + const hidden = getHiddenResponsiveColumns({ + columns: [ + { id: 'trackName', width: 260 }, + { id: 'plays', width: 120 }, + { id: 'reposts', width: 120 }, + { id: 'trackActions', width: 140 } + ], + containerWidth: 900, + responsiveColumns: { + breakpoints: [ + { maxWidth: 1000, hide: ['reposts'] }, + { maxWidth: 920, hide: ['reposts', 'plays'] } + ], + alwaysVisibleIds: ['trackName', 'trackActions'] + }, + fallbackColumnWidth: 64 + }) + + expect(Array.from(hidden)).toEqual(['reposts', 'plays']) + }) + + it('does not hide any columns above all breakpoints', () => { + const hidden = getHiddenResponsiveColumns({ + columns: [ + { id: 'trackName', width: 260 }, + { id: 'plays', width: 120 }, + { id: 'trackActions', width: 140 } + ], + containerWidth: 1200, + responsiveColumns: { + breakpoints: [{ maxWidth: 1000, hide: ['plays'] }], + alwaysVisibleIds: ['trackName', 'trackActions'] + }, + fallbackColumnWidth: 64 + }) + + expect(hidden.size).toBe(0) + }) + + it('respects always-visible ids in breakpoint mode', () => { + const hidden = getHiddenResponsiveColumns({ + columns: [ + { id: 'trackName', width: 260 }, + { id: 'plays', width: 120 }, + { id: 'trackActions', width: 140 } + ], + containerWidth: 900, + responsiveColumns: { + breakpoints: [ + { maxWidth: 920, hide: ['plays', 'trackActions', 'trackName'] } + ], + alwaysVisibleIds: ['trackName', 'trackActions'] + }, + fallbackColumnWidth: 64 + }) + + expect(Array.from(hidden)).toEqual(['plays']) + }) + + it('gets column base width using width > maxWidth > minWidth > fallback', () => { + expect( + getColumnBaseWidth( + { id: 'a', width: 120, maxWidth: 100, minWidth: 80 }, + 64 + ) + ).toBe(120) + expect( + getColumnBaseWidth({ id: 'b', maxWidth: 140, minWidth: 80 }, 64) + ).toBe(140) + expect(getColumnBaseWidth({ id: 'c', minWidth: 90 }, 64)).toBe(90) + expect(getColumnBaseWidth({ id: 'd' }, 64)).toBe(64) + }) + + it('hides columns in configured order until table fits', () => { + const hidden = getHiddenResponsiveColumns({ + columns: [ + { id: 'trackName', width: 240, maxWidth: 420 }, + { id: 'plays', width: 120 }, + { id: 'reposts', width: 120 }, + { id: 'trackActions', width: 140 } + ], + containerWidth: 420, + responsiveColumns: { + hideOrder: ['reposts', 'plays'], + alwaysVisibleIds: ['trackName', 'trackActions'] + }, + fallbackColumnWidth: 64 + }) + + expect(Array.from(hidden)).toEqual(['reposts', 'plays']) + }) + + it('uses minWidth floor in responsive width budget', () => { + const hidden = getHiddenResponsiveColumns({ + columns: [ + { id: 'trackName', width: 240, minWidth: 320, maxWidth: 420 }, + { id: 'plays', width: 120 }, + { id: 'trackActions', width: 140 } + ], + containerWidth: 560, + responsiveColumns: { + hideOrder: ['plays'], + alwaysVisibleIds: ['trackName', 'trackActions'] + }, + fallbackColumnWidth: 64 + }) + + expect(Array.from(hidden)).toEqual(['plays']) + }) + + it('never hides always-visible columns', () => { + const hidden = getHiddenResponsiveColumns({ + columns: [ + { id: 'contentName', width: 320 }, + { id: 'date', width: 150 }, + { id: 'value', width: 150 } + ], + containerWidth: 260, + responsiveColumns: { + hideOrder: ['date', 'value', 'contentName'], + alwaysVisibleIds: ['contentName', 'value'] + }, + fallbackColumnWidth: 64 + }) + + expect(Array.from(hidden)).toEqual(['date']) + }) + + it('returns no hidden columns when width is sufficient', () => { + const hidden = getHiddenResponsiveColumns({ + columns: [ + { id: 'a', width: 100 }, + { id: 'b', width: 100 } + ], + containerWidth: 400, + responsiveColumns: { hideOrder: ['b'] }, + fallbackColumnWidth: 64 + }) + + expect(hidden.size).toBe(0) + }) + + it('is stable when no hideable columns remain', () => { + const hidden = getHiddenResponsiveColumns({ + columns: [ + { id: 'playButton', width: 48 }, + { id: 'trackName', width: 280 }, + { id: 'trackActions', width: 140 } + ], + containerWidth: 120, + responsiveColumns: { + hideOrder: ['date', 'plays', 'reposts'], + alwaysVisibleIds: ['playButton', 'trackName', 'trackActions'] + }, + fallbackColumnWidth: 64 + }) + + expect(hidden.size).toBe(0) + }) + + it('does not hide columns before the table is measured', () => { + const hidden = getHiddenResponsiveColumns({ + columns: [ + { id: 'trackName', width: 240 }, + { id: 'plays', width: 120 }, + { id: 'trackActions', width: 140 } + ], + containerWidth: 0, + responsiveColumns: { + hideOrder: ['plays'], + alwaysVisibleIds: ['trackName', 'trackActions'] + }, + fallbackColumnWidth: 64 + }) + + expect(hidden.size).toBe(0) + }) + + it('falls back to hideOrder budget mode when breakpoints are not provided', () => { + const hidden = getHiddenResponsiveColumns({ + columns: [ + { id: 'trackName', width: 240 }, + { id: 'plays', width: 120 }, + { id: 'reposts', width: 120 }, + { id: 'trackActions', width: 140 } + ], + containerWidth: 420, + responsiveColumns: { + hideOrder: ['reposts', 'plays'], + alwaysVisibleIds: ['trackName', 'trackActions'] + }, + fallbackColumnWidth: 64 + }) + + expect(Array.from(hidden)).toEqual(['reposts', 'plays']) + }) + + it('drops fixed utility columns while keeping track column visible', () => { + const hidden = getHiddenResponsiveColumns({ + columns: [ + { + id: 'trackName', + minWidth: 220, + width: 260, + maxWidth: Number.MAX_SAFE_INTEGER + }, + { id: 'dateReleased', minWidth: 104, width: 104, maxWidth: 104 }, + { id: 'time', minWidth: 80, width: 80, maxWidth: 80 }, + { id: 'dateSaved', minWidth: 104, width: 104, maxWidth: 104 }, + { id: 'reposts', minWidth: 80, width: 80, maxWidth: 80 }, + { id: 'plays', minWidth: 80, width: 80, maxWidth: 80 }, + { id: 'trackActions', minWidth: 140, width: 140, maxWidth: 140 } + ], + containerWidth: 700, + responsiveColumns: { + hideOrder: ['dateReleased', 'time', 'dateSaved', 'reposts', 'plays'], + alwaysVisibleIds: ['trackName', 'trackActions'] + }, + fallbackColumnWidth: 64 + }) + + expect(Array.from(hidden)).toEqual(['dateReleased', 'time']) + }) + + it('accounts for rendered per-column chrome when budgeting drops', () => { + const hidden = getHiddenResponsiveColumns({ + columns: [ + { + id: 'trackName', + minWidth: 320, + width: 320, + maxWidth: Number.MAX_SAFE_INTEGER + }, + { id: 'reposts', minWidth: 80, width: 80, maxWidth: 80 }, + { id: 'plays', minWidth: 80, width: 80, maxWidth: 80 }, + { id: 'trackActions', minWidth: 140, width: 140, maxWidth: 140 } + ], + containerWidth: 700, + responsiveColumns: { + hideOrder: ['reposts', 'plays'], + alwaysVisibleIds: ['trackName', 'trackActions'] + }, + fallbackColumnWidth: 64, + columnChromeWidth: 24 + }) + + expect(Array.from(hidden)).toEqual(['reposts']) + }) +}) diff --git a/packages/web/src/components/table/responsiveColumns.ts b/packages/web/src/components/table/responsiveColumns.ts new file mode 100644 index 00000000000..457c049ecb7 --- /dev/null +++ b/packages/web/src/components/table/responsiveColumns.ts @@ -0,0 +1,118 @@ +export type ResponsiveBreakpoint = { + maxWidth: number + hide: readonly string[] +} + +export type ResponsiveColumns = { + hideOrder?: readonly string[] + alwaysVisibleIds?: readonly string[] + breakpoints?: readonly ResponsiveBreakpoint[] +} + +export type ColumnWithSize = { + id?: string + accessor?: string | ((...args: any[]) => any) + width?: number + minWidth?: number + maxWidth?: number +} + +type GetHiddenResponsiveColumnsArgs = { + columns: ColumnWithSize[] + containerWidth: number + responsiveColumns: ResponsiveColumns + fallbackColumnWidth: number + columnChromeWidth?: number +} + +const isNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) + +const getColumnId = (column: ColumnWithSize) => { + if (typeof column.id === 'string' && column.id.length > 0) return column.id + if (typeof column.accessor === 'string' && column.accessor.length > 0) { + return column.accessor + } + return null +} + +export const getColumnBaseWidth = ( + column: ColumnWithSize, + fallbackColumnWidth: number +) => { + if (isNumber(column.width)) return column.width + if (isNumber(column.maxWidth)) return column.maxWidth + if (isNumber(column.minWidth)) return column.minWidth + return fallbackColumnWidth +} + +const getResponsiveBudgetWidth = ( + column: ColumnWithSize, + fallbackColumnWidth: number +) => { + // For responsive collapse decisions, use the column's minimum visible width + // first, so columns shrink before they are dropped. + if (isNumber(column.minWidth)) return column.minWidth + if (isNumber(column.width)) return column.width + if (isNumber(column.maxWidth)) return column.maxWidth + return fallbackColumnWidth +} + +export const getHiddenResponsiveColumns = ({ + columns, + containerWidth, + responsiveColumns, + fallbackColumnWidth, + columnChromeWidth = 0 +}: GetHiddenResponsiveColumnsArgs) => { + const alwaysVisible = new Set(responsiveColumns.alwaysVisibleIds ?? []) + + const breakpoints = responsiveColumns.breakpoints ?? [] + if (breakpoints.length > 0) { + if (!isNumber(containerWidth) || containerWidth <= 0) { + return new Set() + } + + const sortedBreakpoints = [...breakpoints].sort( + (a, b) => a.maxWidth - b.maxWidth + ) + const activeBreakpoint = sortedBreakpoints.find( + (breakpoint) => containerWidth <= breakpoint.maxWidth + ) + + if (!activeBreakpoint) return new Set() + + return new Set(activeBreakpoint.hide.filter((id) => !alwaysVisible.has(id))) + } + + const hideOrder = responsiveColumns.hideOrder ?? [] + if (!hideOrder.length || !isNumber(containerWidth) || containerWidth <= 0) { + return new Set() + } + + const widthById = new Map() + let totalWidth = 0 + + for (const column of columns) { + const id = getColumnId(column) + if (!id) continue + const width = + getResponsiveBudgetWidth(column, fallbackColumnWidth) + columnChromeWidth + widthById.set(id, width) + totalWidth += width + } + + if (totalWidth <= containerWidth) return new Set() + + const hidden = new Set() + for (const id of hideOrder) { + if (totalWidth <= containerWidth) break + if (alwaysVisible.has(id)) continue + const width = widthById.get(id) + if (!isNumber(width) || hidden.has(id)) continue + hidden.add(id) + totalWidth -= width + } + + return hidden +} diff --git a/packages/web/src/components/table/responsiveCoverage.test.ts b/packages/web/src/components/table/responsiveCoverage.test.ts new file mode 100644 index 00000000000..eac94e31ec7 --- /dev/null +++ b/packages/web/src/components/table/responsiveCoverage.test.ts @@ -0,0 +1,111 @@ +import fs from 'fs' +import path from 'path' + +import { describe, expect, it } from 'vitest' + +import { RESPONSIVE_TABLE_POLICIES } from './responsivePolicies' + +const webRoot = process.cwd().endsWith(path.join('packages', 'web')) + ? process.cwd() + : path.resolve(process.cwd(), 'packages/web') + +const responsiveConsumerFiles = [ + 'src/pages/library-page/components/desktop/LibraryPage.tsx', + 'src/pages/collection-page/components/desktop/CollectionPage.tsx', + 'src/pages/dashboard-page/components/ArtistDashboardTracksTab.tsx', + 'src/pages/history-page/components/desktop/HistoryPage.tsx', + 'src/pages/dashboard-page/components/ArtistDashboardAlbumsTab.tsx', + 'src/pages/artist-coins-launchpad-page/components/ArtistCoinsTable.tsx', + 'src/components/audio-transactions-table/AudioTransactionsTable.tsx', + 'src/pages/pay-and-earn-page/components/SalesTable.tsx', + 'src/pages/pay-and-earn-page/components/PurchasesTable.tsx', + 'src/pages/pay-and-earn-page/components/WithdrawalsTable.tsx' +] + +const allowedNonPolicyTableConsumers = [ + 'src/components/tracks-table/TrackTableLineup.tsx', + 'src/components/tracks-table/TracksTable.tsx', + 'src/components/collections-table/CollectionsTable.tsx' +] + +const tableUsageRegex = + /<(Table|TracksTable|TrackTableLineup|CollectionsTable)(\s|>|\n)/g + +const walk = (dir: string): string[] => { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === 'dist') continue + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + files.push(...walk(fullPath)) + continue + } + if (!entry.isFile()) continue + if (!fullPath.endsWith('.tsx')) continue + if (fullPath.endsWith('.test.tsx')) continue + files.push(fullPath) + } + + return files +} + +const getTableUsageFiles = () => { + const srcRoot = path.join(webRoot, 'src') + const files = walk(srcRoot) + const tableFiles: string[] = [] + + for (const file of files) { + const source = fs.readFileSync(file, 'utf8') + tableUsageRegex.lastIndex = 0 + if (!tableUsageRegex.test(source)) continue + tableFiles.push(path.relative(webRoot, file).replace(/\\/g, '/')) + } + + return tableFiles.sort() +} + +describe('responsive table coverage', () => { + it('has a policy for each audited shared table consumer', () => { + expect(Object.keys(RESPONSIVE_TABLE_POLICIES).sort()).toEqual( + [ + 'artistCoinsLeaderboard', + 'audioTransactions', + 'collectionAlbumTracks', + 'collectionPlaylistTracks', + 'dashboardAlbums', + 'dashboardTracks', + 'historyTracks', + 'libraryTracks', + 'purchases', + 'sales', + 'withdrawals' + ].sort() + ) + }) + + it('contains all table usage surfaces in the audited allowlist', () => { + const discoveredTableFiles = getTableUsageFiles() + const auditedTableFiles = [ + ...responsiveConsumerFiles, + ...allowedNonPolicyTableConsumers + ].sort() + + expect(discoveredTableFiles).toEqual(auditedTableFiles) + }) + + it('wires responsiveColumns policy in every audited shared table consumer', () => { + for (const relativePath of responsiveConsumerFiles) { + const fullPath = path.join(webRoot, relativePath) + const source = fs.readFileSync(fullPath, 'utf8') + expect(source, `${relativePath} must import responsive policies`).toMatch( + /RESPONSIVE_TABLE_POLICIES/ + ) + expect( + source, + `${relativePath} must pass responsiveColumns into a table wrapper` + ).toMatch(/responsiveColumns\s*=/) + } + }) +}) diff --git a/packages/web/src/components/table/responsivePolicies.ts b/packages/web/src/components/table/responsivePolicies.ts new file mode 100644 index 00000000000..53acbdbfdaa --- /dev/null +++ b/packages/web/src/components/table/responsivePolicies.ts @@ -0,0 +1,50 @@ +import { ResponsiveColumns } from './responsiveColumns' + +const makeHideOrderPolicy = ( + hideOrder: readonly string[], + alwaysVisibleIds: readonly string[] +): ResponsiveColumns => ({ + hideOrder, + alwaysVisibleIds +}) + +export const RESPONSIVE_TABLE_POLICIES = { + libraryTracks: makeHideOrderPolicy( + ['dateReleased', 'time', 'dateSaved', 'reposts', 'plays'], + ['trackName', 'trackActions'] + ), + collectionPlaylistTracks: makeHideOrderPolicy( + ['dateAdded', 'time', 'reposts', 'plays'], + ['trackName', 'trackActions'] + ), + collectionAlbumTracks: makeHideOrderPolicy( + ['date', 'time', 'reposts', 'plays'], + ['playButton', 'trackName', 'trackActions'] + ), + dashboardTracks: makeHideOrderPolicy( + ['spacer', 'reposts', 'saves', 'comments', 'plays', 'dateReleased'], + ['trackName', 'overflowMenu'] + ), + historyTracks: makeHideOrderPolicy( + ['dateReleased', 'dateListened', 'time', 'reposts', 'plays'], + ['trackName', 'trackActions'] + ), + dashboardAlbums: makeHideOrderPolicy( + ['spacer', 'reposts', 'saves', 'dateReleased'], + ['name', 'overflowMenu'] + ), + artistCoinsLeaderboard: makeHideOrderPolicy( + ['holders', 'createdDate', 'marketCap', 'totalVolumeUSD', 'artist'], + ['tokenName', 'price', 'buy'] + ), + audioTransactions: makeHideOrderPolicy( + ['spacer2', 'balance', 'change', 'date', 'spacer'], + ['transactionType'] + ), + sales: makeHideOrderPolicy( + ['spacerRight', 'buyer', 'date', 'spacerLeft'], + ['contentName', 'value'] + ), + purchases: makeHideOrderPolicy([], ['contentName', 'date', 'value']), + withdrawals: makeHideOrderPolicy([], ['destination', 'date', 'amount']) +} as const satisfies Record diff --git a/packages/web/src/components/tracks-table/TracksTable.module.css b/packages/web/src/components/tracks-table/TracksTable.module.css index d3a1ff31527..543621cc12a 100644 --- a/packages/web/src/components/tracks-table/TracksTable.module.css +++ b/packages/web/src/components/tracks-table/TracksTable.module.css @@ -1,11 +1,74 @@ .textContainer { position: relative; - display: inline-flex; - gap: 4px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + width: 100%; + min-width: 0; max-width: 100%; } +.trackInfoContainer { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + user-select: none; +} + +.inlineArtworkContainer { + position: relative; + width: 48px; + height: 48px; + min-width: 48px; + border: 1px solid var(--harmony-n-100); + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; +} + +.inlineArtworkIcon { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + background-color: rgba(0, 0, 0, 0.45); + transition: opacity 0.12s ease-in-out; + pointer-events: none; +} + +.inlineArtworkIcon :global(svg) { + width: 20px; + height: 20px; +} + +.tableRow:hover .inlineArtworkIcon, +.tableRow.active .inlineArtworkIcon { + opacity: 0.75; +} + +.inlineArtworkIconActive { + opacity: 0.75; +} + +.trackHeaderWithArtwork { + display: inline-flex; + align-items: center; +} + +.trackHeaderArtworkSpacer { + width: 56px; + min-width: 56px; + display: inline-block; +} + .textCell { + width: 100%; + min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -15,11 +78,13 @@ .trackCell { composes: textCell; display: block; + user-select: none; } .trackName { display: flex; align-items: center; + user-select: none; } .trackName:hover:not(:has(.locked)) { @@ -32,6 +97,18 @@ .artistCellContainer { width: 100%; + min-width: 0; +} + +.stackedArtistText { + display: block; + width: 100%; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--harmony-n-500); + user-select: none; } .badges { diff --git a/packages/web/src/components/tracks-table/TracksTable.test.tsx b/packages/web/src/components/tracks-table/TracksTable.test.tsx new file mode 100644 index 00000000000..82492d6e055 --- /dev/null +++ b/packages/web/src/components/tracks-table/TracksTable.test.tsx @@ -0,0 +1,127 @@ +import { ReactNode } from 'react' + +import { describe, expect, it, vi } from 'vitest' + +import { render, screen } from 'test/test-utils' + +import { TracksTable } from './TracksTable' + +vi.mock('@audius/common/hooks', () => ({ + useGatedContentAccessMap: () => ({}) +})) + +vi.mock('@audius/common/store', async () => { + const actual = await vi.importActual('@audius/common/store') + return { + ...actual, + PurchaseableContentType: { + ...actual.PurchaseableContentType, + TRACK: 'track' + }, + gatedContentActions: { + ...actual.gatedContentActions, + setLockedContentId: vi.fn() + }, + gatedContentSelectors: { + ...actual.gatedContentSelectors, + getGatedContentStatusMap: () => ({}) + }, + usePremiumContentPurchaseModal: () => ({ onOpen: vi.fn() }) + } +}) + +vi.mock('common/hooks/useModalState', () => ({ + useModalState: () => [false, vi.fn()] +})) + +vi.mock('hooks/useTrackCoverArt', () => ({ + useTrackCoverArt: () => ({ imageUrl: undefined, hasNoArtwork: false }) +})) + +vi.mock('components/link', () => ({ + TextLink: ({ children, to }: { children: ReactNode; to?: string }) => ( + {children} + ), + UserLink: ({ userId }: { userId?: number }) => ( + {`user-${userId ?? 'unknown'}`} + ) +})) + +vi.mock('components/table', () => ({ + Table: ({ columns, data }: { columns: any[]; data: any[] }) => ( +
+ {columns.map((column) => ( +
+ {column.Cell + ? column.Cell({ + row: { original: data[0], index: 0 } + }) + : null} +
+ ))} +
+ ), + OverflowMenuButton: () => null, + TableFavoriteButton: () => null, + TablePlayButton: () => null, + TableRepostButton: () => null, + alphaSorter: () => () => 0, + dateSorter: () => () => 0, + numericSorter: () => () => 0 +})) + +const track = { + track_id: 1, + uid: '1', + name: 'Track One', + title: 'Track One', + permalink: '/tracks/one', + created_at: '2024-01-01', + date: '2024-01-01', + dateAdded: '2024-01-01', + dateSaved: '2024-01-01', + dateListened: '2024-01-01', + release_date: '2024-01-01', + duration: 180, + time: 180, + plays: 100, + play_count: 100, + repost_count: 10, + save_count: 5, + comment_count: 1, + owner_id: 1, + is_unlisted: false, + is_delete: false, + _marked_deleted: false, + is_stream_gated: false, + has_current_user_saved: false, + has_current_user_reposted: false, + user: { + user_id: 1, + name: 'Artist One', + handle: 'artist-one', + is_deactivated: false + } +} + +describe('TracksTable', () => { + it('renders stacked artist info inside trackName when enabled', () => { + render( + + ) + + expect(screen.getByText('Track One')).toBeInTheDocument() + expect(screen.getByText('user-1')).toBeInTheDocument() + }) + + it('does not render stacked artist info when disabled', () => { + render() + + expect(screen.getByText('Track One')).toBeInTheDocument() + expect(screen.queryByText('user-1')).not.toBeInTheDocument() + }) +}) diff --git a/packages/web/src/components/tracks-table/TracksTable.tsx b/packages/web/src/components/tracks-table/TracksTable.tsx index 63b6af081d4..19e7b33bca0 100644 --- a/packages/web/src/components/tracks-table/TracksTable.tsx +++ b/packages/web/src/components/tracks-table/TracksTable.tsx @@ -1,8 +1,9 @@ -import { MouseEvent, useCallback, useMemo, useRef } from 'react' +import { memo, MouseEvent, useCallback, useMemo, useRef } from 'react' import { useGatedContentAccessMap } from '@audius/common/hooks' import { ModalSource, + SquareSizes, Track, UID, UserTrack, @@ -19,9 +20,13 @@ import { formatCount, formatSeconds, dayjs } from '@audius/common/utils' import { IconVisibilityHidden, IconLock, + IconPlay, + IconPause, + IconImage, Flex, IconSparkles, IconCart, + Artwork, Text, Tooltip } from '@audius/harmony' @@ -43,6 +48,7 @@ import { } from 'components/table' import type { TableProps } from 'components/table/Table' import { GatedConditionsPill } from 'components/track/GatedConditionsPill' +import { useTrackCoverArt } from 'hooks/useTrackCoverArt' import { isDescendantElementOf } from 'utils/domUtils' import styles from './TracksTable.module.css' @@ -66,6 +72,43 @@ type TrackCell = Cell type TrackRow = Row +type MiniTrackArtworkProps = { + trackId: number + isPlaying: boolean + isLocked: boolean +} + +const MiniTrackArtwork = memo( + ({ trackId, isPlaying, isLocked }: MiniTrackArtworkProps) => { + const { imageUrl, hasNoArtwork } = useTrackCoverArt({ + trackId, + size: SquareSizes.SIZE_150_BY_150 + }) + + const IconComponent = hasNoArtwork + ? IconImage + : isLocked + ? IconLock + : isPlaying + ? IconPause + : IconPlay + + return ( +
+ +
+ +
+
+ ) + } +) +MiniTrackArtwork.displayName = 'MiniTrackArtwork' + export type TracksTableColumn = | 'addedDate' | 'artistName' @@ -103,6 +146,7 @@ type TracksTableProps = { onReorder?: (source: number, destination: number) => void onSort?: (...props: any[]) => void columns?: TracksTableColumn[] + showArtistInTrackNameColumn?: boolean onClickRow?: (track: any, index: number) => void } & Omit @@ -127,6 +171,7 @@ export const TracksTable = ({ onClickRepost, playing = false, removeText, + showArtistInTrackNameColumn = false, userId, columns = defaultColumns, data, @@ -134,104 +179,195 @@ export const TracksTable = ({ ...tableProps }: TracksTableProps) => { const { isVirtualized, onClickRow } = tableProps + const activeIndexRef = useRef(activeIndex) + activeIndexRef.current = activeIndex + const playingRef = useRef(playing) + playingRef.current = playing const dispatch = useDispatch() const gatedTrackStatusMap = useSelector(getGatedContentStatusMap) + const gatedTrackStatusMapRef = useRef(gatedTrackStatusMap) + gatedTrackStatusMapRef.current = gatedTrackStatusMap const trackAccessMap = useGatedContentAccessMap(data) + const trackAccessMapRef = useRef(trackAccessMap) + trackAccessMapRef.current = trackAccessMap + const dataRef = useRef(data) + dataRef.current = data + const onClickRowRef = useRef(onClickRow) + onClickRowRef.current = onClickRow + const onClickFavoriteRef = useRef(onClickFavorite) + onClickFavoriteRef.current = onClickFavorite + const onClickRepostRef = useRef(onClickRepost) + onClickRepostRef.current = onClickRepost + const onClickRemoveRef = useRef(onClickRemove) + onClickRemoveRef.current = onClickRemove const { onOpen: openPremiumContentPurchaseModal } = usePremiumContentPurchaseModal() const [, setGatedModalVisibility] = useModalState('LockedContent') // Cell Render Functions - const renderPlayButtonCell = useCallback( - (cellInfo: TrackCell) => { - const index = cellInfo.row.index - const active = index === activeIndex - const track = cellInfo.row.original - const isTrackPremium = isContentUSDCPurchaseGated(track.stream_conditions) - const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMap[ - track.track_id - ] ?? { isFetchingNFTAccess: false, hasStreamAccess: true } - const isLocked = !isFetchingNFTAccess && !hasStreamAccess - - return ( - - ) - }, - [playing, activeIndex, trackAccessMap] - ) + const renderPlayButtonCell = useCallback((cellInfo: TrackCell) => { + const index = cellInfo.row.index + const active = index === activeIndexRef.current + const track = cellInfo.row.original + const isTrackPremium = isContentUSDCPurchaseGated(track.stream_conditions) + const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMapRef.current[ + track.track_id + ] ?? { isFetchingNFTAccess: false, hasStreamAccess: true } + const isLocked = !isFetchingNFTAccess && !hasStreamAccess + + return ( + + ) + }, []) const renderTrackNameCell = useCallback( (cellInfo: TrackCell) => { const track = cellInfo.row.original const index = cellInfo.row.index - const active = index === activeIndex + const active = index === activeIndexRef.current + const isTrackPlaying = active && playingRef.current const deleted = track.is_delete || track._marked_deleted || !!track.user?.is_deactivated - - return ( -
- {deleted ? ( - {`${track.name} [Deleted By Artist]`} - ) : ( - - {track.name ?? track.title} - - )} -
- ) - }, - [activeIndex] - ) - - const renderArtistNameCell = useCallback( - (cellInfo: TrackCell) => { - const { original: track, index } = cellInfo.row - const { user } = track - if (!user) { - return 'Unknown' - } - if (user?.is_deactivated) { - return `${user?.name} [Deactivated]` + const user = track.user + const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMapRef + .current[track.track_id] ?? { + isFetchingNFTAccess: false, + hasStreamAccess: true } + const isLocked = !isFetchingNFTAccess && !hasStreamAccess - return ( -
+ const artistRow = showArtistInTrackNameColumn ? ( + user?.is_deactivated ? ( + + {`${user.name} [Deactivated]`} + + ) : user?.user_id ? ( -
+ ) : ( + + Unknown + + ) + ) : null + + return ( + + {showArtistInTrackNameColumn && track.track_id ? ( + + ) : null} + + {deleted ? ( + {`${track.name} [Deleted By Artist]`} + ) : ( + + {track.name ?? track.title} + + )} + {artistRow} + + ) }, - [activeIndex] + [showArtistInTrackNameColumn] ) + const renderArtistNameCell = useCallback((cellInfo: TrackCell) => { + const { original: track, index } = cellInfo.row + const { user } = track + if (!user) { + return 'Unknown' + } + if (user?.is_deactivated) { + return `${user?.name} [Deactivated]` + } + + return ( +
+ +
+ ) + }, []) + const renderPlaysCell = useCallback( (cellInfo: TrackCell) => { const track = cellInfo.row.original @@ -330,9 +466,11 @@ export const TracksTable = ({ const renderFavoriteButtonCell = useCallback( (cellInfo: TrackCell) => { const track = cellInfo.row.original - const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMap[ - track.track_id - ] ?? { isFetchingNFTAccess: false, hasStreamAccess: true } + const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMapRef + .current[track.track_id] ?? { + isFetchingNFTAccess: false, + hasStreamAccess: true + } const isLocked = !isFetchingNFTAccess && !hasStreamAccess const deleted = track.is_delete || track._marked_deleted || !!track.user?.is_deactivated @@ -352,23 +490,25 @@ export const TracksTable = ({ className={cn(styles.tableActionButton, { [styles.active]: track.has_current_user_saved })} - onClick={() => onClickFavorite?.(track)} + onClick={() => onClickFavoriteRef.current?.(track)} favorited={track.has_current_user_saved} /> ) }, - [trackAccessMap, onClickFavorite, userId] + [userId] ) const repostButtonRef = useRef(null) const renderRepostButtonCell = useCallback( (cellInfo: TrackCell) => { const track = cellInfo.row.original - const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMap[ - track.track_id - ] ?? { isFetchingNFTAccess: false, hasStreamAccess: true } + const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMapRef + .current[track.track_id] ?? { + isFetchingNFTAccess: false, + hasStreamAccess: true + } const isLocked = !isFetchingNFTAccess && !hasStreamAccess const deleted = track.is_delete || track._marked_deleted || !!track.user?.is_deactivated @@ -389,7 +529,7 @@ export const TracksTable = ({ [styles.active]: track.has_current_user_reposted })} onClick={(e) => { - onClickRepost?.(track) + onClickRepostRef.current?.(track) e.stopPropagation() }} reposted={track.has_current_user_reposted} @@ -398,7 +538,7 @@ export const TracksTable = ({ ) }, - [trackAccessMap, onClickRepost, userId] + [userId] ) const overflowMenuRef = useRef(null) @@ -407,9 +547,11 @@ export const TracksTable = ({ const track = cellInfo.row.original const { stream_conditions: streamConditions, is_unlisted: isUnlisted } = track - const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMap[ - track.track_id - ] ?? { isFetchingNFTAccess: false, hasStreamAccess: true } + const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMapRef + .current[track.track_id] ?? { + isFetchingNFTAccess: false, + hasStreamAccess: true + } const isOwner = track.owner_id === userId const isLocked = !isFetchingNFTAccess && !hasStreamAccess const isDdex = !!track.ddex_app @@ -461,7 +603,7 @@ export const TracksTable = ({ includeEmbed: !isUnlisted, includeEdit: !disabledTrackEdit, includeAddToPlaylist: !isUnlisted || isOwner, - onRemove: onClickRemove, + onRemove: onClickRemoveRef.current, removeText } @@ -481,15 +623,7 @@ export const TracksTable = ({ ) }, - [ - trackAccessMap, - shouldShowGatedType, - disabledTrackEdit, - isAlbumPage, - onClickRemove, - removeText, - userId - ] + [shouldShowGatedType, disabledTrackEdit, isAlbumPage, removeText, userId] ) const onClickPremiumPill = useCallback( @@ -516,13 +650,15 @@ export const TracksTable = ({ const renderLockedButtonCell = useCallback( (cellInfo: TrackCell) => { const track = cellInfo.row.original - const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMap[ - track.track_id - ] ?? { isFetchingNFTAccess: false, hasStreamAccess: true } + const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMapRef + .current[track.track_id] ?? { + isFetchingNFTAccess: false, + hasStreamAccess: true + } const isLocked = !isFetchingNFTAccess && !hasStreamAccess const isLockedPremium = isLocked && isContentUSDCPurchaseGated(track.stream_conditions) - const gatedTrackStatus = gatedTrackStatusMap[track.track_id] + const gatedTrackStatus = gatedTrackStatusMapRef.current[track.track_id] const isOwner = track.owner_id === userId const deleted = @@ -547,21 +683,17 @@ export const TracksTable = ({ /> ) }, - [ - gatedTrackStatusMap, - onClickGatedPill, - onClickPremiumPill, - trackAccessMap, - userId - ] + [onClickGatedPill, onClickPremiumPill, userId] ) const renderTrackActions = useCallback( (cellInfo: TrackCell) => { const track = cellInfo.row.original - const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMap[ - track.track_id - ] ?? { isFetchingNFTAccess: false, hasStreamAccess: true } + const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMapRef + .current[track.track_id] ?? { + isFetchingNFTAccess: false, + hasStreamAccess: true + } const isLocked = !isFetchingNFTAccess && !hasStreamAccess return ( @@ -582,7 +714,6 @@ export const TracksTable = ({ ) }, [ - trackAccessMap, renderFavoriteButtonCell, renderOverflowMenuCell, renderLockedButtonCell, @@ -598,9 +729,12 @@ export const TracksTable = ({ Header: 'Added', accessor: 'dateAdded', Cell: renderAddedDateCell, - maxWidth: 160, + minWidth: 104, + width: 104, + maxWidth: 104, sortTitle: 'Date Added', sorter: dateSorter('dateAdded'), + disableResizing: true, align: 'right' }, artistName: { @@ -608,10 +742,12 @@ export const TracksTable = ({ Header: 'Artist', accessor: 'artist', Cell: renderArtistNameCell, - maxWidth: 300, - width: 120, + minWidth: 180, + width: 180, + maxWidth: 180, sortTitle: 'Artist Name', sorter: alphaSorter('artist'), + disableResizing: true, align: 'left' }, date: { @@ -619,9 +755,12 @@ export const TracksTable = ({ Header: 'Date', accessor: 'date', Cell: renderDateCell, - maxWidth: 160, + minWidth: 104, + width: 104, + maxWidth: 104, sortTitle: 'Date Listened', sorter: dateSorter('date'), + disableResizing: true, align: 'right' }, listenDate: { @@ -629,9 +768,12 @@ export const TracksTable = ({ Header: 'Played', accessor: 'dateListened', Cell: renderListenDateCell, - maxWidth: 160, + minWidth: 104, + width: 104, + maxWidth: 104, sortTitle: 'Date Listened', sorter: dateSorter('dateListened'), + disableResizing: true, align: 'right' }, releaseDate: { @@ -639,9 +781,12 @@ export const TracksTable = ({ Header: 'Released', accessor: 'created_at', Cell: renderReleaseDateCell, - maxWidth: 160, + minWidth: 104, + width: 104, + maxWidth: 104, sortTitle: 'Date Released', sorter: dateSorter('created_at'), + disableResizing: true, align: 'right' }, reposts: { @@ -649,9 +794,12 @@ export const TracksTable = ({ Header: 'Reposts', accessor: 'repost_count', Cell: renderRepostsCell, - maxWidth: 160, + minWidth: 80, + width: 80, + maxWidth: 80, sortTitle: 'Reposts', sorter: numericSorter('repost_count'), + disableResizing: true, align: 'right' }, plays: { @@ -659,11 +807,12 @@ export const TracksTable = ({ Header: 'Plays', accessor: 'plays', Cell: renderPlaysCell, - maxWidth: 120, - width: 48, - minWidth: 48, + minWidth: 80, + width: 80, + maxWidth: 80, sortTitle: 'Plays', sorter: numericSorter('plays'), + disableResizing: true, align: 'right' }, playButton: { @@ -679,9 +828,12 @@ export const TracksTable = ({ Header: 'Favorites', accessor: 'save_count', Cell: renderSavesCell, - maxWidth: 160, + minWidth: 80, + width: 80, + maxWidth: 80, sortTitle: 'Favorites', sorter: numericSorter('save_count'), + disableResizing: true, align: 'right' }, comments: { @@ -689,17 +841,20 @@ export const TracksTable = ({ Header: 'Comments', accessor: 'comment_count', Cell: renderCommentsCell, - maxWidth: 160, + minWidth: 80, + width: 80, + maxWidth: 80, sortTitle: 'Comments', sorter: numericSorter('comment_count'), + disableResizing: true, align: 'right' }, overflowActions: { id: 'trackActions', Cell: renderTrackActions, - minWidth: 140, - maxWidth: 140, - width: 140, + minWidth: 120, + maxWidth: 120, + width: 120, disableResizing: true, disableSortBy: true }, @@ -717,19 +872,33 @@ export const TracksTable = ({ Header: 'Length', accessor: 'time', Cell: renderLengthCell, - maxWidth: 160, + minWidth: 80, + width: 80, + maxWidth: 80, sortTitle: 'Track Length', sorter: numericSorter('time'), disableSortBy: isVirtualized, + disableResizing: true, align: 'right' }, trackName: { id: 'trackName', - Header: 'Track Name', + Header: showArtistInTrackNameColumn ? ( + + + ) : ( + 'Track' + ), accessor: 'title', Cell: renderTrackNameCell, - maxWidth: 300, - width: 120, + minWidth: showArtistInTrackNameColumn ? 320 : 260, + width: showArtistInTrackNameColumn ? 320 : 260, + maxWidth: Number.MAX_SAFE_INTEGER, sortTitle: 'Track Name', sorter: alphaSorter('title'), align: 'left' @@ -739,9 +908,12 @@ export const TracksTable = ({ Header: 'Saved', accessor: 'dateSaved', Cell: renderSavedDateCell, - maxWidth: 160, + minWidth: 104, + width: 104, + maxWidth: 104, sortTitle: 'Date Saved', sorter: dateSorter('dateSaved'), + disableResizing: true, align: 'right' }, spacer: { @@ -767,6 +939,7 @@ export const TracksTable = ({ renderOverflowMenuCell, renderLengthCell, isVirtualized, + showArtistInTrackNameColumn, renderTrackNameCell, renderSavedDateCell ] @@ -780,9 +953,11 @@ export const TracksTable = ({ const handleClickRow = useCallback( (e: MouseEvent, rowInfo: TrackRow, index: number) => { const track = rowInfo.original - const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMap[ - track.track_id - ] ?? { isFetchingNFTAccess: false, hasStreamAccess: true } + const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMapRef + .current[track.track_id] ?? { + isFetchingNFTAccess: false, + hasStreamAccess: true + } const isLocked = !isFetchingNFTAccess && !hasStreamAccess const isPremium = isContentUSDCPurchaseGated(track.stream_conditions) const deleted = @@ -794,28 +969,28 @@ export const TracksTable = ({ ].some((ref) => isDescendantElementOf(e?.target, ref.current)) if ((isLocked && !isPremium) || deleted || clickedActionButton) return - onClickRow?.(track, index) + onClickRowRef.current?.(track, index) }, - [trackAccessMap, onClickRow] + [] ) - const getRowClassName = useCallback( - (rowIndex: number) => { - const track = data[rowIndex] - const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMap[ - track.track_id - ] ?? { isFetchingNFTAccess: false, hasStreamAccess: true } - const isLocked = !isFetchingNFTAccess && !hasStreamAccess - const deleted = - track.is_delete || track._marked_deleted || !!track.user?.is_deactivated - const isPremium = isContentUSDCPurchaseGated(track.stream_conditions) - return cn(styles.tableRow, { - [styles.disabled]: deleted, - [styles.lockedRow]: isLocked && !deleted && !isPremium - }) - }, - [trackAccessMap, data] - ) + const getRowClassName = useCallback((rowIndex: number) => { + const track = dataRef.current[rowIndex] + const { isFetchingNFTAccess, hasStreamAccess } = trackAccessMapRef.current[ + track.track_id + ] ?? { + isFetchingNFTAccess: false, + hasStreamAccess: true + } + const isLocked = !isFetchingNFTAccess && !hasStreamAccess + const deleted = + track.is_delete || track._marked_deleted || !!track.user?.is_deactivated + const isPremium = isContentUSDCPurchaseGated(track.stream_conditions) + return cn(styles.tableRow, { + [styles.disabled]: deleted, + [styles.lockedRow]: isLocked && !deleted && !isPremium + }) + }, []) return (
void onSort?: (...props: any[]) => void columns?: TracksTableColumn[] + showArtistInTrackNameColumn?: boolean onClickRow?: (track: TrackWithUID, index: number) => void } & Omit diff --git a/packages/web/src/components/transaction-details-modal/components/TransactionDetailsContent.tsx b/packages/web/src/components/transaction-details-modal/components/TransactionDetailsContent.tsx index a9d5ef31681..e34f5db4aa3 100644 --- a/packages/web/src/components/transaction-details-modal/components/TransactionDetailsContent.tsx +++ b/packages/web/src/components/transaction-details-modal/components/TransactionDetailsContent.tsx @@ -41,6 +41,7 @@ const messages = { trendingRewardDescription: 'Trending Competition Award', challengeRewardHeader: 'Challenge Completed', challengeRewardDescription: '$AUDIO Reward Earned', + tipDescription: 'Tip', transferDescription: '$AUDIO ', transferSentHeader: 'Destination Wallet', transferReceivedHeader: 'Origin Wallet', @@ -50,6 +51,7 @@ const messages = { const transactionDescriptions: Record = { [TransactionType.PURCHASE]: messages.purchaseDescription, [TransactionType.TRANSFER]: messages.transferDescription, + [TransactionType.TIP]: messages.tipDescription, [TransactionType.TRENDING_REWARD]: messages.trendingRewardDescription, [TransactionType.CHALLENGE_REWARD]: messages.challengeRewardDescription } @@ -124,6 +126,15 @@ const dateAndMetadataBlocks = ({ ) } + case TransactionType.TIP: { + return ( + <> + + {transactionDetails.date} + + + ) + } default: return <> } @@ -164,7 +175,9 @@ export const TransactionDetailsContent = ({
{transactionDescriptions[transactionDetails.transactionType] + - (transactionDetails.transactionType === TransactionType.TRANSFER + (transactionDetails.transactionType === + TransactionType.TRANSFER || + transactionDetails.transactionType === TransactionType.TIP ? formatCapitalizeString(transactionDetails.method) : '')} diff --git a/packages/web/src/pages/artist-coins-launchpad-page/components/ArtistCoinsTable.module.css b/packages/web/src/pages/artist-coins-launchpad-page/components/ArtistCoinsTable.module.css deleted file mode 100644 index e08b78d2ad6..00000000000 --- a/packages/web/src/pages/artist-coins-launchpad-page/components/ArtistCoinsTable.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.tableHeader { - padding-left: var(--harmony-spacing-xl); -} diff --git a/packages/web/src/pages/artist-coins-launchpad-page/components/ArtistCoinsTable.tsx b/packages/web/src/pages/artist-coins-launchpad-page/components/ArtistCoinsTable.tsx index d6d30f9ea32..9dfa713518f 100644 --- a/packages/web/src/pages/artist-coins-launchpad-page/components/ArtistCoinsTable.tsx +++ b/packages/web/src/pages/artist-coins-launchpad-page/components/ArtistCoinsTable.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { Coin } from '@audius/common/adapters' import { @@ -33,11 +33,11 @@ import { Cell } from 'react-table' import { TokenIcon } from 'components/buy-sell-modal/TokenIcon' import { TextLink, UserLink } from 'components/link' import { dateSorter, numericSorter, Table } from 'components/table' +import { RESPONSIVE_TABLE_POLICIES } from 'components/table/responsivePolicies' import { useExternalWalletAddress } from 'hooks/useExternalWalletAddress' import { useMainContentRef } from 'pages/MainContentContext' import { getScrollParent } from 'utils/scrollParent' -import styles from './ArtistCoinsTable.module.css' import { FanClubCardSkeleton, FanClubCoinCard } from './FanClubCoinCard' export const FAN_CLUBS_VIEW_STORAGE_KEY = 'audius:fan-clubs-explore-view' @@ -62,7 +62,6 @@ type CoinCell = Cell const renderTokenNameCell = (cellInfo: CoinCell) => { const coin = cellInfo.row.original - const { ownerId } = coin if (!coin || !coin.ticker) { return null @@ -93,9 +92,8 @@ const renderTokenNameCell = (cellInfo: CoinCell) => { alignItems='center' css={{ overflow: 'hidden', - flex: '0 0 clamp(80px, 24ch, 180px)', - minWidth: 'clamp(80px, 24ch, 180px)', - maxWidth: 'clamp(80px, 24ch, 180px)' + flex: '1 1 0', + minWidth: 0 }} > { - - {ownerId ? ( - - ) : ( - - )} - ) } +const renderArtistCell = (cellInfo: CoinCell) => { + const coin = cellInfo.row.original + const { ownerId } = coin + + if (!ownerId) { + return + } + + return ( + + ) +} + const renderPriceCell = (cellInfo: CoinCell) => { const coin = cellInfo.row.original const price = @@ -227,13 +224,27 @@ const renderBuyCell = ( const tableColumnMap = { tokenName: { id: 'tokenName', - Header: () => Coin, + Header: () => Coin, accessor: 'name', Cell: renderTokenNameCell, - minWidth: 150, + minWidth: 220, + width: 220, + maxWidth: Number.MAX_SAFE_INTEGER, disableSortBy: true, align: 'left' }, + artist: { + id: 'artist', + Header: () => Artist, + accessor: 'ownerId', + Cell: renderArtistCell, + minWidth: 140, + width: 140, + maxWidth: 140, + disableSortBy: true, + disableResizing: true, + align: 'left' + }, price: { id: 'price', Header: 'Price', @@ -241,8 +252,10 @@ const tableColumnMap = { Cell: renderPriceCell, disableSortBy: false, align: 'right', - width: 50, - minWidth: 50, + width: 104, + minWidth: 104, + maxWidth: 104, + disableResizing: true, sorter: numericSorter('price') }, totalVolumeUSD: { @@ -252,8 +265,10 @@ const tableColumnMap = { Cell: renderTotalVolumeUSDCell, disableSortBy: false, align: 'right', - width: 40, - minWidth: 40, + width: 120, + minWidth: 120, + maxWidth: 120, + disableResizing: true, sorter: numericSorter('totalVolumeUSD') }, marketCap: { @@ -263,8 +278,10 @@ const tableColumnMap = { Cell: renderMarketCapCell, disableSortBy: false, align: 'right', - width: 50, - minWidth: 50, + width: 120, + minWidth: 120, + maxWidth: 120, + disableResizing: true, sorter: numericSorter('marketCap') }, createdDate: { @@ -274,8 +291,10 @@ const tableColumnMap = { Cell: renderCreatedDateCell, disableSortBy: false, align: 'right', - width: 40, - minWidth: 40, + width: 104, + minWidth: 104, + maxWidth: 104, + disableResizing: true, sorter: dateSorter('createdAt') }, holders: { @@ -285,8 +304,10 @@ const tableColumnMap = { Cell: renderHoldersCell, disableSortBy: false, align: 'right', - width: 40, - minWidth: 40, + width: 88, + minWidth: 88, + maxWidth: 88, + disableResizing: true, sorter: numericSorter('holder') }, buy: { @@ -295,8 +316,10 @@ const tableColumnMap = { Cell: renderBuyCell, disableSortBy: true, align: 'right', - width: 30, - minWidth: 30 + width: 112, + minWidth: 112, + maxWidth: 112, + disableResizing: true } } @@ -332,7 +355,6 @@ export const ArtistCoinsTable = ({ const navigate = useNavigate() const { onOpen: openBuySellModal } = useBuySellModal() const { env } = useQueryContext() - const tableRef = useRef(null) const externalWalletAddress = useExternalWalletAddress() const { data: externalUsdcBalance } = useExternalWalletBalance({ mint: env.USDC_MINT_ADDRESS, @@ -346,7 +368,6 @@ export const ArtistCoinsTable = ({ externalUsdcBalance, externalAudioBalance }) - const [hiddenColumns, setHiddenColumns] = useState(null) const scrollContainerRef = useRef(null) const [sortMethod, setSortMethod] = useState( GetCoinsSortMethodEnum.MarketCap @@ -394,61 +415,8 @@ export const ArtistCoinsTable = ({ return (getScrollParent(scrollContainerRef.current) as HTMLElement) ?? null }, [mainContentRef]) - const resizeObserverRef = useRef(null) - - const updateColumnVisibility = useCallback(() => { - if (!tableRef.current) return - const width = tableRef.current.offsetWidth - if (width < 728) { - setHiddenColumns([ - tableColumnMap.totalVolumeUSD.id, - tableColumnMap.marketCap.id, - tableColumnMap.createdDate.id, - tableColumnMap.holders.id - ]) - } else if (width < 866) { - setHiddenColumns([ - tableColumnMap.marketCap.id, - tableColumnMap.createdDate.id, - tableColumnMap.holders.id - ]) - } else if (width < 972) { - setHiddenColumns([ - tableColumnMap.createdDate.id, - tableColumnMap.holders.id - ]) - } else if (width < 1074) { - setHiddenColumns([tableColumnMap.holders.id]) - } else { - setHiddenColumns(null) - } - }, []) - - const setTableNode = useCallback( - (node: HTMLDivElement | null) => { - scrollContainerRef.current = node - if (resizeObserverRef.current && tableRef.current) { - resizeObserverRef.current.unobserve(tableRef.current) - } - tableRef.current = node - if (!node) return - - if (!resizeObserverRef.current) { - resizeObserverRef.current = new ResizeObserver(() => { - updateColumnVisibility() - }) - } - resizeObserverRef.current.observe(node) - updateColumnVisibility() - }, - [updateColumnVisibility] - ) - - useEffect(() => { - return () => { - resizeObserverRef.current?.disconnect() - resizeObserverRef.current = null - } + const setTableNode = useCallback((node: HTMLDivElement | null) => { + scrollContainerRef.current = node }, []) const onSort = useCallback( @@ -489,10 +457,17 @@ export const ArtistCoinsTable = ({ ...baseColumns.buy, Cell: (cellInfo: CoinCell) => renderBuyCell(cellInfo, handleBuy) } - return Object.values(baseColumns).filter( - (column) => !hiddenColumns?.includes(column.id) - ) - }, [handleBuy, hiddenColumns]) + return [ + baseColumns.tokenName, + baseColumns.artist, + baseColumns.price, + baseColumns.totalVolumeUSD, + baseColumns.marketCap, + baseColumns.createdDate, + baseColumns.holders, + baseColumns.buy + ] + }, [handleBuy]) const showEmptyState = !isPending && (!coins || coins.length === 0) @@ -536,7 +511,7 @@ export const ArtistCoinsTable = ({ isEmptyRow={isEmptyRow} fetchMore={loadNextPage} fetchBatchSize={ARTIST_COINS_BATCH_SIZE} - tableHeaderClassName={styles.tableHeader} + responsiveColumns={RESPONSIVE_TABLE_POLICIES.artistCoinsLeaderboard} scrollRef={mainContentRef} /> diff --git a/packages/web/src/pages/audio-page/AudioWalletTransactions.tsx b/packages/web/src/pages/audio-page/AudioWalletTransactions.tsx index 2a630427751..90f79b8d5ae 100644 --- a/packages/web/src/pages/audio-page/AudioWalletTransactions.tsx +++ b/packages/web/src/pages/audio-page/AudioWalletTransactions.tsx @@ -61,7 +61,6 @@ const Disclaimer = () => { } export const AudioWalletTransactions = () => { - const [page, setPage] = useState(0) const [sortMethod, setSortMethod] = useState( GetAudioTransactionsSortMethodEnum.Date @@ -74,19 +73,25 @@ export const AudioWalletTransactions = () => { const dispatch = useDispatch() const setVisibility = useSetVisibility() + const { data: audioTransactionsCount = 0, isPending: isCountLoading } = + useAudioTransactionsCount() + + const requestedPageSize = + audioTransactionsCount > 0 + ? audioTransactionsCount + : DEFAULT_AUDIO_TRANSACTIONS_BATCH_SIZE + const { data: audioTransactions = [], isPending: isTransactionsLoading } = useAudioTransactions( { - page, + page: 0, + pageSize: requestedPageSize, sortMethod, sortDirection }, { refetchOnMount: 'always' } ) - const { data: audioTransactionsCount = 0, isPending: isCountLoading } = - useAudioTransactionsCount() - // Defaults: sort method = date, sort direction = desc const onSort = useCallback( (sortMethodInner: string, sortDirectionInner: string) => { @@ -100,8 +105,6 @@ export const AudioWalletTransactions = () => { ? GetAudioTransactionsSortDirectionEnum.Asc : GetAudioTransactionsSortDirectionEnum.Desc setSortDirection(sortDirectionRes) - // Reset page when sorting changes - setPage(0) }, [setSortMethod, setSortDirection] ) @@ -119,10 +122,6 @@ export const AudioWalletTransactions = () => { [dispatch, setVisibility] ) - const handleFetchPage = useCallback((newPage: number) => { - setPage(newPage) - }, []) - const tableLoading = isTransactionsLoading || isCountLoading const isEmpty = audioTransactions.length === 0 @@ -140,11 +139,7 @@ export const AudioWalletTransactions = () => { loading={tableLoading} onSort={onSort} onClickRow={onClickRow} - fetchPage={handleFetchPage} - pageSize={DEFAULT_AUDIO_TRANSACTIONS_BATCH_SIZE} - isPaginated showMoreLimit={AUDIO_TRANSACTIONS_SHOW_MORE_LIMIT} - totalRowCount={audioTransactionsCount} scrollRef={mainContentRef} /> )} diff --git a/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx b/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx index 588013b012a..4cced08ff9e 100644 --- a/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx +++ b/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx @@ -24,6 +24,7 @@ import { CollectionDogEar } from 'components/collection' import { CollectionHeader } from 'components/collection/desktop/CollectionHeader' import Page from 'components/page/Page' import { SuggestedTracks } from 'components/suggested-tracks' +import { RESPONSIVE_TABLE_POLICIES } from 'components/table/responsivePolicies' import { TracksTable } from 'components/tracks-table' import { useRequiresAccountCallback } from 'hooks/useRequiresAccount' import { useMainContentRef } from 'pages/MainContentContext' @@ -161,9 +162,8 @@ const CollectionPage = ({ type }: CollectionPageProps) => { // useMemo must be called before any conditional returns const tracksTableColumns = useMemo(() => { const columns = [ - 'playButton', + isAlbum ? 'playButton' : undefined, 'trackName', - isAlbum ? undefined : 'artistName', isAlbum ? 'date' : 'addedDate', 'length', areAllTracksPremium ? undefined : 'plays', @@ -330,6 +330,12 @@ const CollectionPage = ({ type }: CollectionPageProps) => { onClickPurchase={openPurchaseModal} onReorder={onReorderTracks} onSort={onSortTracks} + showArtistInTrackNameColumn={!isAlbum} + responsiveColumns={ + isAlbum + ? RESPONSIVE_TABLE_POLICIES.collectionAlbumTracks + : RESPONSIVE_TABLE_POLICIES.collectionPlaylistTracks + } isReorderable={ accountUserId !== null && accountUserId === playlistOwnerId && diff --git a/packages/web/src/pages/dashboard-page/components/ArtistDashboardAlbumsTab.tsx b/packages/web/src/pages/dashboard-page/components/ArtistDashboardAlbumsTab.tsx index b81573f6bef..1877b020c11 100644 --- a/packages/web/src/pages/dashboard-page/components/ArtistDashboardAlbumsTab.tsx +++ b/packages/web/src/pages/dashboard-page/components/ArtistDashboardAlbumsTab.tsx @@ -9,6 +9,7 @@ import { CollectionsTable, CollectionsTableColumn } from 'components/collections-table' +import { RESPONSIVE_TABLE_POLICIES } from 'components/table/responsivePolicies' import { useNavigateToPage } from 'hooks/useNavigateToPage' import styles from '../DashboardPage.module.css' @@ -69,6 +70,7 @@ export const ArtistDashboardAlbumsTab = ({ showMoreLimit={SHOW_MORE_LIMIT} totalRowCount={account.track_count} tableHeaderClassName={styles.tableHeader} + responsiveColumns={RESPONSIVE_TABLE_POLICIES.dashboardAlbums} /> ) diff --git a/packages/web/src/pages/dashboard-page/components/ArtistDashboardTracksTab.tsx b/packages/web/src/pages/dashboard-page/components/ArtistDashboardTracksTab.tsx index 0713767b556..55ca42007cf 100644 --- a/packages/web/src/pages/dashboard-page/components/ArtistDashboardTracksTab.tsx +++ b/packages/web/src/pages/dashboard-page/components/ArtistDashboardTracksTab.tsx @@ -6,6 +6,7 @@ import { Nullable } from '@audius/common/utils' import { Flex } from '@audius/harmony' import { useDispatch, useSelector } from 'react-redux' +import { RESPONSIVE_TABLE_POLICIES } from 'components/table/responsivePolicies' import { TracksTable, TracksTableColumn } from 'components/tracks-table' import { useNavigateToPage } from 'hooks/useNavigateToPage' @@ -88,6 +89,7 @@ export const ArtistDashboardTracksTab = ({ loading={tracksStatus === Status.LOADING} isPaginated tableHeaderClassName={styles.tableHeader} + responsiveColumns={RESPONSIVE_TABLE_POLICIES.dashboardTracks} shouldShowGatedType /> diff --git a/packages/web/src/pages/history-page/components/desktop/HistoryPage.tsx b/packages/web/src/pages/history-page/components/desktop/HistoryPage.tsx index bd27c0d9c3e..d9a76a42862 100644 --- a/packages/web/src/pages/history-page/components/desktop/HistoryPage.tsx +++ b/packages/web/src/pages/history-page/components/desktop/HistoryPage.tsx @@ -21,7 +21,8 @@ import FilterInput from 'components/filter-input/FilterInput' import { Header } from 'components/header/desktop/Header' import Page from 'components/page/Page' import { dateSorter } from 'components/table' -import { TrackTableLineup } from 'components/tracks-table' +import { RESPONSIVE_TABLE_POLICIES } from 'components/table/responsivePolicies' +import { TrackTableLineup, TracksTableColumn } from 'components/tracks-table' import EmptyTable from 'components/tracks-table/EmptyTable' import { useMainContentRef } from 'pages/MainContentContext' @@ -39,6 +40,15 @@ export type HistoryPageProps = { } const pageSize = 50 +const historyTableColumns: TracksTableColumn[] = [ + 'trackName', + 'releaseDate', + 'listenDate', + 'length', + 'plays', + 'reposts', + 'overflowActions' +] export const HistoryPage = ({ title, description }: HistoryPageProps) => { const { spacing } = useTheme() @@ -148,8 +158,11 @@ export const HistoryPage = ({ title, description }: HistoryPageProps) => { ) : ( { } }) - const getTracksTableData = (): [LibraryPageTrack[], number] => { - let [data, activeIndex] = getFilteredData(entries) - if (!hasReachedEnd) { - // Add in some empty rows to show user that more are loading in - data = data.concat(new Array(5).fill({ kind: Kind.EMPTY })) + const { trackRows, activeIndex } = useMemo(() => { + if (!(status === Status.SUCCESS || entries.length)) { + return { trackRows: [] as LibraryPageTrack[], activeIndex: -1 } } - return [data, activeIndex] - } + const [rows, index] = getFilteredData(entries) + return { trackRows: rows, activeIndex: index } + }, [status, entries, getFilteredData]) - const [dataSource, activeIndex] = - status === Status.SUCCESS || entries.length - ? getTracksTableData() - : [[], -1] + const dataSource = useMemo(() => { + if (hasReachedEnd) return trackRows + // Add in some empty rows to show user that more are loading in. + return trackRows.concat(new Array(5).fill({ kind: Kind.EMPTY })) + }, [hasReachedEnd, trackRows]) const isEmpty = entries.length === 0 || @@ -236,6 +235,8 @@ const LibraryPage = () => { onSort={allTracksFetched ? onSortTracks : onSortChange} playing={queuedAndPlaying} activeIndex={activeIndex} + showArtistInTrackNameColumn + responsiveColumns={RESPONSIVE_TABLE_POLICIES.libraryTracks} scrollRef={mainContentRef} useLocalSort={allTracksFetched} fetchBatchSize={50} diff --git a/packages/web/src/pages/library-page/hooks/useLibraryPage.ts b/packages/web/src/pages/library-page/hooks/useLibraryPage.ts index a4bf2f064a1..8da6a36ab39 100644 --- a/packages/web/src/pages/library-page/hooks/useLibraryPage.ts +++ b/packages/web/src/pages/library-page/hooks/useLibraryPage.ts @@ -447,6 +447,22 @@ export const useLibraryPage = () => { })) }, []) + const formattedEntries = useMemo( + () => formatMetadata(tracks.entries), + [formatMetadata, tracks.entries] + ) + + const filteredFormattedEntries = useMemo(() => { + const filterText = (state.filterText ?? '').toLowerCase() + return formattedEntries + .filter((item) => !item._marked_deleted && !item.is_delete) + .filter( + (item) => + item.title?.toLowerCase().indexOf(filterText) > -1 || + item.user?.name.toLowerCase().indexOf(filterText) > -1 + ) + }, [formattedEntries, state.filterText]) + const isQueued = useCallback(() => { return tracks.entries.some( (entry: any) => currentQueueItem.uid === entry.uid @@ -464,44 +480,44 @@ export const useLibraryPage = () => { const getFormattedData = useCallback( (trackMetadatas: LibraryPageTrack[]): [LibraryPageTrack[], number] => { const playingUid = getPlayingUid() - const activeIndex = tracks.entries.findIndex( - ({ uid }: any) => uid === playingUid - ) - const filteredMetadata = formatMetadata(trackMetadatas) - const filteredIndex = - activeIndex > -1 - ? filteredMetadata.findIndex( - (metadata) => metadata.uid === playingUid - ) - : activeIndex + const filteredMetadata = + trackMetadatas === tracks.entries + ? formattedEntries + : formatMetadata(trackMetadatas) + const filteredIndex = playingUid + ? filteredMetadata.findIndex((metadata) => metadata.uid === playingUid) + : -1 return [filteredMetadata, filteredIndex] }, - [getPlayingUid, tracks.entries, formatMetadata] + [getPlayingUid, tracks.entries, formattedEntries, formatMetadata] ) const getFilteredData = useCallback( (trackMetadatas: LibraryPageTrack[]): [LibraryPageTrack[], number] => { - const filterText = state.filterText ?? '' const playingUid = getPlayingUid() - const activeIndex = tracks.entries.findIndex( - ({ uid }: any) => uid === playingUid - ) - const filteredMetadata = formatMetadata(trackMetadatas) - .filter((item) => !item._marked_deleted && !item.is_delete) - .filter( - (item) => - item.title?.toLowerCase().indexOf(filterText.toLowerCase()) > -1 || - item.user?.name.toLowerCase().indexOf(filterText.toLowerCase()) > -1 - ) - const filteredIndex = - activeIndex > -1 - ? filteredMetadata.findIndex( - (metadata) => metadata.uid === playingUid - ) - : activeIndex + const filterText = (state.filterText ?? '').toLowerCase() + const filteredMetadata = + trackMetadatas === tracks.entries + ? filteredFormattedEntries + : formatMetadata(trackMetadatas) + .filter((item) => !item._marked_deleted && !item.is_delete) + .filter( + (item) => + item.title?.toLowerCase().indexOf(filterText) > -1 || + item.user?.name.toLowerCase().indexOf(filterText) > -1 + ) + const filteredIndex = playingUid + ? filteredMetadata.findIndex((metadata) => metadata.uid === playingUid) + : -1 return [filteredMetadata, filteredIndex] }, - [state.filterText, getPlayingUid, tracks.entries, formatMetadata] + [ + state.filterText, + getPlayingUid, + tracks.entries, + filteredFormattedEntries, + formatMetadata + ] ) const onClickRow = useCallback( diff --git a/packages/web/src/pages/pay-and-earn-page/components/PurchasesTab.tsx b/packages/web/src/pages/pay-and-earn-page/components/PurchasesTab.tsx index b9f46b1e2dd..43d369f0040 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/PurchasesTab.tsx +++ b/packages/web/src/pages/pay-and-earn-page/components/PurchasesTab.tsx @@ -50,8 +50,7 @@ const sortMethods: { [k in PurchasesTableSortMethod]: GetPurchasesSortMethodEnum } = { contentId: GetPurchasesSortMethodEnum.ContentTitle, - createdAt: GetPurchasesSortMethodEnum.Date, - sellerUserId: GetPurchasesSortMethodEnum.ArtistName + createdAt: GetPurchasesSortMethodEnum.Date } const sortDirections: { diff --git a/packages/web/src/pages/pay-and-earn-page/components/PurchasesTable.tsx b/packages/web/src/pages/pay-and-earn-page/components/PurchasesTable.tsx index b91ea9d92fa..afd4b42ea43 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/PurchasesTable.tsx +++ b/packages/web/src/pages/pay-and-earn-page/components/PurchasesTable.tsx @@ -4,27 +4,27 @@ import { USDCPurchaseDetails } from '@audius/common/models' import { dayjs } from '@audius/common/utils' import { USDC } from '@audius/fixed-decimal' -import { UserLink } from 'components/link' import { Table } from 'components/table' +import { RESPONSIVE_TABLE_POLICIES } from 'components/table/responsivePolicies' import styles from '../PayAndEarnPage.module.css' import { PurchaseCell, PurchaseRow } from '../types' import { isEmptyPurchaseRow } from '../utils' -import { TrackNameWithArtwork } from './TrackNameWithArtwork' +import { + PurchaseArtistLink, + TrackNameWithArtwork +} from './TrackNameWithArtwork' +import artworkStyles from './TrackNameWithArtwork.module.css' export type PurchasesTableColumn = | 'contentName' - | 'artist' | 'date' | 'value' | 'spacerLeft' | 'spacerRight' -export type PurchasesTableSortMethod = - | 'contentId' - | 'sellerUserId' - | 'createdAt' +export type PurchasesTableSortMethod = 'contentId' | 'createdAt' export type PurchasesTableSortDirection = 'asc' | 'desc' type PurchasesTableProps = { @@ -44,9 +44,7 @@ type PurchasesTableProps = { } const defaultColumns: PurchasesTableColumn[] = [ - 'spacerLeft', 'contentName', - 'artist', 'date', 'value', 'spacerRight' @@ -54,13 +52,14 @@ const defaultColumns: PurchasesTableColumn[] = [ // Cell Render Functions const renderContentNameCell = (cellInfo: PurchaseCell) => { - const { contentId, contentType } = cellInfo.row.original - return -} - -const renderArtistCell = (cellInfo: PurchaseCell) => { - const { sellerUserId } = cellInfo.row.original - return + const { contentId, contentType, sellerUserId } = cellInfo.row.original + return ( + } + /> + ) } const renderDateCell = (cellInfo: PurchaseCell) => { @@ -81,22 +80,15 @@ const renderValueCell = (cellInfo: PurchaseCell) => { const tableColumnMap = { contentName: { id: 'contentName', - Header: 'Purchases', + Header: ( + Purchases + ), accessor: 'contentId', Cell: renderContentNameCell, width: 480, disableSortBy: false, align: 'left' }, - artist: { - id: 'artist', - Header: 'Artist', - accessor: 'sellerUserId', - Cell: renderArtistCell, - maxWidth: 200, - disableSortBy: false, - align: 'left' - }, date: { id: 'date', Header: 'Date', @@ -174,6 +166,7 @@ export const PurchasesTable = ({ scrollRef={scrollRef} fetchBatchSize={fetchBatchSize} wrapperClassName={styles.tableWrapper} + responsiveColumns={RESPONSIVE_TABLE_POLICIES.purchases} /> ) } diff --git a/packages/web/src/pages/pay-and-earn-page/components/SalesTable.tsx b/packages/web/src/pages/pay-and-earn-page/components/SalesTable.tsx index f522f5fefa6..7785481566e 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/SalesTable.tsx +++ b/packages/web/src/pages/pay-and-earn-page/components/SalesTable.tsx @@ -7,6 +7,7 @@ import { dayjs } from '@audius/common/utils' import { USDC } from '@audius/fixed-decimal' import { Table } from 'components/table' +import { RESPONSIVE_TABLE_POLICIES } from 'components/table/responsivePolicies' import styles from '../PayAndEarnPage.module.css' import { PurchaseCell, PurchaseRow } from '../types' @@ -182,6 +183,7 @@ export const SalesTable = ({ tableHeaderClassName={ isNetworkCutEnabled ? styles.tableHeaderSmallPadding : undefined } + responsiveColumns={RESPONSIVE_TABLE_POLICIES.sales} /> ) } diff --git a/packages/web/src/pages/pay-and-earn-page/components/TrackNameWithArtwork.module.css b/packages/web/src/pages/pay-and-earn-page/components/TrackNameWithArtwork.module.css index 668f34ca35e..778bc0aa7fd 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/TrackNameWithArtwork.module.css +++ b/packages/web/src/pages/pay-and-earn-page/components/TrackNameWithArtwork.module.css @@ -1,14 +1,47 @@ .container { display: inline-flex; align-items: center; - gap: var(--harmony-unit-6); + gap: var(--harmony-unit-2); max-width: 100%; + min-width: 0; } .artwork { - height: var(--harmony-unit-10); - width: var(--harmony-unit-10); + height: 48px; + width: 48px; border-radius: var(--harmony-unit-1); border: 1px solid var(--harmony-border-default); overflow: hidden; + flex-shrink: 0; +} + +.textContainer { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + gap: var(--harmony-unit-1); +} + +.titleText { + display: block; + width: 100%; + min-width: 0; + line-height: 125%; +} + +.artistText { + display: block; + width: 100%; + min-width: 0; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--harmony-n-500); + line-height: 125%; +} + +.contentHeaderWithArtwork { + padding-left: 56px; } diff --git a/packages/web/src/pages/pay-and-earn-page/components/TrackNameWithArtwork.tsx b/packages/web/src/pages/pay-and-earn-page/components/TrackNameWithArtwork.tsx index c44ffa36e83..0405adb8448 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/TrackNameWithArtwork.tsx +++ b/packages/web/src/pages/pay-and-earn-page/components/TrackNameWithArtwork.tsx @@ -1,8 +1,11 @@ +import { ReactNode } from 'react' + import { useCollection, useTrack } from '@audius/common/api' import { SquareSizes, USDCContentPurchaseType } from '@audius/common/models' import { Skeleton, Text } from '@audius/harmony' import DynamicImage from 'components/dynamic-image/DynamicImage' +import { UserLink } from 'components/link' import { useCollectionCoverArt } from 'hooks/useCollectionCoverArt' import { useTrackCoverArt } from 'hooks/useTrackCoverArt' @@ -10,10 +13,12 @@ import styles from './TrackNameWithArtwork.module.css' export const TrackNameWithArtwork = ({ id, - contentType + contentType, + secondary }: { id: number contentType: USDCContentPurchaseType + secondary?: ReactNode }) => { const isTrack = contentType === USDCContentPurchaseType.TRACK const { data: trackTitle, isPending: isTrackPending } = useTrack(id, { @@ -43,9 +48,32 @@ export const TrackNameWithArtwork = ({ ) : ( <> - {title} +
+ + {title} + + {secondary} +
)}
) } + +export const PurchaseArtistLink = ({ userId }: { userId: number }) => ( + +) diff --git a/packages/web/src/pages/pay-and-earn-page/components/WithdrawalsTable.module.css b/packages/web/src/pages/pay-and-earn-page/components/WithdrawalsTable.module.css index 833bb3e49ec..726b86b2306 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/WithdrawalsTable.module.css +++ b/packages/web/src/pages/pay-and-earn-page/components/WithdrawalsTable.module.css @@ -3,3 +3,7 @@ overflow: hidden; text-overflow: ellipsis; } + +.methodHeader { + padding-left: 0; +} diff --git a/packages/web/src/pages/pay-and-earn-page/components/WithdrawalsTable.tsx b/packages/web/src/pages/pay-and-earn-page/components/WithdrawalsTable.tsx index 8032925ad2d..961aa56138c 100644 --- a/packages/web/src/pages/pay-and-earn-page/components/WithdrawalsTable.tsx +++ b/packages/web/src/pages/pay-and-earn-page/components/WithdrawalsTable.tsx @@ -8,6 +8,7 @@ import { dayjs } from '@audius/common/utils' import { USDC } from '@audius/fixed-decimal' import { Table } from 'components/table' +import { RESPONSIVE_TABLE_POLICIES } from 'components/table/responsivePolicies' import payAndEarnStyles from '../PayAndEarnPage.module.css' import { TransactionCell, TransactionRow } from '../types' @@ -47,7 +48,6 @@ type WithdrawalsTableProps = { } const defaultColumns: WithdrawalsTableColumn[] = [ - 'spacerLeft', 'destination', 'date', 'amount', @@ -92,7 +92,7 @@ const renderAmountCell = (cellInfo: TransactionCell) => { const tableColumnMap = { destination: { id: 'destination', - Header: 'Method', + Header: Method, accessor: 'metadata', Cell: renderDestinationCell, width: 480, @@ -176,6 +176,7 @@ export const WithdrawalsTable = ({ scrollRef={scrollRef} fetchBatchSize={fetchBatchSize} wrapperClassName={payAndEarnStyles.tableWrapper} + responsiveColumns={RESPONSIVE_TABLE_POLICIES.withdrawals} /> ) }