diff --git a/Dockerfile b/Dockerfile index 53b069ae01..fa9d302bce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,8 +25,8 @@ FROM ${BASE_REGISTRY}/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby # Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA -# Example: v4.3.0-nightly.2023.11.09+pr-123456 -# Overwrite existence of 'alpha.X' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023.11.09"] +# Example: v4.3.0-nightly.2023-11-09+pr-123456 +# Overwrite existence of 'alpha.X' in version.rb [--build-arg MASTODON_VERSION_PRERELEASE="nightly.2023-11-09"] ARG MASTODON_VERSION_PRERELEASE="" # Append build metadata or fork information to version.rb [--build-arg MASTODON_VERSION_METADATA="pr-123456"] ARG MASTODON_VERSION_METADATA="" @@ -48,29 +48,27 @@ ARG GID="991" # Apply Mastodon build options based on options above ENV \ - # Apply Mastodon version information MASTODON_VERSION_PRERELEASE="${MASTODON_VERSION_PRERELEASE}" \ MASTODON_VERSION_METADATA="${MASTODON_VERSION_METADATA}" \ SOURCE_COMMIT="${SOURCE_COMMIT}" \ - # Apply Mastodon static files and YJIT options - RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES} \ - RUBY_YJIT_ENABLE=${RUBY_YJIT_ENABLE} \ - # Apply timezone - TZ=${TZ} + RAILS_SERVE_STATIC_FILES="${RAILS_SERVE_STATIC_FILES}" \ + RUBY_YJIT_ENABLE="${RUBY_YJIT_ENABLE}" \ + TZ="${TZ}" +# Configure runtime environment +# BIND: IP to bind Mastodon to when serving traffic +# NODE_ENV/RAILS_ENV: production settings for Node.js and Ruby on Rails +# DEBIAN_FRONTEND: suppress interactive prompts +# PATH: add Ruby and Mastodon installation directories +# MALLOC_CONF: optimize jemalloc 5.x performance +# MASTODON_SIDEKIQ_READY_FILENAME: Sidekiq readiness check filename for Kubernetes ENV \ - # Configure the IP to bind Mastodon to when serving traffic BIND="0.0.0.0" \ - # Use production settings for Yarn, Node.js and related tools NODE_ENV="production" \ - # Use production settings for Ruby on Rails RAILS_ENV="production" \ - # Add Ruby and Mastodon installation to the PATH DEBIAN_FRONTEND="noninteractive" \ PATH="${PATH}:/opt/ruby/bin:/opt/mastodon/bin" \ - # Optimize jemalloc 5.x performance MALLOC_CONF="narenas:2,background_thread:true,thp:never,dirty_decay_ms:1000,muzzy_decay_ms:0" \ - # Sidekiq will touch tmp/sidekiq_process_has_started_and_will_begin_processing_jobs to indicate it is ready. This can be used for a readiness check in Kubernetes MASTODON_SIDEKIQ_READY_FILENAME=sidekiq_process_has_started_and_will_begin_processing_jobs # Set default shell used for running commands @@ -99,10 +97,10 @@ RUN \ # Mount Apt cache and lib directories from Docker buildx caches --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ - # Apt update & upgrade to check for security updates to Debian image + # Update package list and upgrade system packages apt-get update; \ apt-get dist-upgrade -yq; \ - # Install jemalloc, curl and other necessary components + # Install jemalloc and other necessary components apt-get install -y --no-install-recommends \ curl \ file \ @@ -112,6 +110,42 @@ RUN \ tini \ tzdata \ wget \ + # Mastodon components + libexpat1 \ + libglib2.0-0t64 \ + libicu76 \ + libidn12 \ + libpq5 \ + libreadline8t64 \ + libssl3t64 \ + libyaml-0-2 \ + # libvips components + libcgif0 \ + libexif12 \ + libheif1 \ + libhwy1t64 \ + libimagequant0 \ + libjpeg62-turbo \ + liblcms2-2 \ + libspng0 \ + libtiff6 \ + libwebp7 \ + libwebpdemux2 \ + libwebpmux3 \ + # ffmpeg components + libdav1d7 \ + libmp3lame0 \ + libopencore-amrnb0 \ + libopencore-amrwb0 \ + libopus0 \ + libsnappy1v5 \ + libtheora0 \ + libvorbis0a \ + libvorbisenc2 \ + libvorbisfile3 \ + libvpx9 \ + libx264-164 \ + libx265-215 \ ; \ # Patch Ruby to use jemalloc patchelf --add-needed libjemalloc.so.2 /usr/local/bin/ruby; \ @@ -120,42 +154,37 @@ RUN \ patchelf \ ; -# Create temporary build layer from base image -FROM ruby AS build +# Build stage for media libraries (libvips, ffmpeg) +FROM ${BASE_REGISTRY}/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS media-build ARG TARGETPLATFORM +# Set default shell used for running commands +SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-c"] + # hadolint ignore=DL3008 RUN \ - # Mount Apt cache and lib directories from Docker buildx caches - --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ - --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ - # Install build tools and bundler dependencies from APT + --mount=type=cache,id=apt-native-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-native-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ + # Remove automatic apt cache Docker cleanup scripts + rm -f /etc/apt/apt.conf.d/docker-clean; \ + # Install build tools for native libraries + apt-get update; \ apt-get install -y --no-install-recommends \ autoconf \ automake \ build-essential \ - cmake \ - git \ - libgdbm-dev \ - libglib2.0-dev \ - libgmp-dev \ - libicu-dev \ - libidn-dev \ - libpq-dev \ - libssl-dev \ libtool \ - libyaml-dev \ meson \ nasm \ pkg-config \ - shared-mime-info \ xz-utils \ # libvips components libcgif-dev \ libexif-dev \ libexpat1-dev \ libgirepository1.0-dev \ + libglib2.0-dev \ libheif-dev \ libhwy-dev \ libimagequant-dev \ @@ -176,8 +205,8 @@ RUN \ libx265-dev \ ; -# Create temporary libvips specific build layer from build layer -FROM build AS libvips +# Create temporary libvips specific build layer +FROM media-build AS libvips # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips @@ -192,15 +221,16 @@ RUN tar xf vips-${VIPS_VERSION}.tar.xz; WORKDIR /usr/local/libvips/src/vips-${VIPS_VERSION} -# Configure and compile libvips -RUN \ - meson setup build --prefix /usr/local/libvips --libdir=lib -Ddeprecated=false -Dintrospection=disabled -Dmodules=disabled -Dexamples=false; \ - cd build; \ - ninja; \ - ninja install; +# Configure libvips +RUN meson setup build --prefix /usr/local/libvips --libdir=lib -Ddeprecated=false -Dintrospection=disabled -Dmodules=disabled -Dexamples=false -# Create temporary ffmpeg specific build layer from build layer -FROM build AS ffmpeg +WORKDIR /usr/local/libvips/src/vips-${VIPS_VERSION}/build + +# Compile and install libvips +RUN ninja && ninja install + +# Create temporary ffmpeg specific build layer +FROM media-build AS ffmpeg # ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"] # renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg @@ -241,17 +271,48 @@ RUN \ --enable-shared \ --enable-version3 \ ; \ - make -j$(nproc); \ + make -j"$(nproc)"; \ make install; +# Create temporary build layer from base image for Ruby dependencies +FROM ruby AS ruby-build + +ARG TARGETPLATFORM + +# hadolint ignore=DL3008 +RUN \ + # Mount Apt cache and lib directories from Docker buildx caches + --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ + # Install build tools and bundler dependencies from APT + apt-get install -y --no-install-recommends \ + build-essential \ + git \ + libgdbm-dev \ + libgmp-dev \ + libicu-dev \ + libidn-dev \ + libpq-dev \ + libssl-dev \ + libyaml-dev \ + shared-mime-info \ + zlib1g-dev \ + ; + # Create temporary bundler specific build layer from build layer -FROM build AS bundler +FROM ruby-build AS bundler ARG TARGETPLATFORM # Copy Gemfile config into working directory COPY Gemfile* /opt/mastodon/ +# Copy libvips for gems that need it during install +COPY --from=libvips /usr/local/libvips/lib /usr/local/lib +COPY --from=libvips /usr/local/libvips/include /usr/local/include + +RUN ldconfig + RUN \ # Mount Ruby Gem caches --mount=type=cache,id=gem-cache-${TARGETPLATFORM},target=/usr/local/bundle/cache/,sharing=locked \ @@ -267,7 +328,7 @@ RUN \ bundle install -j"$(nproc)"; # Create temporary assets build layer from build layer -FROM build AS precompiler +FROM ruby-build AS precompiler ARG TARGETPLATFORM @@ -311,53 +372,6 @@ FROM ruby AS mastodon ARG TARGETPLATFORM -# hadolint ignore=DL3008 -RUN \ - # Mount Apt cache and lib directories from Docker buildx caches - --mount=type=cache,id=apt-cache-${TARGETPLATFORM},target=/var/cache/apt,sharing=locked \ - --mount=type=cache,id=apt-lib-${TARGETPLATFORM},target=/var/lib/apt,sharing=locked \ - # Mount Corepack and Yarn caches from Docker buildx caches - --mount=type=cache,id=corepack-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/corepack,sharing=locked \ - --mount=type=cache,id=yarn-cache-${TARGETPLATFORM},target=/usr/local/share/.cache/yarn,sharing=locked \ - # Apt update install non-dev versions of necessary components - apt-get install -y --no-install-recommends \ - libexpat1 \ - libglib2.0-0t64 \ - libicu76 \ - libidn12 \ - libpq5 \ - libreadline8t64 \ - libssl3t64 \ - libyaml-0-2 \ - # libvips components - libcgif0 \ - libexif12 \ - libheif1 \ - libhwy1t64 \ - libimagequant0 \ - libjpeg62-turbo \ - liblcms2-2 \ - libspng0 \ - libtiff6 \ - libwebp7 \ - libwebpdemux2 \ - libwebpmux3 \ - # ffmpeg components - libdav1d7 \ - libmp3lame0 \ - libopencore-amrnb0 \ - libopencore-amrwb0 \ - libopus0 \ - libsnappy1v5 \ - libtheora0 \ - libvorbis0a \ - libvorbisenc2 \ - libvorbisfile3 \ - libvpx9 \ - libx264-164 \ - libx265-215 \ - ; - # Copy Mastodon sources into final layer COPY . /opt/mastodon/ diff --git a/Gemfile b/Gemfile index 44b2b3d102..23c0a90002 100644 --- a/Gemfile +++ b/Gemfile @@ -102,10 +102,10 @@ gem 'rdf-normalize', '~> 0.5' gem 'prometheus_exporter', '~> 2.2', require: false -gem 'opentelemetry-api', '~> 1.8.0' +gem 'opentelemetry-api', '~> 1.9.0' group :opentelemetry do - gem 'opentelemetry-exporter-otlp', '~> 0.32.0', require: false + gem 'opentelemetry-exporter-otlp', '~> 0.33.0', require: false gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 15a40835ff..9d95dd111f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -99,7 +99,7 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1227.0) + aws-partitions (1.1236.0) aws-sdk-core (3.244.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -111,7 +111,7 @@ GEM aws-sdk-kms (1.123.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.217.0) + aws-sdk-s3 (1.219.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -278,7 +278,7 @@ GEM raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) - google-protobuf (4.34.0) + google-protobuf (4.34.1) bigdecimal rake (~> 13.3) googleapis-common-protos-types (1.22.0) @@ -451,7 +451,7 @@ GEM mime-types-data (3.2026.0317) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (6.0.2) + minitest (6.0.3) drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) @@ -509,11 +509,11 @@ GEM openssl (4.0.1) openssl-signature_algorithm (1.3.0) openssl (> 2.0) - opentelemetry-api (1.8.0) + opentelemetry-api (1.9.0) logger - opentelemetry-common (0.23.0) + opentelemetry-common (0.24.0) opentelemetry-api (~> 1.0) - opentelemetry-exporter-otlp (0.32.0) + opentelemetry-exporter-otlp (0.33.0) google-protobuf (>= 3.18) googleapis-common-protos-types (~> 1.3) opentelemetry-api (~> 1.1) @@ -576,14 +576,15 @@ GEM opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-sidekiq (0.28.1) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-registry (0.4.0) + opentelemetry-registry (0.5.0) opentelemetry-api (~> 1.1) - opentelemetry-sdk (1.10.0) + opentelemetry-sdk (1.11.0) + logger opentelemetry-api (~> 1.1) opentelemetry-common (~> 0.20) opentelemetry-registry (~> 0.2) opentelemetry-semantic_conventions - opentelemetry-semantic_conventions (1.36.0) + opentelemetry-semantic_conventions (1.37.0) opentelemetry-api (~> 1.0) orm_adapter (0.5.0) ostruct (0.6.3) @@ -851,8 +852,8 @@ GEM concurrent-ruby zeitwerk stringio (3.2.0) - strong_migrations (2.5.2) - activerecord (>= 7.1) + strong_migrations (2.6.0) + activerecord (>= 7.2) swd (2.0.3) activesupport (>= 3) attr_required (>= 0.0.5) @@ -1020,8 +1021,8 @@ DEPENDENCIES omniauth-rails_csrf_protection (~> 2.0) omniauth-saml (~> 2.0) omniauth_openid_connect (~> 0.8.0) - opentelemetry-api (~> 1.8.0) - opentelemetry-exporter-otlp (~> 0.32.0) + opentelemetry-api (~> 1.9.0) + opentelemetry-exporter-otlp (~> 0.33.0) opentelemetry-instrumentation-active_job (~> 0.10.0) opentelemetry-instrumentation-active_model_serializers (~> 0.24.0) opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0) diff --git a/app/javascript/flavours/glitch/api_types/accounts.ts b/app/javascript/flavours/glitch/api_types/accounts.ts index 1c340c387a..85ee62ad07 100644 --- a/app/javascript/flavours/glitch/api_types/accounts.ts +++ b/app/javascript/flavours/glitch/api_types/accounts.ts @@ -53,7 +53,7 @@ export interface BaseApiAccountJSON { header_static: string; header_description: string; id: string; - last_status_at: string; + last_status_at: string | null; locked: boolean; show_media: boolean; show_media_replies: boolean; diff --git a/app/javascript/flavours/glitch/components/account_list_item/index.tsx b/app/javascript/flavours/glitch/components/account_list_item/index.tsx index 7c4ed47f48..142a053c5f 100644 --- a/app/javascript/flavours/glitch/components/account_list_item/index.tsx +++ b/app/javascript/flavours/glitch/components/account_list_item/index.tsx @@ -11,7 +11,6 @@ import { useRelationship } from 'flavours/glitch/hooks/useRelationship'; import type { Relationship } from 'flavours/glitch/models/relationship'; import { EmojiHTML } from '../emoji/html'; -import { FamiliarFollowers } from '../familiar_followers'; import { FollowButton } from '../follow_button'; import { FormattedDateWrapper } from '../formatted_date'; import { NumberFields, NumberFieldsItem } from '../number_fields'; @@ -31,10 +30,11 @@ interface Props { accountId: string | undefined; stats?: Stat[]; renderButton?: (options: RenderButtonOptions) => React.ReactNode; + withBio?: boolean; withBorder?: boolean; } -const DEFAULT_STATS: Stat[] = ['followers', 'following', 'joined']; +const DEFAULT_STATS: Stat[] = ['followers', 'posts', 'last-active']; /** * Extended account list item with bio, verified link badge, @@ -46,6 +46,7 @@ const DEFAULT_STATS: Stat[] = ['followers', 'following', 'joined']; export const AccountListItem: React.FC = ({ accountId, stats = DEFAULT_STATS, + withBio = true, withBorder = true, renderButton = defaultRenderButton, }) => { @@ -147,11 +148,15 @@ export const AccountListItem: React.FC = ({ /> } > - + {account.last_status_at ? ( + + ) : ( + '-' + )} )} {firstVerifiedField && ( @@ -161,8 +166,7 @@ export const AccountListItem: React.FC = ({ /> )} - - {account.note.length > 0 && ( + {withBio && account.note.length > 0 && ( = ({ @@ -23,6 +29,7 @@ export const EmptyMessage: React.FC = ({ suspended, hidden, blockedBy, + withoutAddCollectionButton, }) => { const { acct } = useParams<{ acct?: string }>(); const me = useCurrentAccountId(); @@ -30,6 +37,17 @@ export const EmptyMessage: React.FC = ({ const ElephantImage = theme === 'dark' ? ElephantDarkImage : ElephantLightImage; + const dispatch = useAppDispatch(); + + const confirmHideFeaturedTab = useCallback(() => { + void dispatch( + openModal({ + modalType: 'ACCOUNT_HIDE_FEATURED_TAB', + modalProps: {}, + }), + ); + }, [dispatch]); + if (!accountId) { return null; } @@ -49,17 +67,31 @@ export const EmptyMessage: React.FC = ({ image={image} title={ + } + message={ + } > - + {!withoutAddCollectionButton && ( + + + + )} + ); } else { @@ -72,7 +104,7 @@ export const EmptyMessage: React.FC = ({ message = ( ); } @@ -93,47 +125,21 @@ export const EmptyMessage: React.FC = ({ /> ); } else { - // Standard other account empty state. - title = ( - - ); - if (hasCollections) { - if (acct) { - message = ( - - ); - } else { - message = ( - - ); - } + if (acct) { + title = ( + + ); } else { - if (acct) { - message = ( - - ); - } else { - message = ( - - ); - } + title = ( + + ); } } diff --git a/app/javascript/flavours/glitch/features/account_featured/components/subheading.module.scss b/app/javascript/flavours/glitch/features/account_featured/components/subheading.module.scss new file mode 100644 index 0000000000..aee5d65af2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_featured/components/subheading.module.scss @@ -0,0 +1,37 @@ +.subheading { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 52px; + padding: 12px 16px; + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + color: var(--color-text-primary); + background: var(--color-bg-secondary); +} + +.link { + display: flex; + padding-block: 4px; + font-size: 13px; + font-weight: 500; + text-transform: none; + text-decoration: none; + color: inherit; + + &:hover { + text-decoration: underline; + } + + &:focus-visible { + outline: var(--outline-focus-default); + outline-offset: 2px; + } + + :global(.icon) { + width: 18px; + height: 18px; + } +} diff --git a/app/javascript/flavours/glitch/features/account_featured/components/subheading.tsx b/app/javascript/flavours/glitch/features/account_featured/components/subheading.tsx new file mode 100644 index 0000000000..f1e6110708 --- /dev/null +++ b/app/javascript/flavours/glitch/features/account_featured/components/subheading.tsx @@ -0,0 +1,42 @@ +import type { ComponentPropsWithoutRef } from 'react'; + +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; + +import type { IconProp } from '@/flavours/glitch/components/icon'; +import { Icon } from '@/flavours/glitch/components/icon'; +import { polymorphicForwardRef } from '@/types/polymorphic'; + +import classes from './subheading.module.scss'; + +export const Subheading = polymorphicForwardRef<'h2'>( + ({ as: Component = 'h2', children, className, ...props }, ref) => { + return ( + + {children} + + ); + }, +); + +interface SubheadingLinkProps extends ComponentPropsWithoutRef { + icon: IconProp; +} + +export const SubheadingLink: React.FC = ({ + icon, + children, + className, + ...props +}) => { + return ( + + + {children} + + ); +}; diff --git a/app/javascript/flavours/glitch/features/account_featured/index.tsx b/app/javascript/flavours/glitch/features/account_featured/index.tsx index 60e9531318..23e8d0e122 100644 --- a/app/javascript/flavours/glitch/features/account_featured/index.tsx +++ b/app/javascript/flavours/glitch/features/account_featured/index.tsx @@ -6,9 +6,10 @@ import { useHistory } from 'react-router'; import { List as ImmutableList } from 'immutable'; +import { AccountListItem } from '@/flavours/glitch/components/account_list_item'; import { useAccount } from '@/flavours/glitch/hooks/useAccount'; +import AddIcon from '@/material-icons/400-24px/add.svg?react'; import { fetchEndorsedAccounts } from 'flavours/glitch/actions/accounts'; -import { Account } from 'flavours/glitch/components/account'; import { ColumnBackButton } from 'flavours/glitch/components/column_back_button'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { RemoteHint } from 'flavours/glitch/components/remote_hint'; @@ -28,10 +29,13 @@ import { } from 'flavours/glitch/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; -import { CollectionListItem } from '../collections/detail/collection_list_item'; +import { CollectionListItem } from '../collections/components/collection_list_item'; import { areCollectionsEnabled } from '../collections/utils'; import { EmptyMessage } from './components/empty_message'; +import { Subheading, SubheadingLink } from './components/subheading'; + +const collectionsEnabled = areCollectionsEnabled(); const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ multiColumn, @@ -54,13 +58,12 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ if (accountId) { void dispatch(fetchEndorsedAccounts({ accountId })); - if (areCollectionsEnabled()) { + if (collectionsEnabled) { void dispatch(fetchAccountCollections({ accountId })); } } }, [accountId, dispatch]); - const isLoading = !accountId; const featuredAccountIds = useAppSelector( (state) => state.user_lists.getIn( @@ -68,8 +71,8 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ ImmutableList(), ) as ImmutableList, ); - const { collections, status } = useAppSelector((state) => - selectAccountCollections(state, accountId ?? null), + const { collections, status: collectionsLoadStatus } = useAppSelector( + (state) => selectAccountCollections(state, accountId ?? null), ); const listedCollections = collections.filter( // Hide unlisted and empty collections to avoid confusion @@ -78,6 +81,15 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ (item) => item.discoverable && !!item.item_count, ); + const hasCollections = + collectionsEnabled && + collectionsLoadStatus === 'idle' && + listedCollections.length > 0; + + const hasFeaturedAccounts = !featuredAccountIds.isEmpty(); + + const isLoading = !accountId || collectionsLoadStatus !== 'idle'; + if (accountId === null) { return ; } @@ -92,7 +104,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ ); } - if (featuredAccountIds.isEmpty() && listedCollections.length === 0) { + if (!hasFeaturedAccounts && !hasCollections) { return ( = ({ {accountId && ( )} - {listedCollections.length > 0 && status === 'idle' && ( - <> -

- -

- - {listedCollections.map((item, index) => ( - - ))} - - - )} {!featuredAccountIds.isEmpty() && ( <> -

+ -

+ {featuredAccountIds.map((featuredAccountId, index) => (
= ({ aria-posinset={index + 1} aria-setsize={featuredAccountIds.size} > - +
))}
)} + {collectionsEnabled && ( + <> + +

+ +

+ + + +
+ {hasCollections ? ( + + {listedCollections.map((item, index) => ( + + ))} + + ) : ( +